@rubytech/create-maxy-code 0.1.68 → 0.1.69

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rubytech/create-maxy-code",
3
- "version": "0.1.68",
3
+ "version": "0.1.69",
4
4
  "description": "Install Maxy — AI for Productive People",
5
5
  "bin": {
6
6
  "create-maxy-code": "./dist/index.js"
@@ -129,7 +129,7 @@ Tools are available via the `admin` MCP server.
129
129
  - `hooks/pre-tool-use.sh` — enforces admin agent write boundaries
130
130
  - `hooks/playwright-file-guard.sh` — rewrites file:// URLs to a backgrounded loopback http.server before Playwright sees them
131
131
  - `hooks/webfetch-preflight.mjs` — short-circuits WebFetch on JS-SPA shells with a structured `WEBFETCH_CANNOT_READ_JS_SPA` error so the agent surfaces a loud failure to the owner instead of paying the 60s extraction timeout. Fail-open on any internal error.
132
- - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope conforming to the database-operator agent file's `## Input shape`. Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
132
+ - `hooks/turn-completed-graph-write.sh` — Stop hook fired once per completed admin-agent turn. Gates on `MAXY_SESSION_ROLE=admin` + `MAXY_SPECIALIST!=database-operator` so it never recurses into the recorder PTY or fires on public sessions. **Task 147** — the recorder is spawned via the same route Sidebar uses. ONE POST to `POST /api/admin/claude-sessions`, body carries `{specialist:'database-operator', model:'haiku', initialMessage:<json-envelope>, adminSessionId:<op>}` — no synthetic `senderId: 'turn-recorder'` marker. The Hono wrapper bypasses cookie auth on this exact method+path when the request originates from `127.0.0.1` (same trust boundary the claude-session-manager itself relies on), looks up the operator's senderId from the manager's `/<adminSessionId>/meta`, and forwards a Sidebar-shape spawn body. The `/recorder-spawn` sibling route is gone. The hook reads its UI port from `MAXY_UI_INTERNAL_PORT` (stamped on the manager systemd unit) — no fallback; absence emits `[turn-recorder] spawn-failed reason=missing-env env=MAXY_UI_INTERNAL_PORT` to stderr instead of silently 19199-ing. **Task 177** — `initialMessage` is a JSON-stringified envelope; the schema lives in [`platform/plugins/docs/references/admin-session.md`](../docs/references/admin-session.md) under "`initialMessage` JSON envelope (Task 177)". Top-level keys exactly: `turns`, `conversationId`, `accountId`, `occurredAt`. `turns` is the full conversation transcript, oldest first, newest last, with each entry `{ role: "user"|"assistant", text, ts, toolCalls? }`. No windowing, no truncation. Multi-record assistant messages collapse on `message.id`. `tool_use` and matching `tool_result` blocks attach as a single `toolCalls` entry on the owning assistant turn; the user record carrying only the `tool_result` does not create a separate user turn. No leading instruction prose. `toolCalls[].input` and `toolCalls[].output` are native JSON values, never re-stringified. (Replaces Task 175's `(operatorMessage, assistantReply)` pair, which asserted a temporal Q→A relationship the walker never enforced.) One observability line `[turn-recorder] envelope sessionId=<op> turnsCount=<n> userTurns=<n> assistantTurns=<n> toolCallTurns=<n>` precedes `spawn-request`. The envelope rides on the `/spawn` body as a trailing positional argv to `claude`, so the database-operator session's JSONL first `role=user` line is the JSON object verbatim. No separate `POST /:id/input` call, no bracketed-paste, no keystroke injection. The recorder-auto-archive subscriber stops the recorder PTY as soon as its JSONL contains an assistant message with `stop_reason === "end_turn"`. **Task 129** — every emit goes through `POST /api/admin/log-ingest` so the lines land in `server.log` keyed by the operator session id. The chain is `trigger` → `spawn-request` → `spawn-success` → `input-delivered` → `tool-call` × N → `tool-result` × N → `write-complete` → `auto-archive`; each gated-off path emits one `trigger-skipped reason=<role-not-admin|is-recorder|empty-stdin|missing-transcript|conversation-empty>` line. Failure modes — `spawn-failed`, `tool-surface-missing`, `input-failed`, `write-empty`, `auto-archive reason=stale-recorder` — each emit one named line; absence is itself a defect.
133
133
 
134
134
  ## Session identifiers (Task 135)
135
135
 
@@ -96,7 +96,7 @@ Body overrides relative to a plain Sidebar click:
96
96
 
97
97
  **Spawn body.** The hook POSTs `{adminSessionId, channel: 'browser', specialist: 'database-operator', model: 'haiku', initialMessage}` to `POST /api/admin/claude-sessions`. `adminSessionId` is the loopback-bypass key — the wrapper resolves the operator's real `senderId` from `GET <managerBase>/<adminSessionId>/meta` and forwards a Sidebar-shape body to the manager.
98
98
 
99
- **`initialMessage` JSON envelope (Task 177).** A single JSON object stringified to a string. Top-level keys exactly match the database-operator agent file `## Input shape`:
99
+ **`initialMessage` JSON envelope (Task 177).** A single JSON object stringified to a string. The schema below is the authoritative contract — the database-operator agent file no longer carries it, so any change here is the only place the envelope shape moves:
100
100
 
101
101
  ```
102
102
  {
@@ -8,68 +8,28 @@ tools: mcp__memory__memory-write, mcp__memory__memory-update, mcp__memory__memor
8
8
 
9
9
  # Database Operator
10
10
 
11
- You are the background recorder for this account's memory graph. Every admin-agent turn that completes fires you as a hidden PTY with the operator's full conversation transcript as stdin. You read what was said, decide what is worth recording from the most recent operator turn, and write through the wrapped writers. You never interact with the operator.
12
-
13
- ## Input shape
14
-
15
- Stdin is one JSON object carrying the chronologically-ordered conversation:
16
-
17
- ```
18
- {
19
- "turns": [
20
- { "role": "user", "text": "<what the owner said>", "ts": "<ISO-8601>" },
21
- { "role": "assistant", "text": "<what the agent said>", "ts": "<ISO-8601>",
22
- "toolCalls": [ { "tool": "...", "input": {...}, "output": ... } ] },
23
- ...
24
- ],
25
- "conversationId": "<intrinsic claude session id>",
26
- "accountId": "<account id>",
27
- "occurredAt": "<ISO-8601 timestamp of the recorder fire>"
28
- }
29
- ```
30
-
31
- The array is oldest first, newest last — every user / assistant turn in the operator's transcript from session start to the moment the recorder fired. No windowing, no truncation. The **most recent operator turn** (the last `role == "user"` entry) is the trigger; everything before it is conversational context that disambiguates what the operator means.
32
-
33
- `toolCalls` appears on assistant turns that called tools. `input` and `output` are native JSON, not strings. `toolCalls` is omitted on turns that called no tools.
34
-
35
- A worked example. Four turns ending in a name-and-business statement:
36
-
37
- ```
38
- {
39
- "turns": [
40
- { "role": "assistant", "text": "Session noted. What would you like to work on?", "ts": "2026-05-19T21:27:45.158Z" },
41
- { "role": "user", "text": "you tell me", "ts": "2026-05-19T21:27:57.314Z" },
42
- { "role": "assistant", "text": "What is your name and the agency you operate under?", "ts": "2026-05-19T21:28:00.847Z" },
43
- { "role": "user", "text": "Smalleys. I am Joel Smalley", "ts": "2026-05-19T21:28:12.248Z" }
44
- ],
45
- "conversationId": "bf704cba-eaba-4d9b-b606-7f4b8b84c011",
46
- "accountId": "be23f93a-1e51-4586-b37c-c0e86d1614c9",
47
- "occurredAt": "2026-05-19T21:28:13.000Z"
48
- }
49
- ```
50
-
51
- Read in order, the trigger is the last operator turn (`"Smalleys. I am Joel Smalley"`). The preceding assistant turn names what was asked. The graph write that follows is `LocalBusiness {name:"Smalleys"}` plus the `:Person {givenName:"Joel"}` adjacency.
11
+ You are this account's background recorder for the memory graph. You receive the operator's full conversation transcript and you decide what the most recent operator turn implies for the graph. You write through the wrapped tools listed in your frontmatter and produce no other output. The operator never sees you.
52
12
 
53
13
  ## Three rules
54
14
 
55
15
  These three rules win when anything else in this prompt conflicts with them.
56
16
 
57
- 1. **Be precise.** Every write has a source in the most recent operator turn, anchored by the prior turns in the array. No inferred facts. No "likely".
58
- 2. **Be concise.** No prose output. Your job is the write, not the report. If there is nothing to write, exit silently.
59
- 3. **Show your evidence.** Stamp `createdBySource='turn-recorder'` and `createdBySession=<conversationId>` on every node and edge so the audit trail names where the claim came from.
17
+ 1. **Be precise.** Every write traces to the most recent operator turn, anchored by the earlier turns in the transcript. No inferred facts. No "likely".
18
+ 2. **Be concise.** Your job is the write, not the report. If the latest operator turn implies no write, exit silently. Do not narrate, summarise, or describe the graph.
19
+ 3. **Stamp your evidence.** On every node and edge you write, set `createdBySource` to the literal string `turn-recorder` and `createdBySession` to the operator's conversation id so the audit trail names where the claim came from.
60
20
 
61
21
  ## Outcome contract
62
22
 
63
- Graph writes only, through the wrapped writers in your tool list. No raw cypher. No filesystem reads. No web calls. No tool not in your frontmatter. The most recent operator turn is what you record; earlier turns are context, not separate triggers. If the latest operator turn implies a write the wrapped writers cannot make, the write is out of scope for this run: skip silently and let the graph stay where it is.
23
+ Writes happen through the wrapped tools in your frontmatter. Nothing else. No raw cypher, no filesystem reads, no web calls, no tool outside that list. The most recent operator turn is what you record; earlier turns are context, not separate triggers. If the latest operator turn implies a change the wrapped tools cannot make, that change is out of scope for this run skip silently and leave the graph where it is.
64
24
 
65
25
  ## Graph hierarchy
66
26
 
67
- Every node has a parent in the canonical chain `LocalBusiness Project Task | Person | Organisation | KnowledgeDocument`. The wrapped writers reject orphans; never try to bypass that by writing the node first and the parent edge "later". If you do not have a parent for the node, do not write the node.
27
+ Every node has a parent in the canonical chain that runs LocalBusiness, then Project, then Task or Person or Organisation or KnowledgeDocument. The wrapped tools reject orphans; do not try to circumvent that by writing a node first and adding the parent edge later. If you do not have a parent for the node, do not write the node.
68
28
 
69
29
  ## Idempotency
70
30
 
71
- You will be fired many times against the same operator. The wrapped writers MERGE on natural keys; identical inputs produce no new nodes or edges. Do not invent surrogate keys to force a fresh write.
31
+ You will be fired many times against the same operator. The wrapped tools merge on natural keys; identical inputs produce no new nodes or edges. Do not invent surrogate keys to force a fresh write.
72
32
 
73
33
  ## When a tool returns an error
74
34
 
75
- Acknowledge the error in your log line, do not retry, do not call a different tool to "rescue" the write. The graph stays where it is; the next turn fires you again.
35
+ Acknowledge the error to yourself and stop. Do not retry. Do not call a different tool to rescue the write. The graph stays where it is; the next turn will fire you again.
@@ -73,6 +73,54 @@ What the file must contain regardless of who generated it:
73
73
 
74
74
  The naming convention is `--ds-{category}-{property}-{variant}-{state}` unless the site already ships a brand-prefixed convention, in which case adopt the existing prefix and note it.
75
75
 
76
+ #### Optional: `## Type roles` block (for branded brochures)
77
+
78
+ When the brand is the source for a property brochure run, the `property-brochure` skill's branded register applies the DESIGN.md's palette and font tokens automatically. For brands whose live identity diverges from the brochure template's editorial conventions (italic display, small-caps decks, gold hairlines under every heading, dropcaps, italic emphasis on `<em>`), the token-only swap is not enough — the brochure ends up reading as a country-house folio painted in the brand's colours, not as a brand document.
79
+
80
+ To close that, declare an optional `## Type roles` block in the DESIGN.md. The brochure build reads it and emits a deterministic CSS overlay at the template's `<!-- BRAND_TYPE_ROLES_OVERRIDE_SLOT -->` sentinel. The full role list (fixed, bounded), the allowed values per role, and the recipe map live in `property-brochure/references/registers.md → Type-role overrides` and `property-brochure/references/build.md → Type-role overlay generation`.
81
+
82
+ Format — one role per line, `role: value`, or a Markdown table:
83
+
84
+ ```markdown
85
+ ## Type roles
86
+
87
+ | Role | Value |
88
+ |---|---|
89
+ | `display-headline-style` | `normal` |
90
+ | `display-em-style` | `weight-shift` |
91
+ | `display-letterspacing` | `medium` |
92
+ | `deck-family` | `sans` |
93
+ | `deck-case` | `sentence` |
94
+ | `subtitle-family` | `sans-tracked-caps` |
95
+ | `caption-family` | `sans` |
96
+ | `caption-em-style` | `weight-shift` |
97
+ | `dropcap` | `hidden` |
98
+ | `hairline-rules` | `eyebrow-only` |
99
+ | `body-em-style` | `weight-shift` |
100
+ | `folio-em-style` | `weight-shift` |
101
+ | `backpage-em-style` | `weight-shift` |
102
+ ```
103
+
104
+ Omitted roles inherit the template default. The block is **optional**; brands whose identity already matches the template's editorial conventions (country-house agents, traditional residential, fine-art galleries) skip the block entirely.
105
+
106
+ How to decide which values to set, given the brand audit you've already done:
107
+
108
+ | Observation on the live site | Maps to role |
109
+ |---|---|
110
+ | Display headlines render upright (no `font-style: italic`) | `display-headline-style: normal` |
111
+ | Display family is a humanist sans (Montserrat, Inter, Söhne) | `display-letterspacing: medium` |
112
+ | Display family is a geometric/grotesque sans (Helvetica, Roboto Mono) | `display-letterspacing: tight` |
113
+ | The site never italicises anything | `display-em-style`, `body-em-style`, `caption-em-style`, `folio-em-style`, `backpage-em-style` → `weight-shift` (preferred) or `normal` |
114
+ | All body running text is sans-serif | `deck-family: sans`, `caption-family: sans` |
115
+ | Captions and labels share the body family | `caption-family: sans` |
116
+ | No dropcaps anywhere on the site | `dropcap: hidden` |
117
+ | Site uses bold sans for initial-cap accents (e.g. magazine-style hero pull-letters) | `dropcap: ink-bold-sans` |
118
+ | Decorative gold/accent rules appear only beside small labels, not under headings | `hairline-rules: eyebrow-only` (or `hidden` if no rules at all) |
119
+ | Subtitles use letter-spaced uppercase sans | `subtitle-family: sans-tracked-caps` |
120
+
121
+ If the brand's identity has no observable signal either way for a role, leave it omitted — the template default applies.
122
+
123
+
76
124
  ### `<slug>-logo-light.png` and `<slug>-logo-dark.png`
77
125
 
78
126
  Two PNGs of the wordmark/lockup as the brand uses it. **The filename describes the colour of the logo art itself, not the surface where it will be placed.** This matches the natural reading and the convention used by Google, Bootstrap, Tailwind, and most public design systems:
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: make-brochure
3
- description: Use when the user gives both an estate agent's website URL (or existing brand-pack directory) and a property listing URL (or existing property-assets directory) in one request, asking for the brochure as an end-to-end deliverable. Trigger phrases include "build a brochure for <listing> using the agent at <agent-url>", "create a brochure for the property at <X> with the brand at <Y>", "end-to-end brochure for <listing>", "do everything from brand to brochure for <listing>", "make me the brochure for <listing> on <agent-site>", or any request that names both a brand source and a property source in the same turn.
3
+ description: Meta-skill that runs the full brand-design → property-extract → property-brochure pipeline end-to-end, skipping any step whose output already exists on disk. This is the default surface whenever the deliverable is a property brochure — use it whether the user supplies an agent URL plus a listing URL, just a listing URL on a known agent, an existing brand pack plus a listing, or simply asks to "make a brochure for <listing>". The three component skills (brand-design, property-extract, property-brochure) remain individually callable for single-step work, but any request whose outcome is the brochure itself should route here. Trigger phrases include "make me a brochure for <listing>", "build the brochure for <X>", "brochure for <listing>", "do the brochure end-to-end", "everything from brand to brochure for <listing>", "create a brochure for the property at <X> with the brand at <Y>", or any full-pipeline brochure intent.
4
4
  allowed-tools:
5
5
  - Bash
6
6
  - Read
@@ -4,17 +4,18 @@
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. Serve locally: `python3 -m http.server <port>` from the project root
8
- 4. Navigate Playwright to the brochure URL
9
- 5. Present to user in browser for review
10
- 6. User requests changes iterate on content, image sizing, page balance
11
- 7. Verify every page fits A4: measure `scrollHeight` for all `.page` elements
12
- 8. Verify no 404s in the browser console
13
- 9. Repeat 5–8 until user approves the final copy
14
- 10. **Only after user approval**: render snapshots (two passes — 300 dpi to `output/`, 192 dpi to `output/.snapshots-web/`) per **Print snapshot capture** below
15
- 11. Build both PDFs (img2pdf + qpdf) per **PDF deliverable** below
16
- 12. Build the web bundle (96 dpi JPGs + bundled web PDF + zip) per **Web bundle** below
17
- 13. User downloads the web PDF via brochure.html's Download button or receives the print master separately for press/print runs
7
+ 3. **If branded register and `<brand_dir>/DESIGN.md` contains a `## Type roles` block** — emit the role overlay into both `brochure.html` and `page.html`/`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
+ 4. Serve locally: `python3 -m http.server <port>` from the project root
9
+ 5. Navigate Playwright to the brochure URL
10
+ 6. Present to user in browser for review
11
+ 7. User requests changes iterate on content, image sizing, page balance
12
+ 8. Verify every page fits A4: measure `scrollHeight` for all `.page` elements
13
+ 9. Verify no 404s in the browser console
14
+ 10. Repeat 6–9 until user approves the final copy
15
+ 11. **Only after user approval**: render snapshots (two passes — 300 dpi to `output/`, 192 dpi to `output/.snapshots-web/`) per **Print snapshot capture** below
16
+ 12. Build both PDFs (img2pdf + qpdf) per **PDF deliverable** below
17
+ 13. Build the web bundle (96 dpi JPGs + bundled web PDF + zip) per **Web bundle** below
18
+ 14. User downloads the web PDF via brochure.html's Download button or receives the print master separately for press/print runs
18
19
 
19
20
  Do not capture snapshots during the editing cycle — they are expensive and invalidated by every change.
20
21
 
@@ -268,3 +269,247 @@ The brochure should render the same as the canonical preview, just lighter on th
268
269
  | `.snapshots-web/*-print.png` | ✓ intermediate (192 dpi PNGs used by the web-PDF build; deletable after) | — |
269
270
 
270
271
  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.
272
+
273
+ ## Type-role overlay generation
274
+
275
+ 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.
276
+
277
+ Two agents running this build on different days for the same brand pack produce byte-identical overlays. That is the contract.
278
+
279
+ The role-overlay step runs **before** snapshot capture (step 10 in the live-editing workflow), not after. The overlay is part of the rendered HTML the user reviews, not a post-process applied to PDFs.
280
+
281
+ ### Recipe map — reference implementation
282
+
283
+ ```python
284
+ ROLE_RECIPES = {
285
+ 'display-headline-style': {
286
+ 'normal': [
287
+ '.cover-title, .opener-title, .ed-headline, .chapter-head h3, '
288
+ '.feature-page h3, .gallery-head h3, .siteplan-page h3, .toc-title, '
289
+ '.back-headline, .mi-head h2, .mi-head h3 '
290
+ '{ font-style: normal !important; }',
291
+ ],
292
+ },
293
+ 'display-em-style': {
294
+ 'weight-shift': [
295
+ '.cover-title em, .opener-title em, .ed-headline em, .toc-title em, '
296
+ '.back-headline em, .mi-head em '
297
+ '{ font-style: normal !important; font-weight: 500 !important; }',
298
+ ],
299
+ 'normal': [
300
+ '.cover-title em, .opener-title em, .ed-headline em, .toc-title em, '
301
+ '.back-headline em, .mi-head em '
302
+ '{ font-style: normal !important; }',
303
+ ],
304
+ },
305
+ 'display-letterspacing': {
306
+ # tuned to family: Cormorant=wide (0.18em), humanist sans=medium (0.10em),
307
+ # geometric sans=tight (0.04em). Applies only to the two largest display lines.
308
+ 'medium': [
309
+ '.cover-title { letter-spacing: 0.10em !important; text-indent: 0.10em !important; }',
310
+ '.opener-title { letter-spacing: -0.012em !important; }',
311
+ ],
312
+ 'tight': [
313
+ '.cover-title { letter-spacing: 0.04em !important; text-indent: 0.04em !important; }',
314
+ '.opener-title { letter-spacing: -0.02em !important; }',
315
+ ],
316
+ },
317
+ 'deck-family': {
318
+ 'sans': [
319
+ '.ed-deck, .ed-deck em, .feature-deck, .gallery-deck, .siteplan-deck '
320
+ '{ font-family: var(--sans) !important; font-style: normal !important; }',
321
+ ],
322
+ },
323
+ 'deck-case': {
324
+ 'sentence': [
325
+ '.ed-deck, .feature-deck, .gallery-deck, .siteplan-deck '
326
+ '{ letter-spacing: 0.02em !important; text-transform: none !important; }',
327
+ '.ed-deck em { font-weight: 500 !important; }',
328
+ ],
329
+ },
330
+ 'subtitle-family': {
331
+ 'sans-tracked-caps': [
332
+ '.opener-title em, .cover-subtitle, .opener-spec '
333
+ '{ font-family: var(--sans) !important; font-style: normal !important; }',
334
+ '.opener-spec em { font-style: normal !important; font-weight: 500 !important; }',
335
+ ],
336
+ 'sans': [
337
+ '.opener-title em, .cover-subtitle, .opener-spec '
338
+ '{ font-family: var(--sans) !important; font-style: normal !important; '
339
+ 'letter-spacing: 0.04em !important; text-transform: none !important; }',
340
+ ],
341
+ },
342
+ 'caption-family': {
343
+ 'sans': [
344
+ '.ed-caption, .ed-caption strong, .caption, .caption-pill, .caption-text, '
345
+ '.img-caption, .photo-caption, .intermission .caption-pill '
346
+ '{ font-family: var(--sans) !important; font-style: normal !important; }',
347
+ ],
348
+ },
349
+ 'caption-em-style': {
350
+ 'weight-shift': [
351
+ '.ed-caption em, .caption em, .caption-text em, .img-caption em, '
352
+ '.photo-caption em, .intermission .caption-pill em '
353
+ '{ font-style: normal !important; font-weight: 500 !important; }',
354
+ ],
355
+ },
356
+ 'dropcap': {
357
+ 'ink': [
358
+ '.ed-body.dropcap::first-letter { color: var(--ink) !important; }',
359
+ ],
360
+ 'ink-bold-sans': [
361
+ '.ed-body.dropcap::first-letter { '
362
+ 'font-family: var(--sans) !important; font-style: normal !important; '
363
+ 'font-weight: 700 !important; color: var(--ink) !important; }',
364
+ ],
365
+ 'hidden': [
366
+ '.ed-body.dropcap::first-letter { '
367
+ 'font-family: var(--sans) !important; font-style: normal !important; '
368
+ 'font-size: inherit !important; font-weight: 400 !important; '
369
+ 'line-height: inherit !important; color: inherit !important; '
370
+ 'float: none !important; padding: 0 !important; }',
371
+ ],
372
+ },
373
+ 'hairline-rules': {
374
+ 'eyebrow-only': [
375
+ '.opener .ed-rule, .opener-text .ed-rule, .opener-body .ed-rule, '
376
+ '.feature-page .ed-rule, .gallery-head .ed-rule, .siteplan-page .ed-rule, '
377
+ '.chapter-head .ed-rule { display: none !important; }',
378
+ ],
379
+ 'hidden': [
380
+ '.ed-rule, .toc-rule { display: none !important; }',
381
+ ],
382
+ },
383
+ 'body-em-style': {
384
+ 'weight-shift': [
385
+ '.ed-body em, .opener-body em, .opener-text em, .gallery-cell em, '
386
+ '.siteplan-panel em, .spec-list li em, .mi-row em, .floorplan-lead em, '
387
+ '.floorplan-sub em, .epc-caption em '
388
+ '{ font-style: normal !important; font-weight: 500 !important; }',
389
+ ],
390
+ },
391
+ 'folio-em-style': {
392
+ 'weight-shift': [
393
+ '.folio em, .folio .folio-mid em '
394
+ '{ font-style: normal !important; font-weight: 500 !important; '
395
+ 'font-family: var(--sans) !important; }',
396
+ ],
397
+ },
398
+ 'backpage-em-style': {
399
+ 'weight-shift': [
400
+ '.back-headline em, .back-tagline em, .back-foot em, .back-contact-grid em, '
401
+ '.back-rule em { font-style: normal !important; font-weight: 500 !important; }',
402
+ ],
403
+ },
404
+ }
405
+
406
+
407
+ def emit_overlay(role_values: dict[str, str]) -> str:
408
+ """Compose the <style id='brand-type-roles'> block from a {role: value} dict.
409
+
410
+ role_values is parsed from the DESIGN.md `## Type roles` block — either a
411
+ role/value table or a flat `role: value` line list. Missing roles inherit
412
+ the template default (no CSS emitted). Unknown roles raise ValueError so
413
+ they surface during the build, not silently in the rendered brochure.
414
+
415
+ DARK_SURFACE_PROTECTION and STRUCTURAL_CLAMP_LOOSEN are emitted FIRST and
416
+ UNCONDITIONALLY whenever any role values are declared. They are the
417
+ foundation of any branded re-skin; see "Always-emit recipes" above.
418
+ """
419
+ if not role_values:
420
+ return "" # No role block declared — emit nothing.
421
+ lines = ["<style id=\"brand-type-roles\">"]
422
+ lines.append(" /* === DARK SURFACE PROTECTION === */")
423
+ lines.extend(DARK_SURFACE_PROTECTION)
424
+ lines.append(" /* === STRUCTURAL CLAMP LOOSEN === */")
425
+ lines.extend(STRUCTURAL_CLAMP_LOOSEN)
426
+ for role, value in role_values.items():
427
+ if role not in ROLE_RECIPES:
428
+ raise ValueError(f"Unknown type role: {role}")
429
+ if value not in ROLE_RECIPES[role]:
430
+ raise ValueError(f"Unknown value '{value}' for role '{role}'")
431
+ lines.append(f" /* === ROLE: {role}: {value} === */")
432
+ lines.extend(ROLE_RECIPES[role][value])
433
+ lines.append("</style>")
434
+ return "\n".join(lines)
435
+ ```
436
+
437
+
438
+ ### Always-emit recipes — dark-surface protection and structural clamps
439
+
440
+ Two sections of the overlay are emitted **whenever the brand pack's `## Type roles` block is present**, regardless of which roles the operator declared. They are the foundation of any branded re-skin — without them the role API's targeted overrides can blow up adjacent template assumptions.
441
+
442
+ #### Dark-surface protection (fixed exclusion list)
443
+
444
+ The role API's color rules deliberately stop short of selectors that sit on dark surfaces — the toc-image caption pill, intermission caption pills, the spread-6 chapter-head overlay on a dark photo, the back-page masthead and tagline, and the back-page contact-grid name lines. The brand-token swap will have remapped `--gold-700` (the template's italic-emphasis color) to the brand's accent — typically navy or a deep brand colour. On a light surface, that's correct. On a dark surface, navy on dark is unreadable.
445
+
446
+ The recipe is a **fixed exclusion list**, not a heuristic. Ship it as part of the overlay:
447
+
448
+ ```python
449
+ DARK_SURFACE_PROTECTION = [
450
+ # Body text on dark surfaces — keep paper-light regardless of brand-token swap.
451
+ '.toc-image .caption, .toc-image .caption *, '
452
+ '.intermission .caption-pill, .intermission .caption-pill *, '
453
+ '.spread-6 .top-image .overlay, .spread-6 .top-image .overlay *, '
454
+ '.back-headline, .back-tagline, .back-foot, .back-contact-grid, .back-rule '
455
+ '{ color: var(--paper-25) !important; }',
456
+ # Emphasis on dark surfaces — use the brand accent (--gold-300) which the
457
+ # brand-tokens block typically maps to the brand's bright/highlight colour.
458
+ '.toc-image .caption em, '
459
+ '.intermission .caption-pill em, '
460
+ '.spread-6 .top-image .overlay em, '
461
+ '.spread-6 .top-image .overlay .ed-headline em, '
462
+ '.back-headline em, '
463
+ '.back-tagline em, '
464
+ '.back-contact-grid .cell .name em, '
465
+ '.back-foot em '
466
+ '{ color: var(--gold-300) !important; }',
467
+ ]
468
+ ```
469
+
470
+ The selector list is closed. Two agents producing brochures for the same brand pack emit byte-identical dark-surface blocks. If the template adds a new dark-surface region in a future revision, this list (in `build.md`) is the authoritative update site.
471
+
472
+ #### Structural clamp loosen
473
+
474
+ The template's chapter-heading grid rows are sized for the Cormorant Garamond display setup (italic, weight 400, letter-spacing tuned for didone). When the role overlay swaps display family, weight, or letter-spacing, headings can grow a few millimetres beyond what the fixed-height rows accommodate — and the template's `overflow: hidden` on those rows clips the descenders. The proposal author's term for this is "structural-element clamps that need to be loosened together with the type-role overlay."
475
+
476
+ The recipe is bounded: only the known fixed-height heading rows in spreads 2 and 3 are loosened, and only to `minmax(<original>, max-content)`. A heading that already fits the original budget renders identically; a heading that needs a few extra mm no longer clips.
477
+
478
+ ```python
479
+ STRUCTURAL_CLAMP_LOOSEN = [
480
+ # spread-3: chapter heading row fixed at 60mm in the template.
481
+ # Loosen to "60mm but grow if needed" — image cells in the second row
482
+ # absorb the shrinkage when the heading takes extra height.
483
+ '.spread-3 { grid-template-rows: minmax(60mm, max-content) 1fr !important; }',
484
+ '.spread-3 .head { overflow: visible !important; }',
485
+ # spread-2: bottom strip fixed at 56mm. Same treatment.
486
+ '.spread-2 { grid-template-rows: 1fr minmax(56mm, max-content) !important; }',
487
+ # Other spreads use auto 1fr or auto auto already — no loosening needed.
488
+ ]
489
+ ```
490
+
491
+ If a future template revision adds another fixed-height heading row, the entry is added here. The substitute step's `emit_overlay()` always emits these two sections first, then the per-role recipes.
492
+
493
+
494
+ ### Where the overlay lands
495
+
496
+ The template's main `<style>` ends with the sentinel line:
497
+
498
+ ```html
499
+ /* BRAND_TYPE_ROLES_OVERRIDE_SLOT — branded register may inject role overrides here */
500
+ </style>
501
+ ```
502
+
503
+ The build replaces that sentinel comment with the emitted `<style id="brand-type-roles">…</style>` block (or with nothing if no role values are declared). The original `<style>` continues to its closing `</style>` immediately after. Both `brochure.html` and the landing page (`page.html` / `index.html`) carry the sentinel and receive the same overlay — the role API is global to the property's output.
504
+
505
+ ### Verification
506
+
507
+ After the overlay is emitted, run a quick check:
508
+
509
+ ```bash
510
+ grep -c 'id="brand-type-roles"' output/brochure.html output/web/page.html
511
+ # both must return 1 if the brand pack declared roles, 0 otherwise.
512
+ ```
513
+
514
+ A branded run whose DESIGN.md declares `## Type roles` but whose rendered HTML lacks `id="brand-type-roles"` is **shipped broken** — the overlay step was skipped or failed silently.
515
+
@@ -834,6 +834,7 @@
834
834
  .key-facts .grid { grid-template-columns: 1fr; }
835
835
  .subnav .left .price { display: none; }
836
836
  }
