diff --git a/app/app.js b/app/app.js index f973849..02ed917 100644 --- a/app/app.js +++ b/app/app.js @@ -7,7 +7,7 @@ import { generateBPN, randomSeed } from "../core/bpn-core.js"; import { COLOURS } from "../core/data/colours.js"; import { framesForColour } from "../core/data/frames.js"; -import { renderBrief, renderDossier, renderBoth, briefHTML, dossierHTML } from "../render/bpn-render.js"; +import { renderBrief, renderField, renderDossier, renderBoth, briefHTML, fieldDossierHTML, dossierHTML } from "../render/bpn-render.js"; const LIB_KEY = "bpn-library-v1"; @@ -129,6 +129,7 @@ function draw() { el.output.classList.add("bpn"); if (!current) return; if (view === "player") renderBrief(current, el.output); + else if (view === "field") renderField(current, el.output); else if (view === "gm") renderDossier(current, el.output); else renderBoth(current, el.output); } @@ -219,7 +220,7 @@ function exportHtml() { -
${briefHTML(current)}${dossierHTML(current)}
`; +
${briefHTML(current)}${fieldDossierHTML(current)}${dossierHTML(current)}
`; const blob = new Blob([doc], { type: "text/html" }); const a = document.createElement("a"); a.href = URL.createObjectURL(blob); diff --git a/art/bpn-art.js b/art/bpn-art.js index a5acd36..6cb496c 100644 --- a/art/bpn-art.js +++ b/art/bpn-art.js @@ -331,6 +331,58 @@ export function portraitSVG(bpn, { size = 120 } = {}) { `; } +/* ------------------------------------------------------------------------ */ +/* SITE MAP — a schematic, non-photorealistic sector diagram for the Field */ +/* Dossier. Numbered nodes (one per location) wired along a route over a */ +/* faint grid, with an entry marker. Labels live in the dossier list. */ +/* ------------------------------------------------------------------------ */ + +export function siteMapSVG(bpn, { width = 640, height = 320 } = {}) { + const accent = bpn?.colour?.accent ?? "#dce8f9"; + const seed = bpn?.seed ?? "BPN"; + const uid = String(seed).replace(/[^A-Za-z0-9]/g, "") + "m"; + const rng = rngFor(seed, "map"); + const dark = "#0b1018"; + const sites = Array.isArray(bpn?.sites) && bpn.sites.length ? bpn.sites : [{}, {}, {}]; + const n = sites.length; + const W = width, H = height; + const mx = 70, my = 64; + + // node positions: spread along x, seeded y within the band + const nodes = sites.map((_, i) => ({ + x: mx + (n === 1 ? (W - 2 * mx) / 2 : (i * (W - 2 * mx)) / (n - 1)), + y: my + rfloat(rng, 0, H - 2 * my) + })); + + // route polyline through the nodes + const route = nodes.map((p, i) => `${i ? "L" : "M"}${r2(p.x)} ${r2(p.y)}`).join(" "); + + const nodeMarks = nodes.map((p, i) => ` + + + + ${i + 1} + `).join(""); + + // entry chevron at the first node + const e = nodes[0]; + const entry = `ENTRY`; + + return ` + + + + + + + + SECTOR SCHEMATIC // ${String(bpn?.bpnId ?? "")} + + ${entry} + ${nodeMarks} +`; +} + /* Convenience: data URIs (handy as srcs or journal icons, and required in hosts that sanitise inline such as Foundry journal pages). */ export function crestDataURI(bpn, opts) { @@ -342,3 +394,6 @@ export function bannerDataURI(bpn, opts) { export function portraitDataURI(bpn, opts) { return `data:image/svg+xml;utf8,${encodeURIComponent(portraitSVG(bpn, opts))}`; } +export function siteMapDataURI(bpn, opts) { + return `data:image/svg+xml;utf8,${encodeURIComponent(siteMapSVG(bpn, opts))}`; +} diff --git a/core/bpn-core.js b/core/bpn-core.js index a72b17a..9c2ad02 100644 --- a/core/bpn-core.js +++ b/core/bpn-core.js @@ -11,6 +11,7 @@ import { COLOURS, COLOUR_ROLL_TABLE, colourByKey } from "./data/colours.js"; import { TABLES, COLOUR_MISSION_IDEAS } from "./data/tables.js"; import { framesForColour } from "./data/frames.js"; +import { DETAIL } from "./data/detail.js"; /* ---- seeded randomness -------------------------------------------------- */ @@ -248,6 +249,66 @@ function buildMissionBrief(b) { ].join(" "); } +/* ---- expanded detail: cast / OTEM / sites ------------------------------- */ + +/** Pick n distinct entries from arr (deterministic via rng). */ +function sample(rng, arr, n) { + const pool = [...arr]; + const out = []; + for (let i = 0; i < n && pool.length; i++) { + out.push(pool.splice(Math.floor(rng() * pool.length), 1)[0]); + } + return out; +} + +function ucFirst(s = "") { + const v = String(s); + return v.charAt(0).toUpperCase() + v.slice(1); +} + +/** Build the cast, OTEM and locations for a briefing. Each entry carries a + * player-facing `player` line and a GM-only `gm` secret. */ +function buildDetail(rng, ctx) { + const cast = []; + // The on-site contact and the reported threat are always part of the cast. + cast.push({ + name: ctx.contactName, + role: ctx.contactRole, + player: `On-site contact, holding at ${lcFirst(trimEnd(ctx.contactLocation))}.`, + gm: pick(rng, DETAIL.npcSecrets) + }); + cast.push({ + name: ctx.threatLead || "Reported hostile", + role: "Designated threat", + player: ctx.threatCount || "Hostile lead flagged by command.", + gm: ctx.oppositionAttitude ? `Really: ${lcFirst(ctx.oppositionAttitude)}` : pick(rng, DETAIL.npcSecrets) + }); + // Plus two generated supporting NPCs. + for (let i = 0; i < 2; i++) { + const role = pick(rng, DETAIL.npcRoles); + cast.push({ + name: `${pick(rng, TABLES.contactFirstNames)} ${pick(rng, TABLES.contactLastNames)}`, + role, + player: `${pick(rng, DETAIL.npcDispositions)} ${role.toLowerCase()}. ${pick(rng, DETAIL.npcPlayerNotes)}`, + gm: pick(rng, DETAIL.npcSecrets) + }); + } + + const otem = sample(rng, DETAIL.otemNames, 3).map((name) => ({ + name, + player: pick(rng, DETAIL.otemPlayerNotes), + gm: pick(rng, DETAIL.otemSecrets) + })); + + const sites = sample(rng, DETAIL.siteNames, 3).map((name) => ({ + name: ucFirst(name), + player: pick(rng, DETAIL.sitePlayerNotes), + gm: pick(rng, DETAIL.siteSecrets) + })); + + return { cast, otem, sites }; +} + /* ---- SCL level ---------------------------------------------------------- */ /** Operative Security Clearance Level: 10 (lowest) … 1 (highest). */ @@ -384,5 +445,20 @@ export function generateBPN(opts = {}) { }; bpn.missionBrief = take(ov.missionBrief, buildMissionBrief(bpn)); + + // Expanded detail for the separate player Field Dossier (player lines) and + // the GM Dossier (secrets). Built last so it can read the resolved cast. + const detail = buildDetail(rng, { + contactName: bpn.contact.name, + contactRole: bpn.contact.role, + contactLocation: bpn.contact.location, + threatLead: bpn.gm.threatLead, + threatCount: bpn.gm.threatCountLabel, + oppositionAttitude: bpn.gm.oppositionAttitude + }); + bpn.cast = detail.cast; + bpn.otem = detail.otem; + bpn.sites = detail.sites; + return bpn; } diff --git a/core/data/colours.js b/core/data/colours.js index b4ee944..9bb16ca 100644 --- a/core/data/colours.js +++ b/core/data/colours.js @@ -1,6 +1,9 @@ /* SLA Industries BPN colour bands — the spine of the whole system. Pure data, no engine or DOM dependencies. Each band carries its - classification, pay/SCL/media profile, and a render accent. */ + classification, pay/SCL/media profile, and a render accent. + + CBS pay ranges scale so the reward slider reaches up to ~2000c on standard + emergency work (Red), with Black/Platinum running higher. */ export const COLOURS = [ { @@ -11,8 +14,8 @@ export const COLOURS = [ details: "Sewer clearances, vermin extermination, infrastructure support, and low-end evictions.", sclReq: "10 - 9", sclRange: { min: 0.05, typical: 0.1, max: 0.2 }, - cbsPerOpRange: { min: 50, max: 150 }, - cbsPerSquadRange: { min: 100, max: 300 }, + cbsPerOpRange: { min: 50, max: 300 }, + cbsPerSquadRange: { min: 100, max: 600 }, mediaCoverage: "Rare / None", accent: "#57b3ff" }, @@ -24,8 +27,8 @@ export const COLOURS = [ details: "Primary objective is bring it back, often issued by Housing, Cloak, or Karma.", sclReq: "10 - 9", sclRange: { min: 0.05, typical: 0.1, max: 0.2 }, - cbsPerOpRange: { min: 100, max: 200 }, - cbsPerSquadRange: { min: 200, max: 400 }, + cbsPerOpRange: { min: 100, max: 400 }, + cbsPerSquadRange: { min: 200, max: 900 }, mediaCoverage: "Station Analysis / None", accent: "#ffdb6e" }, @@ -37,8 +40,8 @@ export const COLOURS = [ details: "Kill or capture designated threats before they escalate into full emergency response.", sclReq: "10 - 8", sclRange: { min: 0.1, typical: 0.1, max: 0.3 }, - cbsPerOpRange: { min: 100, max: 250 }, - cbsPerSquadRange: { min: 200, max: 500 }, + cbsPerOpRange: { min: 150, max: 500 }, + cbsPerSquadRange: { min: 300, max: 1100 }, mediaCoverage: "Station Analysis / Third Eye", accent: "#78d48f" }, @@ -50,8 +53,8 @@ export const COLOURS = [ details: "Evidence-led cases with witness work, profiling, and pattern analysis.", sclReq: "10 - 7", sclRange: { min: 0.1, typical: 0.1, max: 0.3 }, - cbsPerOpRange: { min: 150, max: 300 }, - cbsPerSquadRange: { min: 250, max: 500 }, + cbsPerOpRange: { min: 200, max: 600 }, + cbsPerSquadRange: { min: 400, max: 1300 }, mediaCoverage: "Third Eye / Station Analysis", accent: "#dce8f9" }, @@ -63,8 +66,8 @@ export const COLOURS = [ details: "Escort, asset protection, audit support, and departmentally awkward clean-ups.", sclReq: "9 - 7", sclRange: { min: 0.1, typical: 0.2, max: 0.3 }, - cbsPerOpRange: { min: 200, max: 400 }, - cbsPerSquadRange: { min: 300, max: 600 }, + cbsPerOpRange: { min: 250, max: 700 }, + cbsPerSquadRange: { min: 500, max: 1500 }, mediaCoverage: "Station Analysis", accent: "#9da6b3" }, @@ -76,8 +79,8 @@ export const COLOURS = [ details: "VIP escort, public appearances, negotiations, and camera-facing operations.", sclReq: "9 - 6", sclRange: { min: 0.1, typical: 0.2, max: 0.4 }, - cbsPerOpRange: { min: 200, max: 500 }, - cbsPerSquadRange: { min: 300, max: 800 }, + cbsPerOpRange: { min: 300, max: 850 }, + cbsPerSquadRange: { min: 600, max: 1700 }, mediaCoverage: "Third Eye / GoreZone", accent: "#d7dee8" }, @@ -89,8 +92,8 @@ export const COLOURS = [ details: "Biohazard incidents, toxic leaks, mutations, and containment failures.", sclReq: "9 - 6", sclRange: { min: 0.1, typical: 0.2, max: 0.4 }, - cbsPerOpRange: { min: 250, max: 500 }, - cbsPerSquadRange: { min: 400, max: 800 }, + cbsPerOpRange: { min: 350, max: 900 }, + cbsPerSquadRange: { min: 700, max: 1800 }, mediaCoverage: "Third Eye / Contract Circuit", accent: "#41d0a3" }, @@ -102,8 +105,8 @@ export const COLOURS = [ details: "Riots, terror incidents, major failures, and high-risk live-response deployments.", sclReq: "10 - 5", sclRange: { min: 0.1, typical: 0.2, max: 0.5 }, - cbsPerOpRange: { min: 200, max: 500 }, - cbsPerSquadRange: { min: 300, max: 1000 }, + cbsPerOpRange: { min: 400, max: 1000 }, + cbsPerSquadRange: { min: 800, max: 2000 }, mediaCoverage: "Third Eye / GoreZone", accent: "#ff6f6f" }, @@ -115,8 +118,8 @@ export const COLOURS = [ details: "Assassination, sabotage, deep infiltration, and disavowed clean-up actions.", sclReq: "7 - 4", sclRange: { min: 0.2, typical: 0.4, max: 0.7 }, - cbsPerOpRange: { min: 500, max: 1000 }, - cbsPerSquadRange: { min: 800, max: 2000 }, + cbsPerOpRange: { min: 600, max: 1500 }, + cbsPerSquadRange: { min: 1200, max: 3000 }, mediaCoverage: "None (classified)", accent: "#9a7cff" }, @@ -129,7 +132,7 @@ export const COLOURS = [ sclReq: "5 - 1", sclRange: { min: 0.3, typical: 0.5, max: 1.0 }, cbsPerOpRange: { min: 1000, max: null }, - cbsPerSquadRange: { min: 1500, max: null }, + cbsPerSquadRange: { min: 2000, max: null }, mediaCoverage: "Classified / Special", accent: "#f5f0d7" } diff --git a/core/data/detail.js b/core/data/detail.js new file mode 100644 index 0000000..bba8a93 --- /dev/null +++ b/core/data/detail.js @@ -0,0 +1,90 @@ +/* Vocabulary for the expanded briefing detail — cast (NPCs), OTEM (items & + equipment), and locations. Pure data; bpn-core assembles entries from these + with the briefing seed, so every briefing gets coherent, varied detail with + a player-facing line and a GM-only secret. + + "OTEM" is used here for mission-relevant items, gear, and objects of note. */ + +export const DETAIL = { + /* ---- cast / NPCs ---- */ + npcRoles: [ + "Sector fixer", "Nervous witness", "Off-duty Shiver", "Street medic", + "Corporate liaison", "Information broker", "Frightened bystander", + "Local enforcer", "Black-market runner", "Maintenance technician", + "Embedded journalist", "Karma field tech", "Departmental clerk", + "Sponsor's handler", "Squatter elder", "Transit controller" + ], + npcDispositions: [ + "Cooperative", "Wary", "Openly hostile", "Opportunistic", "Terrified", + "Loyal to the issuing department", "Bitter and uncooperative", + "Eager to please the cameras", "Quietly desperate" + ], + npcPlayerNotes: [ + "Can point the squad to the right door if handled well.", + "Keeps demanding to know who is paying for the damage.", + "Has a name and a face the public already half-recognises.", + "Will talk, but only away from the crowd.", + "Carries a comms slate they keep glancing at.", + "Insists they 'didn't see anything' a little too quickly.", + "Offers help in exchange for a favour later.", + "Is clearly waiting for someone who has not arrived." + ], + npcSecrets: [ + "Is quietly feeding movement reports to the true employer.", + "Knows where the real objective is and is sitting on it.", + "Will flip to the squad's side if offered a clean way out.", + "Is marked for removal by the opposition and does not know it.", + "Has already sold the squad's arrival time to a third party.", + "Is an undercover asset of a department that is not on the BPN.", + "Plans to loot the site the moment the shooting starts.", + "Is the actual target the GM is steering the mission toward." + ], + + /* ---- OTEM (items & equipment) ---- */ + otemNames: [ + "Sealed data slate", "Sample canister", "Concealed weapon cache", + "Forged clearance chip", "Departmental evidence crate", "Surveillance bug cluster", + "Prototype component", "Encrypted comms relay", "Med-kit with restricted stims", + "Sponsor-branded camera drone", "Maintenance master-key", "Blood-stained ID card" + ], + otemPlayerNotes: [ + "Logged on the BPN manifest; command wants it back intact.", + "Spotted on the contact's last report, location uncertain.", + "Rumoured to be on-site but not officially confirmed.", + "Will read as contraband if a Shiver scans the squad.", + "Worth a quiet bonus to the right buyer.", + "Fragile — rough handling will ruin its value." + ], + otemSecrets: [ + "Is still broadcasting a locator only the GM's faction can hear.", + "Is a decoy; the real article left the site hours ago.", + "Is booby-trapped to wipe or detonate if opened wrong.", + "Implicates the issuing department if it ever reaches Head Office.", + "Is worth far more than the BPN pay and everyone on-site knows it.", + "Matches evidence from a case SLA officially closed." + ], + + /* ---- locations / sites ---- */ + siteNames: [ + "the loading dock", "a sub-level service gallery", "the relay/control room", + "a collapsed access stair", "the upper catwalks", "a sealed storage bay", + "the perimeter checkpoint", "a flooded maintenance shaft", "the lobby atrium", + "a back-alley cut-through", "the rooftop plant deck", "an evidence locker" + ], + sitePlayerNotes: [ + "Marked as the recommended entry point.", + "Command flags it as the likely chokepoint.", + "Civilian traffic runs straight through it.", + "Reported as the contact's fallback position.", + "Surveillance here is patchy at best.", + "Last place the objective was logged." + ], + siteSecrets: [ + "Has a second exit the opposition is using to flank.", + "Is wired to seal on a silent alarm and trap whoever is inside.", + "Hides the true objective behind a maintenance panel.", + "Is where the escalation event will first break out.", + "Conceals a body the issuing department wants nobody to find.", + "Is overlooked on the official map command handed the squad." + ] +}; diff --git a/index.html b/index.html index ee8885c..261dce9 100644 --- a/index.html +++ b/index.html @@ -90,8 +90,9 @@
+ - +
diff --git a/render/bpn-render.css b/render/bpn-render.css index a1e7724..a65553a 100644 --- a/render/bpn-render.css +++ b/render/bpn-render.css @@ -282,6 +282,74 @@ } .bpn-footer .bpn-value { font-family: var(--bpn-head); font-size: 0.96rem; } +/* ===================== PLAYER FIELD DOSSIER ===================== */ + +.bpn-field__head { + padding: 18px 22px; + border-bottom: 1px solid var(--bpn-line); + background: + linear-gradient(90deg, color-mix(in srgb, var(--bpn-accent) 18%, transparent), transparent 55%), + linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel)); +} +.bpn-field__title { + font-family: var(--bpn-display); + font-weight: 700; + font-size: clamp(1.7rem, 4vw, 2.4rem); + line-height: 0.96; + letter-spacing: 0.02em; + text-transform: uppercase; + margin: 10px 0 4px; + color: #fff; +} +.bpn-field__sub { margin: 0; color: var(--bpn-muted); font-size: 0.98rem; } + +.bpn-detail-list { display: grid; gap: 10px; } +.bpn-detail-item { + position: relative; + padding: 12px 14px 12px 16px; + border-radius: 10px; + background: linear-gradient(180deg, var(--bpn-panel-2), var(--bpn-panel)); + border: 1px solid var(--bpn-line); +} +.bpn-detail-item::before { + content: ""; + position: absolute; inset: 0 auto 0 0; width: 3px; + border-radius: 10px 0 0 10px; + background: linear-gradient(180deg, var(--bpn-accent), transparent); + opacity: 0.85; +} +.bpn-detail-item__name { + font-family: var(--bpn-head); + font-weight: 700; + font-size: 1.06rem; + color: color-mix(in srgb, var(--bpn-accent) 24%, var(--bpn-text)); + display: flex; + align-items: center; + gap: 8px; +} +.bpn-detail-item__role { + font-family: var(--bpn-mono); + font-size: 0.72rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--bpn-muted); + margin: 2px 0 5px; +} +.bpn-detail-item__line { color: var(--bpn-text); font-size: 1rem; line-height: 1.5; } +.bpn-site-no { + flex: 0 0 auto; + width: 22px; height: 22px; + display: inline-grid; place-items: center; + border-radius: 50%; + background: color-mix(in srgb, var(--bpn-accent) 24%, #0c1119); + border: 1px solid color-mix(in srgb, var(--bpn-accent) 55%, transparent); + font-family: var(--bpn-mono); + font-size: 0.8rem; + font-weight: 700; +} +.bpn-sitemap { margin-bottom: 12px; } +.bpn-sitemap svg, .bpn-sitemap__img { width: 100%; height: auto; display: block; border-radius: 10px; } + /* ===================== GM DOSSIER ===================== */ .bpn-dossier .bpn-doc { box-shadow: 0 24px 60px rgba(0, 0, 0, 0.55); } @@ -356,6 +424,24 @@ border-radius: 4px; } +.bpn-gmnotes { display: grid; gap: 8px; } +.bpn-gmnote { + display: grid; + grid-template-columns: minmax(0, 38%) 1fr; + gap: 12px; + padding: 9px 12px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid var(--bpn-line); +} +.bpn-gmnote__k { + font-family: var(--bpn-mono); + font-size: 0.78rem; + letter-spacing: 0.04em; + color: color-mix(in srgb, var(--bpn-accent) 30%, var(--bpn-text)); +} +.bpn-gmnote__v { color: var(--bpn-text); font-size: 0.96rem; } + .bpn-rolls { font-family: var(--bpn-mono); font-size: 0.78rem; @@ -363,6 +449,10 @@ letter-spacing: 0.04em; } +@media (max-width: 720px) { + .bpn-gmnote { grid-template-columns: 1fr; gap: 3px; } +} + /* ---------- responsive: small running laptop ----------------------------- */ @media (max-width: 720px) { diff --git a/render/bpn-render.js b/render/bpn-render.js index c115afc..532c74a 100644 --- a/render/bpn-render.js +++ b/render/bpn-render.js @@ -12,8 +12,8 @@ */ import { - crestSVG, bannerSVG, portraitSVG, - crestDataURI, bannerDataURI, portraitDataURI + crestSVG, bannerSVG, portraitSVG, siteMapSVG, + crestDataURI, bannerDataURI, portraitDataURI, siteMapDataURI } from "../art/bpn-art.js"; function esc(value) { @@ -120,6 +120,65 @@ export function briefHTML(bpn, { art = true, artMode = "inline" } = {}) { `; } +/* ---------------- PLAYER FIELD DOSSIER ---------------- */ +/* A separate player-facing document: the cast, OTEM and locations the squad + are cleared to know, with a schematic site map. The GM-only secrets for the + same entries live in the GM Dossier. */ + +function castRow(n) { + return `
+
${esc(n.name)}
+
${esc(n.role)}
+
${esc(n.player)}
+
`; +} + +function otemRow(o) { + return `
+
${esc(o.name)}
+
${esc(o.player)}
+
`; +} + +function siteRow(s, i) { + return `
+
${i + 1}${esc(s.name)}
+
${esc(s.player)}
+
`; +} + +export function fieldDossierHTML(bpn, { artMode = "inline" } = {}) { + const c = bpn.colour; + const map = artMode === "img" + ? `sector schematic` + : siteMapSVG(bpn, { width: 640, height: 320 }); + + return `
+
+
+ ${esc(c.label)} Field Dossier +

${esc(bpn.shortTitle)} — Operative Pack

+

Player reference: who you'll meet, what's in play, and where. Cleared for squad eyes.

+
+
+
+

Cast on the Ground

+
${bpn.cast.map(castRow).join("")}
+
+
+

OTEM · Items & Equipment

+
${bpn.otem.map(otemRow).join("")}
+
+
+

Key Locations

+
${map}
+
${bpn.sites.map(siteRow).join("")}
+
+
+
+
`; +} + /* ---------------- GM DOSSIER ---------------- */ export function dossierHTML(bpn, { artMode = "inline" } = {}) { @@ -183,6 +242,15 @@ export function dossierHTML(bpn, { artMode = "inline" } = {}) { +
+

Cast · OTEM · Sites — GM Notes

+
+ ${bpn.cast.map((n) => `
${esc(n.name)} — ${esc(n.role)}${esc(n.gm)}
`).join("")} + ${bpn.otem.map((o) => `
${esc(o.name)}${esc(o.gm)}
`).join("")} + ${bpn.sites.map((s, i) => `
${i + 1}. ${esc(s.name)}${esc(s.gm)}
`).join("")} +
+
+ ${seamRow}

Seed ${esc(bpn.seed)} · frame ${esc(g.frameId)} · rolls: colour ${esc(r.colour ?? 0)}, pay ${esc(r.paymentType ?? 0)}, CBS ${esc(r.cbsTier ?? 0)}, SCL ${esc(r.sclTier ?? 0)}, media ${esc(r.media ?? 0)}, financier ${esc(r.financier ?? 0)}.

@@ -207,10 +275,17 @@ export function renderDossier(bpn, el) { root.innerHTML = dossierHTML(bpn); } -/* Render both surfaces into one element (used for the print / "both" view). */ +export function renderField(bpn, el, opts) { + const root = typeof el === "string" ? document.querySelector(el) : el; + if (!root) return; + root.classList.add("bpn"); + root.innerHTML = fieldDossierHTML(bpn, opts); +} + +/* Render all three surfaces into one element (the print / "all" view). */ export function renderBoth(bpn, el, opts) { const root = typeof el === "string" ? document.querySelector(el) : el; if (!root) return; root.classList.add("bpn"); - root.innerHTML = briefHTML(bpn, opts) + dossierHTML(bpn); + root.innerHTML = briefHTML(bpn, opts) + fieldDossierHTML(bpn, opts) + dossierHTML(bpn, opts); }