@rubytech/create-maxy-code 0.1.18 → 0.1.20
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/dist/__tests__/cdp-port-no-silent-fallback.test.js +2 -2
- package/package.json +2 -2
- package/payload/platform/config/brand-registry.json +4 -4
- package/payload/platform/config/brand.json +4 -4
- package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +1 -1
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +1 -1
- package/payload/platform/plugins/docs/references/deployment.md +6 -2
- package/payload/platform/scripts/installer-device-verify.sh +2 -2
- package/payload/platform/scripts/qa/onboarding-fresh-install.sh +435 -0
- package/payload/platform/scripts/redact-install-logs.sh +2 -2
- package/payload/platform/scripts/verify-skill-tool-surface.sh +1 -1
- package/payload/platform/scripts/vnc.sh +2 -2
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/brand-design/SKILL.md +1 -1
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/make-brochure/SKILL.md +9 -9
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/SKILL.md +7 -7
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/build.md +5 -5
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/copy.md +3 -3
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/images.md +1 -1
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/{page-landing.md → index-landing.md} +14 -14
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/{page.html → index.html} +4 -4
- package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/references/placeholders.md +6 -6
- package/payload/server/package.json +1 -1
- package/payload/server/public/brand-constants.json +1 -1
- package/payload/server/public/brand-defaults.css +1 -1
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// silent-fallback-masks-root-cause violation. The four runtime sites that
|
|
3
3
|
// previously substituted `9222 + offset` for a missing brand.json.cdpPort
|
|
4
4
|
// (paths.ts, admin/mcp/index.ts, vnc.sh, test-laptop-vnc-boot.sh) plus the
|
|
5
|
-
// installer-side brand stamp at packages/create-maxy/src/index.ts have all
|
|
5
|
+
// installer-side brand stamp at packages/create-maxy-code/src/index.ts have all
|
|
6
6
|
// been swept to loud-fail. This test asserts the three greps from criterion
|
|
7
7
|
// 2 of the task brief return zero matches; reintroducing any silent fallback
|
|
8
8
|
// fails CI immediately.
|
|
@@ -19,7 +19,7 @@ import { resolve } from "node:path";
|
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
20
|
const SELF_DIR = fileURLToPath(new URL(".", import.meta.url));
|
|
21
21
|
// dist/__tests__/ → repo root is four levels up
|
|
22
|
-
// (packages/create-maxy/dist/__tests__ → packages/create-maxy → packages → repo).
|
|
22
|
+
// (packages/create-maxy-code/dist/__tests__ → packages/create-maxy-code → packages → repo).
|
|
23
23
|
const REPO_ROOT = resolve(SELF_DIR, "..", "..", "..", "..");
|
|
24
24
|
function grepReturns(pattern, includes) {
|
|
25
25
|
const args = ["-RnE", pattern];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rubytech/create-maxy-code",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.20",
|
|
4
4
|
"description": "Install Maxy — AI for Productive People",
|
|
5
5
|
"bin": {
|
|
6
6
|
"create-maxy-code": "./dist/index.js"
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"payload"
|
|
18
18
|
],
|
|
19
19
|
"keywords": [
|
|
20
|
-
"maxy",
|
|
20
|
+
"maxy-code",
|
|
21
21
|
"ai",
|
|
22
22
|
"assistant",
|
|
23
23
|
"raspberry-pi",
|
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"brands": [
|
|
3
3
|
{
|
|
4
|
-
"hostname": "maxy",
|
|
5
|
-
"configDir": ".maxy",
|
|
4
|
+
"hostname": "maxy-code",
|
|
5
|
+
"configDir": ".maxy-code",
|
|
6
6
|
"vncDisplay": 99,
|
|
7
7
|
"rfbPort": 5900,
|
|
8
8
|
"websockifyPort": 6080,
|
|
9
9
|
"cdpPort": 9222
|
|
10
10
|
},
|
|
11
11
|
{
|
|
12
|
-
"hostname": "realagent",
|
|
13
|
-
"configDir": ".realagent",
|
|
12
|
+
"hostname": "realagent-code",
|
|
13
|
+
"configDir": ".realagent-code",
|
|
14
14
|
"vncDisplay": 100,
|
|
15
15
|
"rfbPort": 5901,
|
|
16
16
|
"websockifyPort": 6081,
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"productName": "Maxy",
|
|
3
|
-
"hostname": "maxy",
|
|
4
|
-
"serviceName": "maxy.service",
|
|
5
|
-
"installDir": "maxy",
|
|
6
|
-
"configDir": ".maxy",
|
|
3
|
+
"hostname": "maxy-code",
|
|
4
|
+
"serviceName": "maxy-code.service",
|
|
5
|
+
"installDir": "maxy-code",
|
|
6
|
+
"configDir": ".maxy-code",
|
|
7
7
|
"tagline": "AI for Productive People",
|
|
8
8
|
"strapline": "Convenience as standard.",
|
|
9
9
|
"domain": "getmaxy.com",
|
|
@@ -28,7 +28,7 @@ set -euo pipefail
|
|
|
28
28
|
|
|
29
29
|
# shellcheck source=_stream-log.sh
|
|
30
30
|
# Resolve symlinks before dirname — ~/reset-tunnel.sh is installed as a symlink
|
|
31
|
-
# (see packages/create-maxy/src/index.ts:installTunnelScripts), so the raw
|
|
31
|
+
# (see packages/create-maxy-code/src/index.ts:installTunnelScripts), so the raw
|
|
32
32
|
# BASH_SOURCE[0] points at $HOME, not the scripts directory where _stream-log.sh lives.
|
|
33
33
|
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_stream-log.sh"
|
|
34
34
|
require_stream_log_path reset-tunnel
|
|
@@ -33,7 +33,7 @@ set -euo pipefail
|
|
|
33
33
|
|
|
34
34
|
# shellcheck source=_stream-log.sh
|
|
35
35
|
# Resolve symlinks before dirname — ~/setup-tunnel.sh is installed as a symlink
|
|
36
|
-
# (see packages/create-maxy/src/index.ts:installTunnelScripts), so the raw
|
|
36
|
+
# (see packages/create-maxy-code/src/index.ts:installTunnelScripts), so the raw
|
|
37
37
|
# BASH_SOURCE[0] points at $HOME, not the scripts directory where _stream-log.sh lives.
|
|
38
38
|
source "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")/_stream-log.sh"
|
|
39
39
|
require_stream_log_path setup-tunnel
|
|
@@ -62,6 +62,10 @@ Failure signals to grep in `~/.maxy/logs/server.log` (or `~/.realagent/logs/serv
|
|
|
62
62
|
- `[onboarding-gate] step=null complete=true` (the pre-Task-033 bug)
|
|
63
63
|
- missing `[skill-load] name=onboarding` while `[onboarding-gate]` reports `complete=false`
|
|
64
64
|
|
|
65
|
+
### QA regression replay
|
|
66
|
+
|
|
67
|
+
`platform/scripts/qa/onboarding-fresh-install.sh <brand> [--watch]` replays the success chain end-to-end on a freshly-installed Pi. It exits zero only when every line above lands, and exits non-zero with the missing line named verbatim on any miss — so a future regression in this chain is caught by replay instead of by a customer. Pass `--watch` to keep polling Neo4j while the operator drives steps 1–9 in the UI; each `currentStep` advance asserts the matching `stepNCompletedAt` is persisted before the next step renders. Provide the admin PIN via `MAXY_ADMIN_PIN`.
|
|
68
|
+
|
|
65
69
|
## Service Management
|
|
66
70
|
|
|
67
71
|
{{productName}} runs via systemd and starts automatically on boot. You don't need to start it manually. To check if it's running, ask {{productName}} "Check system status."
|
|
@@ -144,7 +148,7 @@ A separate operator-side harness at `platform/scripts/installer-device-verify.sh
|
|
|
144
148
|
|
|
145
149
|
The installer registers Claude Code plugins on the device as the last step before the brand service starts. After registration, `claude plugin list` on the Pi shows every Maxy platform plugin shipped by the brand, every premium sub-plugin shipped by the brand, and any external plugins the brand declares (e.g. Telegram, Discord, iMessage from `claude-plugins-official`). Spawned `claude` sessions inherit those plugins from `~/.claude/` — the session manager passes no `--mcp-config` argv.
|
|
146
150
|
|
|
147
|
-
**Where the manifests come from.** The Maxy plugin source tree uses `PLUGIN.md` (YAML frontmatter) for plugin metadata, not Claude Code's native `.claude-plugin/plugin.json`. At bundle time, `scripts/generate-plugin-manifests.mjs` walks the payload and synthesises a Claude-Code-native `plugin.json` per plugin plus a `marketplace.json` at each tree root. The generator runs in `packages/create-maxy/scripts/bundle.js` after platform + premium plugins are copied into the payload, so the deployed install directory carries:
|
|
151
|
+
**Where the manifests come from.** The Maxy plugin source tree uses `PLUGIN.md` (YAML frontmatter) for plugin metadata, not Claude Code's native `.claude-plugin/plugin.json`. At bundle time, `scripts/generate-plugin-manifests.mjs` walks the payload and synthesises a Claude-Code-native `plugin.json` per plugin plus a `marketplace.json` at each tree root. The generator runs in `packages/create-maxy-code/scripts/bundle.js` after platform + premium plugins are copied into the payload, so the deployed install directory carries:
|
|
148
152
|
|
|
149
153
|
- `<INSTALL_DIR>/platform/plugins/<name>/.claude-plugin/plugin.json` per platform plugin
|
|
150
154
|
- `<INSTALL_DIR>/platform/plugins/.claude-plugin/marketplace.json` (marketplace `maxy-platform`)
|
|
@@ -165,7 +169,7 @@ Generator schema:
|
|
|
165
169
|
|
|
166
170
|
Skills, agents, hooks, and commands directories at the plugin root are auto-discovered by Claude Code — no explicit field needed.
|
|
167
171
|
|
|
168
|
-
**Install flow** (`registerLocalAndExternalPlugins()` in `packages/create-maxy/src/index.ts`):
|
|
172
|
+
**Install flow** (`registerLocalAndExternalPlugins()` in `packages/create-maxy-code/src/index.ts`):
|
|
169
173
|
|
|
170
174
|
1. Discover every `.claude-plugin/marketplace.json` under the install directory.
|
|
171
175
|
2. For each one not already in `claude plugin marketplace list`, run `claude plugin marketplace add <dir>`. Pre-existing entries log `[plugin-marketplace] added <name> idempotent=true`.
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# banner — is a non-zero exit with the failing device named on stderr.
|
|
13
13
|
#
|
|
14
14
|
# CONTRACT
|
|
15
|
-
# Archival of any task that touches packages/create-maxy/** is contingent
|
|
15
|
+
# Archival of any task that touches packages/create-maxy-code/** is contingent
|
|
16
16
|
# on this script exiting zero against the published version. The exit code
|
|
17
17
|
# and the per-device summary are quoted in the close commit body.
|
|
18
18
|
#
|
|
@@ -191,7 +191,7 @@ for i in $(seq 0 $((DEVICE_COUNT - 1))); do
|
|
|
191
191
|
# Step 2 — confirm a terminal-success banner in the latest install log.
|
|
192
192
|
# Pick the newest install-*.log by mtime (`ls -t`) and grep for either of
|
|
193
193
|
# the installer's two terminal-success markers emitted by
|
|
194
|
-
# packages/create-maxy/src/index.ts:
|
|
194
|
+
# packages/create-maxy-code/src/index.ts:
|
|
195
195
|
# • DISPLAY_MODE=virtual (Pi, headless VNC) →
|
|
196
196
|
# "Browser automation ready (CDP connected)" (index.ts:3012)
|
|
197
197
|
# • DISPLAY_MODE=native (laptop, on-demand Chromium) →
|
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Task 035 — Onboarding fresh-install Pi integration script.
|
|
3
|
+
#
|
|
4
|
+
# Replays the Task 033 success chain against a freshly-installed brand on
|
|
5
|
+
# this Pi (Maxy Code or Real Agent Code). Exit zero ↔ the entire chain is
|
|
6
|
+
# observable end-to-end. Any miss exits non-zero with the failing
|
|
7
|
+
# observability line named verbatim.
|
|
8
|
+
#
|
|
9
|
+
# Success chain (fresh install, currentStep=0):
|
|
10
|
+
# [onboarding-seed] currentStep=0
|
|
11
|
+
# → installer log
|
|
12
|
+
# [install-invariant] onboarding-state-present
|
|
13
|
+
# → installer log
|
|
14
|
+
# [onboarding-gate] step=0 complete=false phase=create
|
|
15
|
+
# → server.log, fires on POST /api/admin/session
|
|
16
|
+
# [onboarding-inject] agent=admin step=0 injected=true
|
|
17
|
+
# → server.log, fires on first POST /api/admin/claude-sessions/:id/input
|
|
18
|
+
# [skill-load] skillName=onboarding result=unique
|
|
19
|
+
# → server.log, fires when admin MCP serves skill-load for the agent
|
|
20
|
+
#
|
|
21
|
+
# CONTRACT
|
|
22
|
+
# Exit 0 — every chain link landed; --watch (if set) walked 9 steps.
|
|
23
|
+
# Exit 1 — at least one chain assertion failed; missing line(s) named
|
|
24
|
+
# verbatim in the final report.
|
|
25
|
+
# Exit 2 — environment fault (cypher-shell unreachable, brand.json
|
|
26
|
+
# missing, refused-as-non-fresh-install, etc.) — never PASS.
|
|
27
|
+
#
|
|
28
|
+
# USAGE
|
|
29
|
+
# $ onboarding-fresh-install.sh <brand> [--watch]
|
|
30
|
+
#
|
|
31
|
+
# <brand> maxy-code | realagent-code (matches brands/<brand>/brand.json).
|
|
32
|
+
# --watch After the fresh-install chain passes, poll Neo4j every 2s.
|
|
33
|
+
# For every observed currentStep advance N→N+1, assert that
|
|
34
|
+
# step(N+1)CompletedAt is non-null at the same moment. Exits 0
|
|
35
|
+
# when currentStep reaches 9 or the operator presses Ctrl-C
|
|
36
|
+
# after at least one advance.
|
|
37
|
+
#
|
|
38
|
+
# ENV
|
|
39
|
+
# MAXY_ADMIN_PIN Admin PIN (required for the session-mint step).
|
|
40
|
+
# May also be passed as the 2nd positional argument.
|
|
41
|
+
# MAXY_NEO4J_PASSWORD
|
|
42
|
+
# Neo4j password (default: read from <installDir>/platform/config/.neo4j-password if present).
|
|
43
|
+
# MAXY_QA_TIMEOUT Seconds to wait for the runtime log lines to appear
|
|
44
|
+
# after the first /input call (default 30).
|
|
45
|
+
# MAXY_QA_HOST Admin host (default 127.0.0.1).
|
|
46
|
+
#
|
|
47
|
+
# OUT-OF-SCOPE (per task 035)
|
|
48
|
+
# * Multi-Pi orchestration — one brand per invocation.
|
|
49
|
+
# * CI pipeline gating.
|
|
50
|
+
# * Driving steps 1–9 by curl. Steps 7 (Cloudflare OAuth), 8 (Anthropic
|
|
51
|
+
# API key paste), and 9 (free-form business profile data) cannot be
|
|
52
|
+
# deterministically scripted on a fresh install; the operator drives
|
|
53
|
+
# each step in the UI under --watch while this script polls Neo4j.
|
|
54
|
+
|
|
55
|
+
set -euo pipefail
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# Argv + env
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
if [ $# -lt 1 ]; then
|
|
62
|
+
echo "usage: $0 <brand> [--watch] [pin]" >&2
|
|
63
|
+
echo " <brand> = maxy-code | realagent-code" >&2
|
|
64
|
+
exit 2
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
BRAND=""
|
|
68
|
+
WATCH=0
|
|
69
|
+
PIN_POSITIONAL=""
|
|
70
|
+
for arg in "$@"; do
|
|
71
|
+
case "$arg" in
|
|
72
|
+
--watch) WATCH=1 ;;
|
|
73
|
+
-*) echo "unknown flag: $arg" >&2; exit 2 ;;
|
|
74
|
+
*) if [ -z "$BRAND" ]; then BRAND="$arg"; else PIN_POSITIONAL="$arg"; fi ;;
|
|
75
|
+
esac
|
|
76
|
+
done
|
|
77
|
+
|
|
78
|
+
if [ -z "$BRAND" ]; then
|
|
79
|
+
echo "error: brand argument required" >&2
|
|
80
|
+
exit 2
|
|
81
|
+
fi
|
|
82
|
+
|
|
83
|
+
PIN="${MAXY_ADMIN_PIN:-$PIN_POSITIONAL}"
|
|
84
|
+
if [ -z "$PIN" ]; then
|
|
85
|
+
echo "error: PIN required — set MAXY_ADMIN_PIN env or pass as second positional argument" >&2
|
|
86
|
+
exit 2
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
HOST="${MAXY_QA_HOST:-127.0.0.1}"
|
|
90
|
+
TIMEOUT="${MAXY_QA_TIMEOUT:-30}"
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Brand resolution — read live brand.json on the Pi for ports + paths.
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
INSTALL_DIR="$HOME/$BRAND"
|
|
97
|
+
BRAND_JSON="$INSTALL_DIR/platform/config/brand.json"
|
|
98
|
+
LOG_DIR="$HOME/.$BRAND/logs"
|
|
99
|
+
SERVER_LOG="$LOG_DIR/server.log"
|
|
100
|
+
|
|
101
|
+
if [ ! -f "$BRAND_JSON" ]; then
|
|
102
|
+
echo "error: brand.json not found at $BRAND_JSON" >&2
|
|
103
|
+
echo "hint: this script expects a brand installed via @rubytech/create-${BRAND}" >&2
|
|
104
|
+
exit 2
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# jq is on the deployment-doctrine list; if it's absent, the script must
|
|
108
|
+
# loud-fail rather than fall back to a regex parse of brand.json.
|
|
109
|
+
if ! command -v jq >/dev/null 2>&1; then
|
|
110
|
+
echo "error: jq required but not installed" >&2
|
|
111
|
+
exit 2
|
|
112
|
+
fi
|
|
113
|
+
|
|
114
|
+
NEO4J_PORT="$(jq -r '.neo4jPort' "$BRAND_JSON")"
|
|
115
|
+
if [ -z "$NEO4J_PORT" ] || [ "$NEO4J_PORT" = "null" ]; then
|
|
116
|
+
echo "error: brand.json at $BRAND_JSON has no neo4jPort field" >&2
|
|
117
|
+
exit 2
|
|
118
|
+
fi
|
|
119
|
+
|
|
120
|
+
# Admin server PORT is set by the installer as a systemd Environment var, not
|
|
121
|
+
# in brand.json (verified packages/create-maxy-code/src/port-resolution.ts).
|
|
122
|
+
# Resolution order: MAXY_QA_PORT env → systemctl --user show <serviceName>
|
|
123
|
+
# → fail loud (never a hard-coded default — silent-fallback masks root cause).
|
|
124
|
+
ADMIN_PORT="${MAXY_QA_PORT:-}"
|
|
125
|
+
if [ -z "$ADMIN_PORT" ]; then
|
|
126
|
+
SERVICE_NAME="$(jq -r '.serviceName' "$BRAND_JSON")"
|
|
127
|
+
if [ -n "$SERVICE_NAME" ] && [ "$SERVICE_NAME" != "null" ] && command -v systemctl >/dev/null 2>&1; then
|
|
128
|
+
ADMIN_PORT="$(systemctl --user show "$SERVICE_NAME" -p Environment 2>/dev/null \
|
|
129
|
+
| tr ' ' '\n' \
|
|
130
|
+
| grep -E '^PORT=' \
|
|
131
|
+
| head -n 1 \
|
|
132
|
+
| cut -d= -f2)"
|
|
133
|
+
fi
|
|
134
|
+
fi
|
|
135
|
+
if [ -z "$ADMIN_PORT" ]; then
|
|
136
|
+
echo "error: admin PORT not resolvable — set MAXY_QA_PORT env (e.g. MAXY_QA_PORT=19200)" >&2
|
|
137
|
+
exit 2
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
NEO4J_URI="bolt://localhost:$NEO4J_PORT"
|
|
141
|
+
ADMIN_BASE="http://$HOST:$ADMIN_PORT"
|
|
142
|
+
|
|
143
|
+
NEO4J_PASSWORD="${MAXY_NEO4J_PASSWORD:-}"
|
|
144
|
+
NEO4J_PASSWORD_FILE="$INSTALL_DIR/platform/config/.neo4j-password"
|
|
145
|
+
if [ -z "$NEO4J_PASSWORD" ] && [ -r "$NEO4J_PASSWORD_FILE" ]; then
|
|
146
|
+
NEO4J_PASSWORD="$(cat "$NEO4J_PASSWORD_FILE")"
|
|
147
|
+
fi
|
|
148
|
+
if [ -z "$NEO4J_PASSWORD" ]; then
|
|
149
|
+
echo "error: Neo4j password required — set MAXY_NEO4J_PASSWORD env or place it at $NEO4J_PASSWORD_FILE" >&2
|
|
150
|
+
exit 2
|
|
151
|
+
fi
|
|
152
|
+
|
|
153
|
+
for tool in cypher-shell curl grep tail; do
|
|
154
|
+
if ! command -v "$tool" >/dev/null 2>&1; then
|
|
155
|
+
echo "error: $tool not on PATH" >&2
|
|
156
|
+
exit 2
|
|
157
|
+
fi
|
|
158
|
+
done
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------------------
|
|
161
|
+
# Cypher helper — exit 2 on unreachable, captures stderr tail for the report.
|
|
162
|
+
# ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
cypher() {
|
|
165
|
+
local query="$1"
|
|
166
|
+
cypher-shell -u neo4j -p "$NEO4J_PASSWORD" -a "$NEO4J_URI" --format plain "$query"
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
cypher_or_die() {
|
|
170
|
+
local query="$1"
|
|
171
|
+
local out
|
|
172
|
+
if ! out="$(cypher "$query" 2>&1)"; then
|
|
173
|
+
echo "error: cypher-shell unreachable at $NEO4J_URI" >&2
|
|
174
|
+
echo "$out" | tail -n 5 >&2
|
|
175
|
+
exit 2
|
|
176
|
+
fi
|
|
177
|
+
printf '%s' "$out"
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
# ---------------------------------------------------------------------------
|
|
181
|
+
# Assertion bookkeeping — every assertion appends one row to the report so
|
|
182
|
+
# the final block names every link verbatim, pass or fail.
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
REPORT=()
|
|
186
|
+
FAIL_COUNT=0
|
|
187
|
+
|
|
188
|
+
record() {
|
|
189
|
+
# record <status> <label> <detail>
|
|
190
|
+
REPORT+=("$1|$2|$3")
|
|
191
|
+
if [ "$1" = "FAIL" ]; then
|
|
192
|
+
FAIL_COUNT=$((FAIL_COUNT + 1))
|
|
193
|
+
fi
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
print_report() {
|
|
197
|
+
echo ""
|
|
198
|
+
echo "## onboarding fresh-install — final report"
|
|
199
|
+
echo ""
|
|
200
|
+
printf '| %-4s | %-50s | %s\n' "Step" "Assertion" "Detail"
|
|
201
|
+
printf '|------|----------------------------------------------------|-----\n'
|
|
202
|
+
local i=1
|
|
203
|
+
for row in "${REPORT[@]}"; do
|
|
204
|
+
local status="${row%%|*}"
|
|
205
|
+
local rest="${row#*|}"
|
|
206
|
+
local label="${rest%%|*}"
|
|
207
|
+
local detail="${rest#*|}"
|
|
208
|
+
local marker
|
|
209
|
+
case "$status" in
|
|
210
|
+
PASS) marker="PASS" ;;
|
|
211
|
+
FAIL) marker="FAIL" ;;
|
|
212
|
+
*) marker="$status" ;;
|
|
213
|
+
esac
|
|
214
|
+
printf '| %-4s | %-50s | %s\n' "$marker" "$label" "$detail"
|
|
215
|
+
i=$((i + 1))
|
|
216
|
+
done
|
|
217
|
+
echo ""
|
|
218
|
+
echo "brand=$BRAND neo4j=$NEO4J_URI logs=$LOG_DIR"
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# ---------------------------------------------------------------------------
|
|
222
|
+
# Step 1 — Pre-flight: refuse if not a fresh install.
|
|
223
|
+
# ---------------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
echo "[1/6] Pre-flight: assert OnboardingState{currentStep:0}"
|
|
226
|
+
PREFLIGHT_OUT="$(cypher_or_die 'MATCH (o:OnboardingState) RETURN o.accountId AS accountId, o.currentStep AS currentStep, o.createdAt AS createdAt')"
|
|
227
|
+
ROW_COUNT="$(printf '%s' "$PREFLIGHT_OUT" | tail -n +2 | grep -c . || true)"
|
|
228
|
+
|
|
229
|
+
if [ "$ROW_COUNT" -eq 0 ]; then
|
|
230
|
+
echo "error: not a fresh install — no OnboardingState row exists" >&2
|
|
231
|
+
echo "hint: the installer's [install-invariant] onboarding-state-MISSING line would have fired; check ~/.${BRAND}/logs/install-*.log" >&2
|
|
232
|
+
exit 2
|
|
233
|
+
fi
|
|
234
|
+
if [ "$ROW_COUNT" -gt 1 ]; then
|
|
235
|
+
echo "error: not a fresh install — $ROW_COUNT OnboardingState rows exist (expected 1)" >&2
|
|
236
|
+
printf '%s\n' "$PREFLIGHT_OUT" >&2
|
|
237
|
+
exit 2
|
|
238
|
+
fi
|
|
239
|
+
|
|
240
|
+
CURRENT_STEP="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $2}')"
|
|
241
|
+
ACCOUNT_ID="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $1}' | tr -d '"')"
|
|
242
|
+
CREATED_AT="$(printf '%s' "$PREFLIGHT_OUT" | tail -n 1 | awk -F', ' '{print $3}' | tr -d '"')"
|
|
243
|
+
|
|
244
|
+
if [ "$CURRENT_STEP" != "0" ]; then
|
|
245
|
+
echo "error: not a fresh install — currentStep=$CURRENT_STEP (refusing to drive on top of completed state)" >&2
|
|
246
|
+
echo "expected: currentStep=0" >&2
|
|
247
|
+
echo "actual: $PREFLIGHT_OUT" >&2
|
|
248
|
+
exit 2
|
|
249
|
+
fi
|
|
250
|
+
record PASS "pre-flight: OnboardingState{currentStep:0}" "accountId=${ACCOUNT_ID:0:8} createdAt=$CREATED_AT"
|
|
251
|
+
|
|
252
|
+
# ---------------------------------------------------------------------------
|
|
253
|
+
# Step 2 — Installer log: [onboarding-seed] currentStep=0 + [install-invariant] onboarding-state-present.
|
|
254
|
+
#
|
|
255
|
+
# Installer logs live in $LOG_DIR/install-*.log (separate from server.log).
|
|
256
|
+
# Both lines fire once per install run; pin to the newest install log so a
|
|
257
|
+
# stale prior install on the same Pi cannot produce a false PASS.
|
|
258
|
+
# ---------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
echo "[2/6] Installer log: [onboarding-seed] + [install-invariant] onboarding-state-present"
|
|
261
|
+
LATEST_INSTALL_LOG="$(ls -t "$LOG_DIR"/install-*.log 2>/dev/null | head -n 1 || true)"
|
|
262
|
+
if [ -z "$LATEST_INSTALL_LOG" ]; then
|
|
263
|
+
record FAIL "[onboarding-seed] currentStep=0" "no install log under $LOG_DIR"
|
|
264
|
+
record FAIL "[install-invariant] onboarding-state-present" "no install log under $LOG_DIR"
|
|
265
|
+
else
|
|
266
|
+
if grep -qF "[onboarding-seed] accountId=$ACCOUNT_ID currentStep=0" "$LATEST_INSTALL_LOG"; then
|
|
267
|
+
record PASS "[onboarding-seed] currentStep=0" "$(basename "$LATEST_INSTALL_LOG")"
|
|
268
|
+
else
|
|
269
|
+
record FAIL "[onboarding-seed] currentStep=0" "missing from $(basename "$LATEST_INSTALL_LOG")"
|
|
270
|
+
fi
|
|
271
|
+
if grep -qF "[install-invariant] onboarding-state-present accountId=$ACCOUNT_ID" "$LATEST_INSTALL_LOG"; then
|
|
272
|
+
record PASS "[install-invariant] onboarding-state-present" "$(basename "$LATEST_INSTALL_LOG")"
|
|
273
|
+
else
|
|
274
|
+
record FAIL "[install-invariant] onboarding-state-present" "missing from $(basename "$LATEST_INSTALL_LOG")"
|
|
275
|
+
fi
|
|
276
|
+
fi
|
|
277
|
+
|
|
278
|
+
# ---------------------------------------------------------------------------
|
|
279
|
+
# Step 3 — POST /api/admin/session: assert onboardingComplete:false in response,
|
|
280
|
+
# then assert [onboarding-gate] step=0 complete=false in server.log.
|
|
281
|
+
# ---------------------------------------------------------------------------
|
|
282
|
+
|
|
283
|
+
echo "[3/6] Mint admin session via PIN and assert [onboarding-gate] step=0 complete=false"
|
|
284
|
+
TURN_MARKER_BEFORE="$(wc -l < "$SERVER_LOG" 2>/dev/null | tr -d ' ' || echo 0)"
|
|
285
|
+
TURN_MARKER_BEFORE="${TURN_MARKER_BEFORE:-0}"
|
|
286
|
+
SESSION_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/session" \
|
|
287
|
+
-H 'content-type: application/json' \
|
|
288
|
+
-d "{\"pin\":\"$PIN\"}")" || {
|
|
289
|
+
record FAIL "POST /api/admin/session" "curl failed against $ADMIN_BASE"
|
|
290
|
+
print_report
|
|
291
|
+
exit 1
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
if ! printf '%s' "$SESSION_RESPONSE" | jq -e '.onboardingComplete == false' >/dev/null 2>&1; then
|
|
295
|
+
record FAIL "POST /api/admin/session onboardingComplete:false" "response=$SESSION_RESPONSE"
|
|
296
|
+
print_report
|
|
297
|
+
exit 1
|
|
298
|
+
fi
|
|
299
|
+
SESSION_KEY="$(printf '%s' "$SESSION_RESPONSE" | jq -r '.session_key')"
|
|
300
|
+
if [ -z "$SESSION_KEY" ] || [ "$SESSION_KEY" = "null" ]; then
|
|
301
|
+
record FAIL "session_key" "absent in /api/admin/session response"
|
|
302
|
+
print_report
|
|
303
|
+
exit 1
|
|
304
|
+
fi
|
|
305
|
+
record PASS "POST /api/admin/session onboardingComplete:false" "session minted"
|
|
306
|
+
|
|
307
|
+
# Tail server.log from the byte offset captured before the call. grep -F
|
|
308
|
+
# matches the substring literally — the gate phrasing in session.ts:193 is
|
|
309
|
+
# `[onboarding-gate] session=... accountId=... step=0 complete=false phase=create`.
|
|
310
|
+
if tail -n +"$TURN_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -qF "[onboarding-gate]" \
|
|
311
|
+
&& tail -n +"$TURN_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[onboarding-gate]" | grep -qF "step=0 complete=false"; then
|
|
312
|
+
record PASS "[onboarding-gate] step=0 complete=false" "phase=create"
|
|
313
|
+
else
|
|
314
|
+
record FAIL "[onboarding-gate] step=0 complete=false" "not found in $SERVER_LOG since session-mint"
|
|
315
|
+
fi
|
|
316
|
+
|
|
317
|
+
# ---------------------------------------------------------------------------
|
|
318
|
+
# Step 4 — Spawn an admin claude-session so the next /input is injection-eligible.
|
|
319
|
+
# ---------------------------------------------------------------------------
|
|
320
|
+
|
|
321
|
+
echo "[4/6] Spawn admin claude-session and capture sessionId"
|
|
322
|
+
SPAWN_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/claude-sessions/?session_key=$SESSION_KEY" \
|
|
323
|
+
-H 'content-type: application/json' \
|
|
324
|
+
-d '{"channel":"browser"}')" || {
|
|
325
|
+
record FAIL "POST /api/admin/claude-sessions/" "curl failed"
|
|
326
|
+
print_report
|
|
327
|
+
exit 1
|
|
328
|
+
}
|
|
329
|
+
SESSION_ID="$(printf '%s' "$SPAWN_RESPONSE" | jq -r '.sessionId // empty')"
|
|
330
|
+
if [ -z "$SESSION_ID" ]; then
|
|
331
|
+
record FAIL "POST /api/admin/claude-sessions/" "no sessionId in response: $SPAWN_RESPONSE"
|
|
332
|
+
print_report
|
|
333
|
+
exit 1
|
|
334
|
+
fi
|
|
335
|
+
record PASS "spawn claude-session" "sessionId=${SESSION_ID:0:8}"
|
|
336
|
+
|
|
337
|
+
# ---------------------------------------------------------------------------
|
|
338
|
+
# Step 5 — First /input call. This triggers buildOnboardingPromptBlock,
|
|
339
|
+
# which logs [onboarding-inject] step=0 injected=true. The agent then runs
|
|
340
|
+
# the prepended skill-load directive, which logs [skill-load] skillName=onboarding.
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
echo "[5/6] Submit first admin input and wait up to ${TIMEOUT}s for [onboarding-inject] and [skill-load]"
|
|
344
|
+
INPUT_MARKER_BEFORE="$(wc -l < "$SERVER_LOG" 2>/dev/null | tr -d ' ' || echo 0)"
|
|
345
|
+
INPUT_MARKER_BEFORE="${INPUT_MARKER_BEFORE:-0}"
|
|
346
|
+
INPUT_RESPONSE="$(curl -sS -X POST "$ADMIN_BASE/api/admin/claude-sessions/$SESSION_ID/input?session_key=$SESSION_KEY" \
|
|
347
|
+
-H 'content-type: application/json' \
|
|
348
|
+
-d '{"text":"hello"}')" || {
|
|
349
|
+
record FAIL "POST /api/admin/claude-sessions/:id/input" "curl failed"
|
|
350
|
+
print_report
|
|
351
|
+
exit 1
|
|
352
|
+
}
|
|
353
|
+
# Status-only check — the manager owns the per-input contract.
|
|
354
|
+
record PASS "POST first /input" "(skill-load arrives async)"
|
|
355
|
+
|
|
356
|
+
# Poll-tail server.log for the two remaining lines.
|
|
357
|
+
deadline=$(( $(date +%s) + TIMEOUT ))
|
|
358
|
+
inject_seen=0
|
|
359
|
+
skill_seen=0
|
|
360
|
+
while [ "$(date +%s)" -lt "$deadline" ]; do
|
|
361
|
+
if [ "$inject_seen" -eq 0 ] \
|
|
362
|
+
&& tail -n +"$INPUT_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[onboarding-inject]" | grep -qF "step=0 injected=true"; then
|
|
363
|
+
inject_seen=1
|
|
364
|
+
fi
|
|
365
|
+
if [ "$skill_seen" -eq 0 ] \
|
|
366
|
+
&& tail -n +"$INPUT_MARKER_BEFORE" "$SERVER_LOG" 2>/dev/null | grep -F "[skill-load]" | grep -qF "skillName=onboarding"; then
|
|
367
|
+
skill_seen=1
|
|
368
|
+
fi
|
|
369
|
+
if [ "$inject_seen" -eq 1 ] && [ "$skill_seen" -eq 1 ]; then break; fi
|
|
370
|
+
sleep 1
|
|
371
|
+
done
|
|
372
|
+
|
|
373
|
+
if [ "$inject_seen" -eq 1 ]; then
|
|
374
|
+
record PASS "[onboarding-inject] step=0 injected=true" "server.log"
|
|
375
|
+
else
|
|
376
|
+
record FAIL "[onboarding-inject] step=0 injected=true" "not found within ${TIMEOUT}s"
|
|
377
|
+
fi
|
|
378
|
+
if [ "$skill_seen" -eq 1 ]; then
|
|
379
|
+
record PASS "[skill-load] skillName=onboarding" "server.log"
|
|
380
|
+
else
|
|
381
|
+
record FAIL "[skill-load] skillName=onboarding" "not found within ${TIMEOUT}s"
|
|
382
|
+
fi
|
|
383
|
+
|
|
384
|
+
# ---------------------------------------------------------------------------
|
|
385
|
+
# Step 6 — --watch mode: poll Neo4j and assert step-advance + timestamp.
|
|
386
|
+
#
|
|
387
|
+
# The operator drives steps 1–9 in the UI. For every observed currentStep
|
|
388
|
+
# transition N→N+1, assert step(N+1)CompletedAt is non-null at the same
|
|
389
|
+
# moment. The script never advances steps itself — steps 7 (Cloudflare
|
|
390
|
+
# OAuth), 8 (Anthropic API key paste), and 9 (free-form business profile)
|
|
391
|
+
# require operator-side inputs that cannot be forged from a script.
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
if [ "$FAIL_COUNT" -gt 0 ]; then
|
|
395
|
+
print_report
|
|
396
|
+
echo "Fresh-install chain failed before --watch could start." >&2
|
|
397
|
+
exit 1
|
|
398
|
+
fi
|
|
399
|
+
|
|
400
|
+
if [ "$WATCH" -eq 1 ]; then
|
|
401
|
+
echo "[6/6] --watch: polling Neo4j every 2s. Drive steps 1-9 in the UI now. Ctrl-C to abort."
|
|
402
|
+
last_step="$CURRENT_STEP"
|
|
403
|
+
while :; do
|
|
404
|
+
sleep 2
|
|
405
|
+
WATCH_OUT="$(cypher_or_die 'MATCH (o:OnboardingState) RETURN o.currentStep AS s, o.step1CompletedAt AS s1, o.step2CompletedAt AS s2, o.step3CompletedAt AS s3, o.step4CompletedAt AS s4, o.step5CompletedAt AS s5, o.step6CompletedAt AS s6, o.step7CompletedAt AS s7, o.step8CompletedAt AS s8, o.step9CompletedAt AS s9')"
|
|
406
|
+
NOW_STEP="$(printf '%s' "$WATCH_OUT" | tail -n 1 | awk -F', ' '{print $1}')"
|
|
407
|
+
if [ "$NOW_STEP" = "$last_step" ]; then continue; fi
|
|
408
|
+
# Advance observed. Check the timestamp for the new step.
|
|
409
|
+
new_step="$NOW_STEP"
|
|
410
|
+
if [ "$new_step" -le "$last_step" ]; then
|
|
411
|
+
record FAIL "step regression" "last=$last_step now=$new_step"
|
|
412
|
+
print_report
|
|
413
|
+
exit 1
|
|
414
|
+
fi
|
|
415
|
+
ts_field=$(( new_step + 1 )) # awk fields are 1-indexed; currentStep is field 1, stepNCompletedAt is field N+1.
|
|
416
|
+
stamp="$(printf '%s' "$WATCH_OUT" | tail -n 1 | awk -F', ' "{print \$$ts_field}" | tr -d '"')"
|
|
417
|
+
if [ -z "$stamp" ] || [ "$stamp" = "NULL" ] || [ "$stamp" = "null" ]; then
|
|
418
|
+
record FAIL "step$new_step advanced without timestamp" "step${new_step}CompletedAt is NULL"
|
|
419
|
+
print_report
|
|
420
|
+
exit 1
|
|
421
|
+
fi
|
|
422
|
+
record PASS "step $new_step advance" "step${new_step}CompletedAt=$stamp"
|
|
423
|
+
last_step="$new_step"
|
|
424
|
+
if [ "$new_step" -ge 9 ]; then
|
|
425
|
+
echo " currentStep=9 — onboarding complete"
|
|
426
|
+
break
|
|
427
|
+
fi
|
|
428
|
+
done
|
|
429
|
+
fi
|
|
430
|
+
|
|
431
|
+
print_report
|
|
432
|
+
if [ "$FAIL_COUNT" -gt 0 ]; then
|
|
433
|
+
exit 1
|
|
434
|
+
fi
|
|
435
|
+
exit 0
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
# Existing-pi install-log redaction (Task 744).
|
|
3
3
|
#
|
|
4
4
|
# Idempotent one-shot remediation for Pis that completed installation BEFORE
|
|
5
|
-
# the install-log redaction landed at packages/create-maxy/src/index.ts:152.
|
|
5
|
+
# the install-log redaction landed at packages/create-maxy-code/src/index.ts:152.
|
|
6
6
|
# Scans every `install-*.log` in the configured logs directory and replaces
|
|
7
7
|
# every literal `set-initial-password ...<secret>` payload with
|
|
8
8
|
# `set-initial-password [REDACTED]`. Re-running the script is safe —
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
# edits occur.
|
|
11
11
|
#
|
|
12
12
|
# Source patterns covered:
|
|
13
|
-
# 1. TS installer (packages/create-maxy/src/index.ts:152) — "[ISO] > sudo
|
|
13
|
+
# 1. TS installer (packages/create-maxy-code/src/index.ts:152) — "[ISO] > sudo
|
|
14
14
|
# neo4j-admin dbms set-initial-password -- <secret>" or any args after
|
|
15
15
|
# "set-initial-password" (positional or "--" delimited).
|
|
16
16
|
# 2. Legacy bash installer (removed) — "+ sudo neo4j-admin dbms
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
# and where a skill prescribes a forbidden direct-execution path
|
|
9
9
|
# (`cypher-shell`, `neo4j-admin` invocations, raw-Cypher DML in prose).
|
|
10
10
|
#
|
|
11
|
-
# Wired into the root `packages/create-maxy/package.json` `prepublishOnly`
|
|
11
|
+
# Wired into the root `packages/create-maxy-code/package.json` `prepublishOnly`
|
|
12
12
|
# script so a regression cannot reach npm publish without firing.
|
|
13
13
|
#
|
|
14
14
|
# One stdout line per (skill, specialist) pair:
|
|
@@ -45,7 +45,7 @@ BRAND_JSON="${PLATFORM_ROOT}/config/brand.json"
|
|
|
45
45
|
# Task 959 — brand.json is the single source of truth at runtime; missing
|
|
46
46
|
# fields loud-fail rather than silently substituting a vncDisplay-derived
|
|
47
47
|
# offset (silent-fallback-masks-root-cause recurrence). The install-time
|
|
48
|
-
# offset rule in packages/create-maxy/src/index.ts stamps every brand at
|
|
48
|
+
# offset rule in packages/create-maxy-code/src/index.ts stamps every brand at
|
|
49
49
|
# build time, so a correctly-installed device always has all five fields.
|
|
50
50
|
if [ ! -f "$BRAND_JSON" ]; then
|
|
51
51
|
echo "[vnc.sh] error reason=brand-config-missing path=$BRAND_JSON" >&2
|
|
@@ -89,7 +89,7 @@ mkdir -p "$LOG_DIR"
|
|
|
89
89
|
log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "$LOG_FILE"; }
|
|
90
90
|
|
|
91
91
|
# Task 929 — resolve the absolute Chromium binary path from the install-time
|
|
92
|
-
# config file. The installer (packages/create-maxy/src/index.ts
|
|
92
|
+
# config file. The installer (packages/create-maxy-code/src/index.ts
|
|
93
93
|
# ensureNonSnapChromium + writeChromiumBinaryPathFile) writes this file with
|
|
94
94
|
# the non-snap binary chosen for this device — `/usr/bin/chromium` on Pi
|
|
95
95
|
# Bookworm (real .deb) or `/usr/bin/google-chrome-stable` on Ubuntu Noble
|
|
@@ -80,7 +80,7 @@ Two PNGs of the wordmark/lockup as the brand uses it. **The filename describes t
|
|
|
80
80
|
| Filename | Art colour | Place it on |
|
|
81
81
|
|---|---|---|
|
|
82
82
|
| `<slug>-logo-light.png` | light/white/cream/pale pixels | dark surfaces (back-page hero, dark masthead) |
|
|
83
|
-
| `<slug>-logo-dark.png` | dark/black/navy/charcoal pixels | light surfaces (TOC,
|
|
83
|
+
| `<slug>-logo-dark.png` | dark/black/navy/charcoal pixels | light surfaces (TOC, index.html nav, paper backgrounds) |
|
|
84
84
|
|
|
85
85
|
So if you open `donnavincent-logo-light.png` and see white text, that is correct — it's the light-coloured variant. If you open `donnavincent-logo-dark.png` and see white text, the file was misnamed by an earlier run; rename it.
|
|
86
86
|
|
|
@@ -23,7 +23,7 @@ The deliverable is identical to `property-brochure`'s outcome — written into `
|
|
|
23
23
|
|
|
24
24
|
- `brochure.html` plus per-page 300 dpi PNG print snapshots (canonical, archival)
|
|
25
25
|
- Two image-only PDFs: `<slug>-brochure-print.pdf` (300 dpi print master) and `<slug>-brochure-web.pdf` (192 dpi web/digital). Both built from per-page Playwright snapshots via img2pdf and linearized via qpdf — no Ghostscript anywhere in the chain. See `property-brochure → PDF deliverable` and `a4-print-documents → PDF deliverables`.
|
|
26
|
-
- A self-contained **web bundle** at `output/web/` plus `output/<slug>-web.zip` — the smaller, ready-to-host version for property micro-sites; includes both the brochure and a companion **landing page** at `
|
|
26
|
+
- A self-contained **web bundle** at `output/web/` plus `output/<slug>-web.zip` — the smaller, ready-to-host version for property micro-sites; includes both the brochure and a companion **landing page** at `index.html` that presents the property as a continuous scrollable web page. See `property-brochure → Web bundle` and `property-brochure → Page`
|
|
27
27
|
|
|
28
28
|
The orchestrator's value is in what it does **not** redo:
|
|
29
29
|
|
|
@@ -271,19 +271,19 @@ After a complete run, the on-disk layout is **exactly** this — no deeper nesti
|
|
|
271
271
|
qr-video.png, qr-listing.png
|
|
272
272
|
web/ # self-contained web bundle, generated alongside print archive
|
|
273
273
|
brochure.html # same HTML; print-img refs point at .jpg snapshots; QR codes are clickable <a>
|
|
274
|
-
|
|
274
|
+
index.html # companion property landing page (Modern House-style scroll)
|
|
275
275
|
<property_slug>-brochure.pdf # digital PDF, copied for the in-page Download button
|
|
276
276
|
cover-print.jpg
|
|
277
277
|
page2-print.jpg … page15-print.jpg
|
|
278
278
|
backpage-print.jpg
|
|
279
279
|
images/ # web-tier per-slot encodings: hero 1300/q82, story 1100/q80, thumb 800/q76
|
|
280
280
|
<property_slug>-NN.webp
|
|
281
|
-
<property_slug>-hero-1-main.webp #
|
|
282
|
-
<property_slug>-hero-2-<role>.webp #
|
|
283
|
-
<property_slug>-hero-3-<role>.webp #
|
|
281
|
+
<property_slug>-hero-1-main.webp # index.html hero rotator — source-res @ q88
|
|
282
|
+
<property_slug>-hero-2-<role>.webp # index.html hero rotator — interior moment
|
|
283
|
+
<property_slug>-hero-3-<role>.webp # index.html hero rotator — garden / outdoor
|
|
284
284
|
<property_slug>-floorplan.png # line-art PNG, copied unchanged from canonical
|
|
285
|
-
<brand_slug>-logo-light.png # light-coloured logo art — for dark surfaces (e.g. dark
|
|
286
|
-
<brand_slug>-logo-dark.png # dark-coloured logo art — for light surfaces (e.g.
|
|
285
|
+
<brand_slug>-logo-light.png # light-coloured logo art — for dark surfaces (e.g. dark index.html sections)
|
|
286
|
+
<brand_slug>-logo-dark.png # dark-coloured logo art — for light surfaces (e.g. index.html nav, brochure cover when paper)
|
|
287
287
|
qr-video.png, qr-listing.png
|
|
288
288
|
<property_slug>-web.zip # zipped form of web/, ready to upload to a static-site host
|
|
289
289
|
```
|
|
@@ -325,7 +325,7 @@ Determinism rules — a run that violates any of these is wrong, even if the bro
|
|
|
325
325
|
| "House" instead of "home" at super-premium register | Wrong word for the buyer. "Home" is warmer, more aspirational, and matches the register. Replace globally. |
|
|
326
326
|
| Text overlaid on photographs relying on a gradient + text-shadow for legibility | Insufficient against busy backgrounds (stone tile, golden-hour skies). Use a translucent dark panel directly behind the text. See `property-brochure → Text on images`. |
|
|
327
327
|
| Including a detached gymnasium-summerhouse footprint in the principal home's first-floor sq ft | Misrepresents £/sq ft. List ancillary footprints separately. See `property-brochure → Floor area`. |
|
|
328
|
-
| Shipping a web bundle without `
|
|
328
|
+
| Shipping a web bundle without `index.html` | The web bundle is the property's hosted experience. The brochure is a downloadable folio; the landing page is the scrollable "for sale" page that links to it. Both are mandatory in the zip. See `property-brochure → Page`. |
|
|
329
329
|
| Putting QR codes on the back page without a visible URL or a clickable wrapper | A QR is unscannable from the digital screen showing the brochure. The clickable URL beneath each QR is mandatory; it makes the digital reader experience equivalent to the print reader experience. See `property-brochure → Back-page QR codes`. |
|
|
330
330
|
| Using the same logo variant for the back-page masthead (dark surface) and the landing-page nav (light surface) | The two surfaces need opposite logo variants — white-on-transparent for the back page, dark/coloured for the landing nav. Both must be present in `output/web/images/`. |
|
|
331
331
|
|
|
@@ -339,7 +339,7 @@ Report `DONE` only after:
|
|
|
339
339
|
- Both PDF deliverables exist alongside `brochure.html`: the print master (`<property_slug>-brochure-print.pdf`, ~50–80 MB at 300 dpi) and the web/digital (`<property_slug>-brochure-web.pdf`, ~20–35 MB at 192 dpi). Both are image-only, built from per-page Playwright snapshots via img2pdf and linearized via qpdf; both pass `qpdf --check`, both report `Optimized: yes`, and both have zero embedded fonts (`pdffonts | wc -l` returns 2). See `a4-print-documents → Verification`.
|
|
340
340
|
- All per-page print snapshots referenced by the HTML's print CSS exist at expected names — `cover-print.png`, `page2-print.png` through `page15-print.png`, and `backpage-print.png` (16 files for the 16-page folio) — at the orientation-correct 300 dpi pixel dimensions (3509×2481 landscape / 2481×3509 portrait).
|
|
341
341
|
- The brochure has an **even page count** (canonical 16; multiples of 2 required, ideally multiples of 4 for booklet binding) — see `a4-print-documents` → Even-page count for duplex printing.
|
|
342
|
-
- The **web bundle** exists at `output/web/` and as a zipped `output/<slug>-web.zip` — see `property-brochure → Web bundle` for what it contains. The bundle includes both `brochure.html` and the companion `
|
|
342
|
+
- The **web bundle** exists at `output/web/` and as a zipped `output/<slug>-web.zip` — see `property-brochure → Web bundle` for what it contains. The bundle includes both `brochure.html` and the companion `index.html` (see `property-brochure → Page`), plus a copy of the web PDF named simply `<property_slug>-brochure.pdf` to match the in-page Download links. Smoke-test was performed: an isolated HTTP server returned 200 for every referenced asset (HTML, web-tier images, jpg snapshots, the bundled PDF), and **both** `brochure.html` and `index.html` rendered correctly. Typical zip is 30–50 MB for a 16-page folio.
|
|
343
343
|
- **Back-page QR codes are clickable.** Each QR is wrapped in an `<a target="_blank">` tag and accompanied by a visible underlined URL beneath the label, so a digital reader can click instead of scan. See `property-brochure → Back-page QR codes`.
|
|
344
344
|
- The user-facing report names which steps ran and which were reused, **and the chosen orientation**, **and the path to both the canonical print archive and the web zip**.
|
|
345
345
|
- No image was read in violation of the 2000px rule (verifiable: every image read should have been preceded by a `sips`/`identify` measurement, and any over-threshold image was previewed via `/tmp/`).
|
package/payload/premium-plugins/real-agency/plugins/brochures/skills/property-brochure/SKILL.md
CHANGED
|
@@ -9,7 +9,7 @@ Produce an A4 property brochure from raw assets. The default deliverable is a 16
|
|
|
9
9
|
|
|
10
10
|
## Output location
|
|
11
11
|
|
|
12
|
-
By default the brochure is written to the **caller's current working directory**. The skill creates `./output/brochure.html`, `./output/web/
|
|
12
|
+
By default the brochure is written to the **caller's current working directory**. The skill creates `./output/brochure.html`, `./output/web/index.html`, the print PNGs, and the two PDFs underneath `pwd` — no inference, no nesting under a brand workspace.
|
|
13
13
|
|
|
14
14
|
If you want the output elsewhere, pass `output_dir` explicitly (an absolute path or a relative path resolved against CWD). Common overrides:
|
|
15
15
|
|
|
@@ -23,12 +23,12 @@ Do not silently nest the output under a brand workspace just because one is pres
|
|
|
23
23
|
|
|
24
24
|
## Step 1 — copy the canonical templates (do this first)
|
|
25
25
|
|
|
26
|
-
`references/template.html` and `references/
|
|
26
|
+
`references/template.html` and `references/index.html` are the **only** acceptable starting points. They are placeholder-only — every property value is a `{{ token }}`. Copy them before doing anything else. **Do not write brochure HTML or page HTML from scratch. Do not start from a sibling property's output. Do not improvise structure from prose.**
|
|
27
27
|
|
|
28
28
|
```bash
|
|
29
29
|
mkdir -p <output_dir>/web
|
|
30
30
|
cp ~/.claude/plugins/real-estate-brochure/skills/property-brochure/references/template.html <output_dir>/brochure.html
|
|
31
|
-
cp ~/.claude/plugins/real-estate-brochure/skills/property-brochure/references/
|
|
31
|
+
cp ~/.claude/plugins/real-estate-brochure/skills/property-brochure/references/index.html <output_dir>/web/index.html
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
`<output_dir>` is whatever the **Output location** section above resolves to — typically `./output/` in the caller's CWD.
|
|
@@ -43,7 +43,7 @@ Both files must contain canonical sentinels (proves they came from `references/`
|
|
|
43
43
|
|
|
44
44
|
```bash
|
|
45
45
|
grep -q '{{ property_name }}' <output_dir>/brochure.html || echo "FAIL: brochure not from template"
|
|
46
|
-
grep -q 'hero-stage' <output_dir>/web/
|
|
46
|
+
grep -q 'hero-stage' <output_dir>/web/index.html || echo "FAIL: index.html not from references"
|
|
47
47
|
```
|
|
48
48
|
|
|
49
49
|
Both greps must succeed before populating content. If either fails, recopy.
|
|
@@ -74,14 +74,14 @@ Read the seller brief once before writing any copy. Then walk the `{{ placeholde
|
|
|
74
74
|
| Editorial copy register, em-dash policy, "home" vs "house", text-on-images, typography, stats rows, AI hero prompt, **per-section composition guide** | [references/copy.md](references/copy.md) |
|
|
75
75
|
| Orientation, 16-page layout, cover, floorplan, distinguishing features, Material Information, QR codes, location map | [references/structure.md](references/structure.md) |
|
|
76
76
|
| Live editing, snapshot capture, PDF deliverable, web bundle | [references/build.md](references/build.md) |
|
|
77
|
-
| `
|
|
77
|
+
| `index.html` landing page — sections, hero rotator, mobile/drawer | [references/index-landing.md](references/index-landing.md) |
|
|
78
78
|
|
|
79
79
|
### Substitution completion gate
|
|
80
80
|
|
|
81
81
|
A brochure with any `{{ x }}` still in the rendered HTML is **shipped broken**. Before any snapshot capture or PDF build, run:
|
|
82
82
|
|
|
83
83
|
```bash
|
|
84
|
-
unsubstituted=$(grep -c '{{' "$output_dir/brochure.html" "$output_dir/web/
|
|
84
|
+
unsubstituted=$(grep -c '{{' "$output_dir/brochure.html" "$output_dir/web/index.html" 2>/dev/null | awk -F: '{s+=$2} END {print s}')
|
|
85
85
|
[ "$unsubstituted" -eq 0 ] || { echo "DEFECT: $unsubstituted unsubstituted {{ tokens }} remain"; exit 1; }
|
|
86
86
|
```
|
|
87
87
|
|
|
@@ -106,7 +106,7 @@ unsubstituted=$(grep -c '{{' "$output_dir/brochure.html" "$output_dir/web/page.h
|
|
|
106
106
|
- **Page count must be even** (16 default; 12 if the property is too small). 14 is forbidden.
|
|
107
107
|
- **Image-only PDFs** — no fonts embedded, no Ghostscript anywhere in the chain.
|
|
108
108
|
- **Self-contained `output/`** — never reference `../` paths. Every asset the HTML uses must live under `output/images/`.
|
|
109
|
-
- **Web bundle must include `
|
|
109
|
+
- **Web bundle must include `index.html`** — a bundle without it is incomplete.
|
|
110
110
|
- **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.
|
|
111
111
|
- **No `{{ token }}` may remain in rendered output** — `grep '{{' output/brochure.html` must return zero matches before PDFs are built.
|
|
112
112
|
|
|
@@ -164,7 +164,7 @@ The web bundle is a parallel directory with the same on-disk shape as `output/`
|
|
|
164
164
|
```
|
|
165
165
|
output/web/
|
|
166
166
|
brochure.html # identical content; only print-img references switched .png → .jpg
|
|
167
|
-
|
|
167
|
+
index.html # companion landing page (mandatory in bundle)
|
|
168
168
|
cover-print.jpg, page2-print.jpg … backpage-print.jpg # 96 dpi JPEG snapshots (q=88)
|
|
169
169
|
<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
|
|
170
170
|
images/
|
|
@@ -220,7 +220,7 @@ A 16-page folio's web-bundle snapshots total ~3 MB (vs ~85 MB for the canonical
|
|
|
220
220
|
|
|
221
221
|
### Copy the web PDF into the bundle
|
|
222
222
|
|
|
223
|
-
The web PDF (`<slug>-brochure-web.pdf` at the property level) is also placed inside the bundle, named simply `<slug>-brochure.pdf` to match `
|
|
223
|
+
The web PDF (`<slug>-brochure-web.pdf` at the property level) is also placed inside the bundle, named simply `<slug>-brochure.pdf` to match `index.html` and `brochure.html` link conventions (where there's only one PDF in scope, no `-web` suffix is needed):
|
|
224
224
|
|
|
225
225
|
```bash
|
|
226
226
|
cp output/<slug>-brochure-web.pdf output/web/<slug>-brochure.pdf
|
|
@@ -245,7 +245,7 @@ Serve the unzipped bundle from an isolated temp directory and verify every refer
|
|
|
245
245
|
TMP=/tmp/web-test && rm -rf $TMP && mkdir -p $TMP
|
|
246
246
|
cd $TMP && unzip -q /path/to/<slug>-web.zip
|
|
247
247
|
python3 -m http.server 8765 &
|
|
248
|
-
for f in brochure.html
|
|
248
|
+
for f in brochure.html index.html cover-print.jpg images/<slug>-01.webp images/<brand>-logo-light.png <slug>-brochure.pdf; do
|
|
249
249
|
echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/$f) $f"
|
|
250
250
|
done
|
|
251
251
|
```
|
|
@@ -257,10 +257,10 @@ The brochure should render the same as the canonical preview, just lighter on th
|
|
|
257
257
|
| File | `output/` (canonical archive) | `output/web/` (web bundle) |
|
|
258
258
|
|---|---|---|
|
|
259
259
|
| `brochure.html` | full-resolution images, `.png` snapshot refs | identical structure, `.jpg` snapshot refs, smaller image refs |
|
|
260
|
-
| `
|
|
260
|
+
| `index.html` | — | ✓ companion landing page; see `index-landing.md` |
|
|
261
261
|
| `<slug>-brochure-print.pdf` | ✓ canonical print master (50–80 MB, 300 dpi) | — (too large to bundle) |
|
|
262
262
|
| `<slug>-brochure-web.pdf` | ✓ web/digital deliverable (20–35 MB, 192 dpi) | — copied into bundle as `<slug>-brochure.pdf` |
|
|
263
|
-
| `<slug>-brochure.pdf` | — | ✓ identical bytes to `-web.pdf`; matches
|
|
263
|
+
| `<slug>-brochure.pdf` | — | ✓ identical bytes to `-web.pdf`; matches index.html / brochure.html link |
|
|
264
264
|
| `cover-print.png … backpage-print.png` | ✓ 300 dpi PNG (~4–7 MB each) — canonical snapshots | — replaced by 96 dpi `.jpg` versions |
|
|
265
265
|
| `cover-print.jpg … backpage-print.jpg` | — | ✓ 96 dpi JPEG (~150 KB each, derived from the 300 dpi PNGs) |
|
|
266
266
|
| `images/<slug>-NN.webp` | full-quality per Render-slot table | web-tier per the web table above |
|
|
@@ -109,7 +109,7 @@ If five cells at the chosen font size do not fit the available row width, **shri
|
|
|
109
109
|
|
|
110
110
|
## Per-section composition guide
|
|
111
111
|
|
|
112
|
-
This section is the writing brief for each placeholder block in `template.html` and `
|
|
112
|
+
This section is the writing brief for each placeholder block in `template.html` and `index.html`. The token names map 1:1 to entries in `placeholders.md`; this file says **how the copy should read** for each one.
|
|
113
113
|
|
|
114
114
|
### Cover & subtitle (page 1)
|
|
115
115
|
|
|
@@ -195,9 +195,9 @@ This section is the writing brief for each placeholder block in `template.html`
|
|
|
195
195
|
- `{{ backpage_headline }}` — canonical phrasing "Arrange a viewing of <em>{{ property_name }}</em>". Do not paraphrase.
|
|
196
196
|
- `{{ backpage_tagline }}` — single sentence, 25-45 words. Mirrors the cover/opener without duplicating phrases. Close with "Best understood in person." or an equivalent property-specific clincher.
|
|
197
197
|
|
|
198
|
-
### Landing page (
|
|
198
|
+
### Landing page (index.html)
|
|
199
199
|
|
|
200
|
-
The landing-page tokens (`{{ landing_* }}`) source the same brief but render in continuous-scroll form rather than a 16-page folio. Each `{{ landing_*_para_N }}` follows the same length/voice rules as the equivalent brochure paragraph — see placeholders.md → "Category 9 —
|
|
200
|
+
The landing-page tokens (`{{ landing_* }}`) source the same brief but render in continuous-scroll form rather than a 16-page folio. Each `{{ landing_*_para_N }}` follows the same length/voice rules as the equivalent brochure paragraph — see placeholders.md → "Category 9 — index.html editorial copy" for the per-section breakdown.
|
|
201
201
|
|
|
202
202
|
## AI hero image prompt
|
|
203
203
|
|
|
@@ -120,7 +120,7 @@ The brochure is a **print-first deliverable**. Source images target the print-ma
|
|
|
120
120
|
| **Map / screenshot** | screenshot with text labels | **2000 px** | source size, no resize | 88 |
|
|
121
121
|
| **EPC chart** | small, already <100KB — copy through unchanged | n/a | as-is | as-is |
|
|
122
122
|
|
|
123
|
-
**Floor plan exception — keep the source file as-is.** When the seller (or the agency's floor-plan provider) supplies a high-resolution PNG, **use it directly** — do not convert to WebP, do not downscale, do not re-encode. Floor plans are line art with thin strokes and crisp text where any quality loss is visibly worse than the same quality loss on photographic content. The same file (`<slug>-floorplan.png`) is referenced in the canonical brochure HTML, in the
|
|
123
|
+
**Floor plan exception — keep the source file as-is.** When the seller (or the agency's floor-plan provider) supplies a high-resolution PNG, **use it directly** — do not convert to WebP, do not downscale, do not re-encode. Floor plans are line art with thin strokes and crisp text where any quality loss is visibly worse than the same quality loss on photographic content. The same file (`<slug>-floorplan.png`) is referenced in the canonical brochure HTML, in the index.html landing page, and copied verbatim into `output/web/images/`. Because the new PDF pipeline rasterises whole pages from the rendered HTML (rather than passing the floor plan through a separate compression stage), the floor plan's quality at the PDF stage is determined by the snapshot DPI — 300 dpi for the print master, 192 dpi for the web PDF. A typical 2000 px floor plan slotted into a ~150 mm-wide brochure cell renders crisply at 300 dpi without any per-image processing. The **web bundle** also references the same `.png` (not a `.webp` substitute) — line art doesn't compress well as WebP and the file size is already modest.
|
|
124
124
|
|
|
125
125
|
The **source-px floor** is the floor on the input photo's longest edge **before optimisation** — see the `Image resolution floor` section in the **a4-print-documents** SKILL for the rationale and full thresholds.
|
|
126
126
|
|
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
# Page — companion property landing page (`
|
|
1
|
+
# Page — companion property landing page (`index.html`)
|
|
2
2
|
|
|
3
|
-
The web bundle includes a **single-page property landing site** at `output/web/
|
|
3
|
+
The web bundle includes a **single-page property landing site** at `output/web/index.html`. The brochure is a folio for download; the landing page is the "for sale" web page a property micro-site or a portal page links to. They share the same web-tier images in `images/` but present the property differently — the brochure as an editorial document; the landing page as a continuous, scrollable web page.
|
|
4
4
|
|
|
5
|
-
The landing page is **mandatory in the web bundle**. A property-brochure run that ships the brochure web bundle without `
|
|
5
|
+
The landing page is **mandatory in the web bundle**. A property-brochure run that ships the brochure web bundle without `index.html` is incomplete.
|
|
6
6
|
|
|
7
|
-
## Canonical reference — `references/
|
|
7
|
+
## Canonical reference — `references/index.html` is the only starting point
|
|
8
8
|
|
|
9
|
-
`references/
|
|
9
|
+
`references/index.html` ships with this skill alongside `references/template.html`. It is the **only** acceptable starting point for the landing page, exactly as `template.html` is the only acceptable starting point for the brochure. Every property-brochure run that produces a web bundle MUST start by copying `references/index.html` to `<property_dir>/output/web/index.html` and substituting the markers — never re-build the file from scratch from the prose in this reference.
|
|
10
10
|
|
|
11
11
|
The subsections below describe **why** the canonical is the way it is. They are **not** instructions to rebuild it. If the canonical file already encodes a rule (e.g. drop cap on the first paragraph of every body-block, the 3-slide Ken-Burns rotator with reduced-motion fallback, the hamburger drawer with full nav set on mobile), copying the canonical IS following the rule. The reference file's leading comment block lists the editorial decisions baked in — read it once before any substitution.
|
|
12
12
|
|
|
@@ -14,12 +14,12 @@ The subsections below describe **why** the canonical is the way it is. They are
|
|
|
14
14
|
|
|
15
15
|
Per the same logic as the brochure's *Forbidden template sources*, do **not** start the landing page from any of:
|
|
16
16
|
|
|
17
|
-
- A sibling property's rendered `output/web/
|
|
18
|
-
- A previous version of `references/
|
|
17
|
+
- A sibling property's rendered `output/web/index.html` (will inherit that property's hand-tuned text + image refs)
|
|
18
|
+
- A previous version of `references/index.html` cached anywhere outside the live skill folder
|
|
19
19
|
- The prose alone, hand-copying CSS/markup blocks from the section descriptions below
|
|
20
20
|
- A "modern landing page generator" or any tool that produces a layout from scratch
|
|
21
21
|
|
|
22
|
-
A run that reconstructs
|
|
22
|
+
A run that reconstructs index.html from prose, even if the result *looks* close to the canonical, drifts every brochure cycle. The dropcap, the rotator timing, the `BRAND-SLUG-logo-dark.png` filename pattern, the sub-nav scroll behaviour, the drawer markup — all of it lives literally in `references/index.html`. Copy it.
|
|
23
23
|
|
|
24
24
|
## How to populate
|
|
25
25
|
|
|
@@ -27,7 +27,7 @@ Two steps:
|
|
|
27
27
|
|
|
28
28
|
1. **Bulk-substitute literal placeholders** (one find/replace pass each):
|
|
29
29
|
- `PROPERTY-SLUG` → property slug (e.g. `warblers-lodge`). Rewrites every image src (`PROPERTY-SLUG-NN.webp`, `PROPERTY-SLUG-floorplan.png`, `PROPERTY-SLUG-hero-N-<role>.webp`) and the in-bundle PDF link (`PROPERTY-SLUG-brochure.pdf`).
|
|
30
|
-
- `BRAND-SLUG` → brand slug (e.g. `rossmargetts`). Rewrites the brand logo path (`BRAND-SLUG-logo-dark.png` — the dark-art variant on the warm-paper top nav). The
|
|
30
|
+
- `BRAND-SLUG` → brand slug (e.g. `rossmargetts`). Rewrites the brand logo path (`BRAND-SLUG-logo-dark.png` — the dark-art variant on the warm-paper top nav). The index.html canonical only uses the dark variant; the light variant is brochure-only.
|
|
31
31
|
|
|
32
32
|
2. **Substitute each `<!-- REPLACE: ... -->` block** with property-specific copy drawn from the seller brief and `property.json`. Walk the file top-to-bottom: `<title>`, meta description, top-nav utility links, drawer nav + foot, sticky sub-nav left/right, hero info-card (tag, title, address, price, meta, four spec cells, three link slots, CTA mailto subject), three hero slides + alt text, six body-block sections (Story, The Home, Bedrooms, The Garden, The Area — eyebrow + h2 + paragraphs), six photo grids (four cells each, 4:3 cover), floor-plan section meta, key-facts grid (~15 rows), CTA strip, footer.
|
|
33
33
|
|
|
@@ -77,7 +77,7 @@ If the property has no kitchen/garden distinction (e.g. an apartment), substitut
|
|
|
77
77
|
|
|
78
78
|
The hero shows each image **fullscreen at viewport scale**, with a 1.05–1.20× Ken-Burns transform on top. That means a body-tier 1300 px @ q82 webp will visibly soften at typical desktop and Retina viewports. The hero needs its own encode tier:
|
|
79
79
|
|
|
80
|
-
| Setting | Body tier (rest of `
|
|
80
|
+
| Setting | Body tier (rest of `index.html`) | **Hero tier** |
|
|
81
81
|
|---|---|---|
|
|
82
82
|
| Long edge | 1300 px (hero) / 1100 px (story) / 800 px (thumb) | **source resolution** (no `-resize`) — typically 1536–1600 px |
|
|
83
83
|
| Quality | q82 / q80 / q76 | **q88** |
|
|
@@ -198,7 +198,7 @@ Some viewers find subtle continuous motion uncomfortable. The `@media (prefers-r
|
|
|
198
198
|
The page has two image tiers:
|
|
199
199
|
|
|
200
200
|
1. **Hero rotator** — three dedicated `<slug>-hero-1-main.webp`, `<slug>-hero-2-<role>.webp`, `<slug>-hero-3-<role>.webp` files at source resolution and q88. See **Hero — rotating Ken-Burns stage** above. These are *additional* files; they don't replace the body-tier images even when sourced from the same originals — the body-tier `<slug>-03.webp` is still used in the photo grids.
|
|
201
|
-
2. **Body photo grids** — `<slug>-NN.webp` at the body-tier (1300/1100/800 px @ q82/80/76) — the same images the brochure HTML references. Both `brochure.html` and `
|
|
201
|
+
2. **Body photo grids** — `<slug>-NN.webp` at the body-tier (1300/1100/800 px @ q82/80/76) — the same images the brochure HTML references. Both `brochure.html` and `index.html` resolve them to the same file under `output/web/images/`.
|
|
202
202
|
|
|
203
203
|
Distribute every brochure image across the photo grids — typically 24–28 unique images across 6 grids of 4. If the count doesn't divide cleanly (e.g. 23 images for 6 grids of 4 = 24 cells), reuse the cover hero in the final cell as a closing visual.
|
|
204
204
|
|
|
@@ -361,10 +361,10 @@ When testing at mobile widths, screenshot at three viewports: **390 × 844** (iP
|
|
|
361
361
|
|
|
362
362
|
## Bundling and smoke-test
|
|
363
363
|
|
|
364
|
-
`
|
|
364
|
+
`index.html` is included in `<slug>-web.zip` automatically (the zip command zips everything in `output/web/`). After zipping, the smoke-test must include both `brochure.html` AND `index.html`:
|
|
365
365
|
|
|
366
366
|
```bash
|
|
367
|
-
for f in brochure.html
|
|
367
|
+
for f in brochure.html index.html cover-print.jpg images/<slug>-01.webp images/<brand>-logo-dark.png <slug>-brochure.pdf; do
|
|
368
368
|
echo "$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:8765/$f) $f"
|
|
369
369
|
done
|
|
370
370
|
```
|
|
@@ -373,4 +373,4 @@ Both pages must return 200 and render correctly. The landing page should not exh
|
|
|
373
373
|
|
|
374
374
|
## When to regenerate
|
|
375
375
|
|
|
376
|
-
`
|
|
376
|
+
`index.html` is regenerated whenever the brochure's image set changes (new photos, swapped slot assignments) or whenever the brochure's chapter copy materially changes (the landing page mirrors the brochure's narrative). Cosmetic CSS-only changes to the brochure don't require a landing-page regenerate. The landing page is *not* part of the canonical print archive — it lives only in `output/web/` and the zip.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
<!--
|
|
2
2
|
════════════════════════════════════════════════════════════════════════════
|
|
3
|
-
COMPANION LANDING PAGE —
|
|
3
|
+
COMPANION LANDING PAGE — index.html · canonical reference template
|
|
4
4
|
════════════════════════════════════════════════════════════════════════════
|
|
5
5
|
|
|
6
6
|
This file is the canonical starting point for the property micro-site that
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
─────────────────────
|
|
14
14
|
Every property-specific value in this file is a {{ token }} placeholder.
|
|
15
15
|
There is no exemplar copy left to accidentally inherit. After substitution,
|
|
16
|
-
`grep "{{" output/web/
|
|
16
|
+
`grep "{{" output/web/index.html` must return ZERO matches — any remaining
|
|
17
17
|
{{ x }} is a bug.
|
|
18
18
|
|
|
19
19
|
The full token list (with source field, format, length, voice) lives in
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
|
|
24
24
|
Contract:
|
|
25
25
|
|
|
26
|
-
1. Copy this file to <property>/output/web/
|
|
26
|
+
1. Copy this file to <property>/output/web/index.html.
|
|
27
27
|
2. Substitute every {{ placeholder }} token from the master placeholders.md.
|
|
28
28
|
3. Update <title>, meta description, OG/Twitter image refs.
|
|
29
29
|
4. If a section does not apply (e.g. property has no garden complex), follow
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
- Mobile collapse: top-nav → hamburger drawer at ≤960px; hero stacks at
|
|
41
41
|
≤760px; hero aspect 5/4 at ≤440px.
|
|
42
42
|
- Type system: Cormorant Garamond (display) + Lora (body serif) + Inter
|
|
43
|
-
(UI labels). DO NOT swap these per-brand — the
|
|
43
|
+
(UI labels). DO NOT swap these per-brand — the index.html register is
|
|
44
44
|
tuned to the canonical premium tier; brand identity lives in the logo,
|
|
45
45
|
copy and brand-specific colour accents that may be added via :root
|
|
46
46
|
overrides if needed (rare).
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Placeholders — the substitution contract
|
|
2
2
|
|
|
3
|
-
`template.html` and `
|
|
3
|
+
`template.html` and `index.html` are pure placeholder templates: every property-specific value is a `{{ token }}` token. There is no exemplar copy left to accidentally inherit. After rendering, `grep "{{" output/brochure.html` (and `output/web/index.html`) MUST return **zero matches**. Any remaining `{{ x }}` is a substitution bug.
|
|
4
4
|
|
|
5
5
|
This file is the substitution contract — the exhaustive list of every token used across both templates, what fills it, what format it takes, and what voice the copy tokens need. Style and composition guidance lives in `copy.md` (and is referenced inline from each `<!-- REPLACE: ... -->` comment in the templates). The two files are complementary: this one says **what** each token is; `copy.md` says **how** the copy should read.
|
|
6
6
|
|
|
@@ -11,7 +11,7 @@ Worked examples below quote the validated Sparrows Farm reference brochure at `/
|
|
|
11
11
|
1. Open the seller brief at `<property_dir>/seller-brief.md` and `<property_dir>/property.json` side-by-side.
|
|
12
12
|
2. Walk the categories below top-to-bottom — common tokens first (identity, brand, agent, listing, specs), then chapter-by-chapter editorial copy.
|
|
13
13
|
3. For each token, write the substitution into a single dict / map keyed by token name. The substitution step at the end of `property-brochure` reads the map and rewrites both files in one pass.
|
|
14
|
-
4. Run the **completion check**: `grep -c "{{" output/brochure.html output/web/
|
|
14
|
+
4. Run the **completion check**: `grep -c "{{" output/brochure.html output/web/index.html`. Both files must return `0`. If any token is unsubstituted, it is a defect — the brochure is not shippable until every `{{ x }}` is gone.
|
|
15
15
|
|
|
16
16
|
## Category 1 — Property identity
|
|
17
17
|
|
|
@@ -46,7 +46,7 @@ From the brand pack at `<brand_dir>/{description.md, DESIGN.md}` and `property.j
|
|
|
46
46
|
| `{{ brand_name }}` | `description.md` first line | Title Case | "Muvin", "Beacons Real Estate". |
|
|
47
47
|
| `{{ brand_town }}` | `description.md` officials block | Title Case | Branch town. |
|
|
48
48
|
| `{{ brand_county }}` | `description.md` officials block | Title Case | |
|
|
49
|
-
| `{{ brand_year }}` | Current year | YYYY | Used in the
|
|
49
|
+
| `{{ brand_year }}` | Current year | YYYY | Used in the index.html footer copyright. |
|
|
50
50
|
| `{{ office_address_line_1 }}` | `description.md` registered office line | "25 Bell Street" | Street, first line of the postal address. |
|
|
51
51
|
| `{{ office_address_line_2 }}` | `description.md` registered office line | "Sawbridgeworth, Hertfordshire CM21 9AR" | Locality + county + postcode. |
|
|
52
52
|
| `{{ office_phone }}` | `property.json -> agent.phone` | Display format | "01873 377 575". |
|
|
@@ -208,7 +208,7 @@ Every `{{ mi_* }}` token corresponds to a Material Information row. Mark unknown
|
|
|
208
208
|
| `{{ backpage_headline }}` | "Arrange a viewing of <em>{{ property_name }}</em>" — canonical phrasing. |
|
|
209
209
|
| `{{ backpage_tagline }}` | 25-45 words. Mirrors but does not duplicate the cover/opener. Close with "Best understood in person." or equivalent clincher. |
|
|
210
210
|
|
|
211
|
-
## Category 9 —
|
|
211
|
+
## Category 9 — index.html (landing) editorial copy
|
|
212
212
|
|
|
213
213
|
The landing page mirrors the brochure's content but in a continuous-scroll form. Tokens are prefixed `landing_*` to avoid name collisions with the brochure tokens. Sections:
|
|
214
214
|
|
|
@@ -235,7 +235,7 @@ The substitution step that runs `property-brochure` should end with this verific
|
|
|
235
235
|
|
|
236
236
|
```bash
|
|
237
237
|
PROP=<property_dir>
|
|
238
|
-
remaining=$(grep -c '{{' "$PROP/output/brochure.html" "$PROP/output/web/
|
|
238
|
+
remaining=$(grep -c '{{' "$PROP/output/brochure.html" "$PROP/output/web/index.html" 2>/dev/null | awk -F: '{s+=$2} END {print s}')
|
|
239
239
|
if [ "$remaining" -gt 0 ]; then
|
|
240
240
|
echo "DEFECT: $remaining unsubstituted {{ tokens }} remain — see grep '{{' output/"
|
|
241
241
|
exit 1
|
|
@@ -247,4 +247,4 @@ A brochure with any `{{ x }}` remaining in the rendered output is shipped broken
|
|
|
247
247
|
|
|
248
248
|
## Adding a new token
|
|
249
249
|
|
|
250
|
-
If a property has a feature the canonical placeholder set doesn't cover (e.g. an annexe with separate utility, a wine cellar, a moat — they exist), do not invent a new token in the templates without also adding the entry here. The substitution contract is what catches the bug; an unlisted token gets silently ignored by the substitution step and the brochure ships with `{{ wine_cellar }}` literal in the body. Either reuse an existing token, or add the new one to this file AND to template.html/
|
|
250
|
+
If a property has a feature the canonical placeholder set doesn't cover (e.g. an annexe with separate utility, a wine cellar, a moat — they exist), do not invent a new token in the templates without also adding the entry here. The substitution contract is what catches the bug; an unlisted token gets silently ignored by the substitution step and the brochure ships with `{{ wine_cellar }}` literal in the body. Either reuse an existing token, or add the new one to this file AND to template.html/index.html before rendering.
|