837
+ /* BRAND_TYPE_ROLES_OVERRIDE_SLOT — branded register may inject role overrides here */
837
838
  </style>
838
839
  </head>
839
840
  <body>
@@ -13,17 +13,74 @@ The skill template ships with a default super-premium visual scheme. Two registe
13
13
 
14
14
  | Register | What you change |
15
15
  |---|---|
16
- | **Premium** (template scheme retained) | Keep all super-premium tokens (palette, type, layout) verbatim. Swap **only** the agent's logo (back-page masthead + TOC credit) and the agent's contact details. Make subtle consistency adjustments e.g. the accent dot in "Brand · Town" can pick up the brand's primary colour if doing so doesn't disrupt the ivory/ink/bronze relationship. **Do not apply the brand's palette or type tokens.** |
17
- | **Branded** (full DESIGN.md re-skin) | Swap font names at the `--serif` / `--body-serif` / `--sans` custom properties; replace palette tokens (`--paper-25`, `--ink`, `--gold-500`, `--gold-700`, `--gold-300`, `--rule` etc.) with the DESIGN doc's values; adjust copy register to match the brand's voice; substitute the brand's logo PNGs at the back-page masthead and TOC credit. The chapter structure (sixteen pages, Roman-numeral chapters, italic headlines, editorial decks, intermissions, gallery, site plan) stays. |
16
+ | **Branded** (default full DESIGN.md re-skin) | Swap font names at the `--serif` / `--body-serif` / `--sans` custom properties; replace palette tokens (`--paper-25`, `--ink`, `--gold-500`, `--gold-700`, `--gold-300`, `--rule` etc.) with the DESIGN doc's values; **apply role-level type overrides from the DESIGN doc's `## Type roles` block** (see *Type-role overrides* below); substitute the brand's logo PNGs at the back-page masthead and TOC credit. The chapter structure (sixteen pages, Roman-numeral chapters, intermissions, gallery, site plan) stays *layout, image slots, page geometry and folio chrome positions remain identical, only the styling of named type roles changes*. |
17
+ | **Premium** (opt-in only template scheme retained) | Keep all super-premium tokens (palette, type, layout) verbatim. Swap **only** the agent's logo (back-page masthead + TOC credit) and the agent's contact details. Make subtle consistency adjustments — e.g. the accent dot in "Brand · Town" can pick up the brand's primary colour if doing so doesn't disrupt the ivory/ink/bronze relationship. **Do not apply the brand's palette or type tokens.** |
18
18
 
19
19
  ## Selecting the register — decide in this order
20
20
 
21
- 1. **The user said "premium" or "super-premium"** in the request → **Premium**, regardless of whether a DESIGN.md exists.
22
- 2. **A DESIGN.md is provided** and the user did *not* say "premium" **Branded**.
23
- 3. **Neither a DESIGN.md nor the word "premium"** → **Premium** (the template ships with super-premium defaults already in place).
21
+ 1. **The user explicitly said "premium" or "super-premium"** in the request → **Premium**, regardless of whether a DESIGN.md exists.
22
+ 2. **Otherwise → Branded.** If no DESIGN.md exists for the brand, **run `brand-design` first** to produce one — do not fall back to Premium just because the brand pack is missing. Premium is opt-in; "missing brand pack" is a setup step, not a register decision.
24
23
 
25
24
  The signal is the user's wording, not the property. Don't infer premium from a high guide price or a country-house name when the user has handed over a DESIGN.md without qualifying language — the agent may want their brand on every brochure regardless of property tier. Conversely, when the user says "premium" but the property is plainly mid-market (a £450k semi, a city flat), surface the mismatch in one line ("Going premium register — confirm you want the country-folio look on this listing?") before generating, so the user can correct the wording rather than discover the mismatch in the rendered output.
26
25
 
26
+ ## Type-role overrides
27
+
28
+ The branded register's font-family and palette swap alone produces a folio painted in the brand's colours but still wearing the template's editorial conventions — italic display, small-caps decks, gold hairlines under every heading, dropcap, italic emphasis on `<em>`. For brands whose live identity does not use those conventions (Cooper Adams' upright TrajanPro, eXp UK's Montserrat, Knight-Frank-adjacent vs corporate-residential), that's a mismatch. The brochure reads as a country-house folio with brand paint, not as a brand document.
29
+
30
+ To close the gap without leaving branded territory, the brand pack's `DESIGN.md` may declare a `## Type roles` block. Each row maps a named brochure type-role to a brand styling decision. The build pipeline reads these and emits a single override CSS block — see `build.md → Type-role overlay generation` for the recipe map.
31
+
32
+ The role API is **fixed and bounded**. Only the roles below may be overridden; the build does not invent selectors. Anything else stays at template defaults.
33
+
34
+ | Role | Targets (CSS selectors) | Allowed values |
35
+ |---|---|---|
36
+ | `display-headline-style` | `.cover-title`, `.opener-title`, `.ed-headline`, `.chapter-head h3`, `.feature-page h3`, `.gallery-head h3`, `.siteplan-page h3`, `.toc-title`, `.back-headline`, `.mi-head h2`, `.mi-head h3` | `italic` (default) / `normal` |
37
+ | `display-em-style` | `<em>` inside the headline selectors above | `italic` (default) / `weight-shift` / `normal` |
38
+ | `display-letterspacing` | `.cover-title`, `.opener-title` | `wide` (default, ≈0.18em — tuned for Cormorant/didone) / `medium` (≈0.10em — humanist sans) / `tight` (≈0.04em — geometric sans) |
39
+ | `deck-family` | `.ed-deck`, `.feature-deck`, `.gallery-deck`, `.siteplan-deck` | `serif` (default) / `sans` |
40
+ | `deck-case` | same as `deck-family` | `caps-tracked` (default) / `sentence` |
41
+ | `eyebrow-family` | `.ed-eyebrow`, `.toc-eyebrow`, `.chapter-eyebrow`, `.ed-eyebrow-light` | `sans` (default — documented for explicitness) |
42
+ | `subtitle-family` | `.opener-title em`, `.cover-subtitle`, `.opener-spec` | `serif-smallcaps` (default) / `sans-tracked-caps` / `sans` |
43
+ | `caption-family` | `.ed-caption`, `.caption-pill`, `.caption-text`, `.img-caption`, `.photo-caption`, `.intermission .caption-pill` | `serif` (default) / `sans` |
44
+ | `caption-em-style` | `<em>` inside any caption selector | `italic` (default) / `weight-shift` / `normal` |
45
+ | `dropcap` | `.ed-body.dropcap::first-letter` | `gold` (default — italic serif in `--gold-700`) / `ink` (italic serif in `--ink`) / `ink-bold-sans` (bold sans in `--ink`, no italic) / `hidden` (neutralised — first letter inline at body size) |
46
+ | `hairline-rules` | `.ed-rule`, `.toc-rule` | `visible` (default) / `eyebrow-only` (rules beside eyebrows only, not under chapter heads) / `hidden` |
47
+ | `body-em-style` | `.ed-body em`, `.opener-body em`, `.opener-text em`, `.gallery-cell em`, `.siteplan-panel em`, `.spec-list li em`, `.mi-row em`, `.floorplan-lead em`, `.floorplan-sub em`, `.epc-caption em` | `italic` (default) / `weight-shift` / `normal` |
48
+ | `folio-em-style` | `.folio em`, `.folio .folio-mid em` | `italic` (default) / `weight-shift` / `normal` |
49
+ | `backpage-em-style` | `.back-headline em`, `.back-tagline em`, `.back-foot em`, `.back-contact-grid em`, `.back-rule em` | `italic-gold` (default) / `weight-shift` / `normal` |
50
+
51
+ ### What the role API permits
52
+
53
+ Each `value` maps to a deterministic, named CSS recipe (the full map lives in `build.md → Type-role overlay generation`). For example:
54
+
55
+ - `display-em-style: weight-shift` → emits `{ font-style: normal !important; font-weight: 500 !important; }`
56
+ - `deck-family: sans` + `deck-case: sentence` → emits `{ font-family: var(--sans) !important; font-style: normal !important; letter-spacing: 0.02em !important; text-transform: none !important; }`
57
+ - `dropcap: hidden` → emits a `::first-letter` override that neutralises the float, size, color and font-family overrides from the template
58
+
59
+ ### What the role API does NOT permit
60
+
61
+ - Changing page layout, grid structure, image slot positions, page count, folio position
62
+ - Adding new CSS classes or DOM elements
63
+ - Changing colour assignments (those belong to the palette tokens, not the role API)
64
+ - Restyling the back page's QR strip or contact grid layout
65
+ - Restyling the MI three-column grid layout
66
+ - Defining new roles ad hoc — the role list above is the complete API
67
+
68
+ A branded brochure must remain layout-identical to the premium brochure; only the *styling of named type roles* may differ.
69
+
70
+ ### Backward compatibility
71
+
72
+ Existing brand packs whose `DESIGN.md` files do not contain a `## Type roles` block are **unaffected**. Omitted roles inherit template defaults — which match the template's current behaviour exactly. Two agents producing brochures for the same brand on different days produce identical results because the role list is fixed and the recipe per value is fixed.
73
+
74
+
75
+ ### Foundation: always-emit recipes
76
+
77
+ Two sections of the role overlay are emitted **whenever the brand pack declares any `## Type roles`**, regardless of which specific roles the operator chose. They are the foundation of any branded re-skin:
78
+
79
+ 1. **Dark-surface protection.** A fixed exclusion list of selectors that sit on dark surfaces — the TOC hero caption pill, intermission caption pills, the spread-6 chapter-head overlay on its dark photo, the back-page masthead and tagline, and the back-page contact-grid name lines. The brand-token swap remaps `--gold-700` (the template's italic-emphasis colour) to the brand's accent. On a light surface that's correct. On a dark surface, a brand-accent like navy or maroon on dark photographic background is unreadable. The dark-surface protection forces text on these selectors to `var(--paper-25)` and emphasis to `var(--gold-300)` (the brand's accent / highlight). This is **not** a per-implementer concern — the exclusion list is fixed in `build.md → Always-emit recipes`.
80
+ 2. **Structural clamp loosen.** The template's chapter-heading rows in `.spread-2` and `.spread-3` are sized for the Cormorant Garamond display setup (italic, weight 400, didone-tuned letter-spacing). When the role overlay changes display family, weight, or letter-spacing, headings can grow a few millimetres beyond what those fixed-height rows accommodate, and the template's `overflow: hidden` clips the descenders. The clamp-loosen recipe converts the affected rows to `minmax(<original>, max-content)` and lifts the heading-row `overflow` from `hidden` to `visible`. A heading that already fits renders identically; a heading that needs a few extra mm no longer clips. If a future template revision adds another fixed-height heading row, the entry is added in `build.md` — not handwritten per property.
81
+
82
+ A branded brochure built with the role overlay must show both blocks in the emitted `<style id="brand-type-roles">`. Their absence is a build-time defect, not a stylistic choice.
83
+
27
84
  ## Forbidden template sources
28
85
 
29
86
  **Do not** start from any of the following, even when they exist in the same brand workspace and look "more on-brand" than the skill template:
@@ -31,9 +88,9 @@ The signal is the user's wording, not the property. Don't infer premium from a h
31
88
  - `<brand>/properties/<other-listing>/output/brochure.html` — a sibling property's rendered brochure
32
89
  - A previous brochure that was hand-edited after the last template release
33
90
  - A "brand brochure" or "marketing PDF" supplied by the agency
34
- - An older version of `template.html` cached anywhere outside `~/.claude/plugins/brochures/skills/property-brochure/references/`
91
+ - An older version of `template.html` cached anywhere outside `~/.claude/plugins/property-brochure/skills/property-brochure/references/`
35
92
 
36
- Sibling brochures contain property-specific copy edits, hand-tuned image positions, ad-hoc CSS overrides, and drift from the canonical layout. Starting from one bakes that drift into the new brochure and silently diverges every property's structure over time. The DESIGN doc supplies design **tokens** (fonts, palette, voice) — it does not authorise reaching for an existing brochure as a starting point.
93
+ Sibling brochures contain property-specific copy edits, hand-tuned image positions, ad-hoc CSS overrides, and drift from the canonical layout. Starting from one bakes that drift into the new brochure and silently diverges every property's structure over time. The DESIGN doc supplies design **tokens** (fonts, palette, voice) and **role declarations** — it does not authorise reaching for an existing brochure as a starting point.
37
94
 
38
95
  If the canonical `references/template.html` looks wrong for the brand, the right action is to **patch the template** (open a discussion with the user about what to change), not to substitute a different starting point for one run.
39
96
 
@@ -44,4 +101,4 @@ If the canonical `references/template.html` looks wrong for the brand, the right
44
101
  - Adding new pages, removing pages, or merging spreads to "look more like" the brand's website
45
102
  - Importing CSS from the brand's website into the brochure to "match the live experience"
46
103
 
47
- If a branded re-skin is genuinely incompatible with the sixteen-page editorial folio (rare — most brands sit happily in this structure with token swaps), surface that to the user before generating anything. Don't unilaterally substitute structure. Any alternative count must still be **even** — see `a4-print-documents` → Even-page count for duplex printing.
104
+ If a branded re-skin is genuinely incompatible with the sixteen-page editorial folio (rare — most brands sit happily in this structure with token swaps plus role overrides), surface that to the user before generating anything. Don't unilaterally substitute structure. Any alternative count must still be **even** — see `a4-print-documents` → Even-page count for duplex printing.
@@ -1360,6 +1360,7 @@
1360
1360
 
1361
1361
  * { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
1362
1362
  }
1363
+ /* BRAND_TYPE_ROLES_OVERRIDE_SLOT — branded register may inject role overrides here */
1363
1364
  </style>
1364
1365
  </head>
1365
1366
  <body>