@geraldmaron/construct 1.3.2 → 1.4.1

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.
Files changed (51) hide show
  1. package/README.md +2 -1
  2. package/bin/construct +61 -15
  3. package/lib/a11y-audit.mjs +104 -0
  4. package/lib/artifact-completion-states.mjs +35 -0
  5. package/lib/artifact-completion.mjs +66 -0
  6. package/lib/artifact-gate-levels.mjs +69 -0
  7. package/lib/artifact-manifest.mjs +21 -0
  8. package/lib/artifact-release-gate.mjs +19 -1
  9. package/lib/artifact-workflow.mjs +16 -0
  10. package/lib/brand-contrast.mjs +60 -0
  11. package/lib/brand-tokens.mjs +14 -0
  12. package/lib/cli-commands.mjs +5 -3
  13. package/lib/codex-config.mjs +7 -1
  14. package/lib/deck-export-pptx.mjs +223 -18
  15. package/lib/diagram-quality.mjs +161 -0
  16. package/lib/document-export.mjs +1 -1
  17. package/lib/env-config.mjs +8 -8
  18. package/lib/export-validate.mjs +106 -0
  19. package/lib/gates-audit.mjs +34 -0
  20. package/lib/health-check.mjs +23 -9
  21. package/lib/hooks/guard-bash.mjs +3 -1
  22. package/lib/hooks/session-start.mjs +2 -1
  23. package/lib/init-unified.mjs +5 -3
  24. package/lib/mcp/server.mjs +53 -3
  25. package/lib/mcp/tool-recovery.mjs +56 -0
  26. package/lib/mcp/tools/web-search.mjs +127 -0
  27. package/lib/mcp-manager.mjs +11 -4
  28. package/lib/mcp-platform-config.mjs +18 -1
  29. package/lib/models/provider-poll.mjs +32 -6
  30. package/lib/orchestration/readiness.mjs +183 -0
  31. package/lib/orchestration-policy.mjs +36 -0
  32. package/lib/output-quality.mjs +90 -0
  33. package/lib/pixel-regression.mjs +172 -0
  34. package/lib/providers/op-run.mjs +6 -5
  35. package/lib/providers/secret-audit-wiring.mjs +32 -0
  36. package/lib/providers/secret-resolver.mjs +90 -20
  37. package/lib/publish.mjs +64 -1
  38. package/lib/render-evidence.mjs +55 -0
  39. package/lib/render-pipeline.mjs +136 -0
  40. package/lib/service-manager.mjs +31 -10
  41. package/lib/templates/doc-presentation.mjs +24 -0
  42. package/lib/tracking-surfaces.mjs +36 -0
  43. package/lib/visual-review.mjs +70 -0
  44. package/package.json +1 -1
  45. package/scripts/sync-specialists.mjs +107 -11
  46. package/specialists/artifact-manifest.json +18 -0
  47. package/specialists/artifact-manifest.schema.json +46 -2
  48. package/templates/distribution/construct-brand.typ +1 -2
  49. package/templates/distribution/construct-deck.html +34 -34
  50. package/templates/distribution/construct-pdf.typ +1 -1
  51. package/templates/distribution/construct-web.html +8 -10
@@ -0,0 +1,70 @@
1
+ /**
2
+ * lib/visual-review.mjs — Rubric registry and the visually-reviewed evidence contract.
3
+ *
4
+ * A visual review is recordable only from a real captured screenshot, a named rubric, and a
5
+ * reviewer verdict — never inferred from source text. recordVisualReview throws unless it is handed
6
+ * screenshot-captured evidence with no degradation, so the visually-reviewed rung cannot be forged
7
+ * without a rendered image. The verdict rides in the proof and gates the later approved rung; the
8
+ * review itself (model or human judgment) is supplied by the caller, not fabricated here.
9
+ */
10
+ import { makeEvidence } from './artifact-completion.mjs';
11
+
12
+ export const VERDICTS = Object.freeze(['pass', 'needs-changes', 'fail']);
13
+
14
+ export const RUBRICS = Object.freeze({
15
+ 'document-v1': {
16
+ id: 'document-v1',
17
+ applies: ['pdf', 'docx', 'html'],
18
+ criteria: ['spacing and visual rhythm', 'visual hierarchy and scan-ability', 'reading order sanity'],
19
+ },
20
+ 'deck-v1': {
21
+ id: 'deck-v1',
22
+ applies: ['pptx', 'deck'],
23
+ criteria: ['slide density', 'font legibility at slide distance', 'no clipped or overflowing content'],
24
+ },
25
+ 'diagram-v1': {
26
+ id: 'diagram-v1',
27
+ applies: ['mermaid', 'd2'],
28
+ criteria: ['purpose is clear', 'labels are readable', 'density is manageable', 'non-happy path shown where relevant'],
29
+ },
30
+ });
31
+
32
+ export function makeVisualReview({ rubricId, image, verdict, reviewer, notes = '' } = {}) {
33
+ if (!RUBRICS[rubricId]) {
34
+ throw new Error(`unknown rubric: ${rubricId} (expected one of ${Object.keys(RUBRICS).join(', ')})`);
35
+ }
36
+ if (typeof image !== 'string' || image.length === 0) {
37
+ throw new Error('visual review requires a rendered image path');
38
+ }
39
+ if (!VERDICTS.includes(verdict)) {
40
+ throw new Error(`unknown verdict: ${verdict} (expected one of ${VERDICTS.join(', ')})`);
41
+ }
42
+ if (typeof reviewer !== 'string' || reviewer.length === 0) {
43
+ throw new Error('visual review requires a reviewer');
44
+ }
45
+ return Object.freeze({ rubricId, image, verdict, reviewer, notes });
46
+ }
47
+
48
+ // The review must trace to a real captured screenshot; handing in anything else (a degraded
49
+ // render, or no evidence at all) throws, so visually-reviewed is unreachable from source alone.
50
+
51
+ export function recordVisualReview({ screenshotEvidence, review, actor = 'construct-review' } = {}) {
52
+ if (!screenshotEvidence
53
+ || screenshotEvidence.state !== 'screenshot-captured'
54
+ || screenshotEvidence.degradation) {
55
+ throw new Error('visual review requires non-degraded screenshot-captured evidence (no source inference)');
56
+ }
57
+ if (!review || !RUBRICS[review.rubricId] || !VERDICTS.includes(review.verdict)) {
58
+ throw new Error('visual review requires a valid review report');
59
+ }
60
+ return makeEvidence('visually-reviewed', {
61
+ actor,
62
+ artifact: screenshotEvidence.artifact,
63
+ proof: {
64
+ rubricId: review.rubricId,
65
+ image: review.image,
66
+ verdict: review.verdict,
67
+ reviewer: review.reviewer,
68
+ },
69
+ });
70
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geraldmaron/construct",
3
- "version": "1.3.2",
3
+ "version": "1.4.1",
4
4
  "type": "module",
5
5
  "packageManager": "npm@11.5.1",
6
6
  "description": "Construct — agent orchestration layer for OpenCode, Claude Code, and other coding surfaces",
@@ -448,6 +448,13 @@ const standardConstructTools = [
448
448
  "cx_trace",
449
449
  "cx_score",
450
450
  ].join(",");
451
+
452
+ // The orchestrator's atomic contract is classify-then-dispatch: orchestration_policy
453
+ // then orchestration_run. On allowlist hosts (Claude) these must be named in the
454
+ // agent's tools list or the call is blocked, leaving the orchestrator unable to route.
455
+ // Single source of truth so every host's orchestrator grant stays in parity.
456
+
457
+ const ORCHESTRATOR_DISPATCH_TOOLS = ["orchestration_policy", "orchestration_run"];
451
458
  const managedStart = `# BEGIN ${systemName.toUpperCase()} AGENTS`;
452
459
  const managedEnd = `# END ${systemName.toUpperCase()} AGENTS`;
453
460
  const mdManagedStart = `<!-- BEGIN ${systemName.toUpperCase()} AGENTS -->`;
@@ -765,7 +772,8 @@ function orchestrationMicroPrompt(platform) {
765
772
  `You are the primary orchestrator. Before any non-trivial answer, call \`${policyTool}\` with the user's \`request\`. Do not guess agent names or workflow types.\n\n` +
766
773
  `Example — the user says "add rate limiting to the API". Your first action is a tool call, not prose:\n` +
767
774
  ` call ${policyTool} { "request": "add rate limiting to the API" }\n` +
768
- `If the route is focused/orchestrated specialist work, call \`${runTool}\` with the same request. If the route suggests a workflow such as \`research-synthesis\`, pass it as \`workflow_type\`. Do not narrate completed research unless \`${runTool}\` or evidence tools actually ran.`
775
+ `If the route is focused/orchestrated specialist work, call \`${runTool}\` with the same request. If the route suggests a workflow such as \`research-synthesis\`, pass it as \`workflow_type\`. Do not narrate completed research unless \`${runTool}\` or evidence tools actually ran.\n\n` +
776
+ `If a request needs a capability you lack — live web/network access, external data, code execution — do not refuse or tell the user to run it themselves. Route it via \`${runTool}\` to the specialist that holds the capability (web access lives with the researcher), or ask one clarifying question when the target is ambiguous.`
769
777
  );
770
778
  }
771
779
 
@@ -984,6 +992,13 @@ function claudeAgentMarkdown(entry, allEntries) {
984
992
  ...baseTools.split(",").map((t) => t.trim()),
985
993
  ...standardConstructTools.split(","),
986
994
  ]);
995
+
996
+ // Claude's tools list is a hard allowlist; the orchestrator can only route if its
997
+ // dispatch tools are named here.
998
+
999
+ if (entry.isOrchestrator) {
1000
+ for (const tool of ORCHESTRATOR_DISPATCH_TOOLS) toolSet.add(tool);
1001
+ }
987
1002
  const tools = Array.from(toolSet).filter(Boolean).join(",");
988
1003
 
989
1004
  return `---
@@ -1346,6 +1361,40 @@ When using this prompt, stay within the role above and adapt to the current repo
1346
1361
  `;
1347
1362
  }
1348
1363
 
1364
+ // VS Code reads custom agents (the renamed successor to chat modes) from
1365
+ // .github/agents/<name>.agent.md, and tool grants must use its namespaced ids:
1366
+ // <server>/* for an MCP server's tools, web/fetch for outbound web, search/read
1367
+ // for repo awareness. The Claude-format tools in .claude/agents/*.md are not
1368
+ // recognized here, so the orchestrator needs its own VS Code agent or it lists
1369
+ // in the picker with no usable tools.
1370
+
1371
+ const COPILOT_AGENT_TOOLS = [
1372
+ "construct-mcp/*",
1373
+ "web/fetch",
1374
+ "web/githubRepo",
1375
+ "search/codebase",
1376
+ "search/usages",
1377
+ "search/fileSearch",
1378
+ "read/problems",
1379
+ "edit/editFiles",
1380
+ ];
1381
+
1382
+ function copilotAgentFile(entry, allEntries) {
1383
+ const name = adapterName(entry);
1384
+ return `---
1385
+ description: ${entry.description}
1386
+ name: ${name}
1387
+ tools: ${JSON.stringify(COPILOT_AGENT_TOOLS)}
1388
+ ---
1389
+
1390
+ ${generatedMarkdownNote}
1391
+
1392
+ # ${name}
1393
+
1394
+ ${buildPrompt(entry, allEntries, "copilot")}
1395
+ `;
1396
+ }
1397
+
1349
1398
  function syncCopilot(entries, targetDir = null, wants = true) {
1350
1399
  const promptsDir = targetDir
1351
1400
  ? path.join(targetDir, ".github", "prompts")
@@ -1362,15 +1411,20 @@ function syncCopilot(entries, targetDir = null, wants = true) {
1362
1411
  sweepLegacyPrefixedFiles(promptsDir, ".prompt.md", writeEntries.map((e) => `${adapterName(e)}.prompt.md`));
1363
1412
  }
1364
1413
 
1365
- // VS Code / Copilot agent mode reads the project's `.claude/agents/*.md`
1366
- // natively (Claude-format custom agents), so Construct does NOT write a
1367
- // separate `.github/agents/` set doing so duplicated every agent in the
1368
- // picker. A pre-existing `.github/agents/` from an earlier sync is swept.
1414
+ // VS Code reads custom agents from `.github/agents/*.agent.md`. The Claude
1415
+ // tool names in `.claude/agents/*.md` are not recognized there, so the front
1416
+ // door ships as a VS Code agent with namespaced tool grants (construct-mcp/*,
1417
+ // web/fetch, search/read) selecting it in the dropdown then scopes those
1418
+ // tools in. The Claude-format set stays for Claude Code.
1369
1419
 
1370
- if (targetDir) {
1371
- const staleAgentsDir = path.join(targetDir, ".github", "agents");
1372
- if (!DRY_RUN && fs.existsSync(staleAgentsDir)) fs.rmSync(staleAgentsDir, { recursive: true, force: true });
1420
+ const agentsDir = targetDir
1421
+ ? path.join(targetDir, ".github", "agents")
1422
+ : path.join(home, ".github", "agents");
1423
+ if (!DRY_RUN && wants) mkdirp(agentsDir);
1424
+ for (const entry of writeEntries) {
1425
+ writeFile(path.join(agentsDir, `${adapterName(entry)}.agent.md`), copilotAgentFile(entry, entries), { stamp: false });
1373
1426
  }
1427
+ if (fs.existsSync(agentsDir)) removeStaleAdapters(agentsDir, ".agent.md", writeEntries);
1374
1428
 
1375
1429
  const instructionsPath = targetDir
1376
1430
  ? path.join(targetDir, ".github", "copilot-instructions.md")
@@ -1387,7 +1441,7 @@ function syncCopilot(entries, targetDir = null, wants = true) {
1387
1441
  const list = listEntries.map((e) => `- \`${adapterName(e)}\`: use \`${promptPathPrefix}/${adapterName(e)}.prompt.md\`.`).join("\n");
1388
1442
  const note = `## ${systemName.charAt(0).toUpperCase() + systemName.slice(1)} Agent Prompts
1389
1443
 
1390
- For a real multi-specialist run, Copilot agent mode calls the \`orchestration_run\` MCP tool (start the engine with \`construct dashboard\`). The prompts below are reusable role profiles for single-pass work:
1444
+ Select \`${systemName}\` from the chat mode dropdown to enter the orchestrator: describe an outcome and it classifies the request and dispatches the right specialists through the \`construct-mcp\` tools (\`orchestration_policy\` then \`orchestration_run\`). You ask for outcomes, not specialists — it routes internally. Requires the \`construct-mcp\` server (wired in \`.vscode/mcp.json\`); if its tools are unavailable, the mode cannot route and will say so rather than guess.
1391
1445
 
1392
1446
  ${list || "(no front-door prompts to surface)"}`;
1393
1447
 
@@ -1432,6 +1486,45 @@ function getVSCodeUserMcpPaths() {
1432
1486
  .filter((file) => fs.existsSync(file));
1433
1487
  }
1434
1488
 
1489
+ // A merged mcp.json preserves existing entries so user customizations survive a
1490
+ // re-sync. A construct-owned server path is a fully-resolved, non-placeholder
1491
+ // path, so the preserve rule keeps it even when it points at a different toolkit
1492
+ // root than the current one — and VS Code then launches a server that may not
1493
+ // exist. Treat a construct toolkit path outside the current root as stale so the
1494
+ // sync refreshes it; user-owned servers carry no lib/mcp toolkit path and stay.
1495
+
1496
+ export function mcpEntryPointsOutsideToolkit(entry, root) {
1497
+ const args = Array.isArray(entry?.args) ? entry.args : [];
1498
+ return args.some(
1499
+ (arg) => typeof arg === "string"
1500
+ && /\/lib\/mcp\/[a-z0-9-]+\.mjs$/.test(arg)
1501
+ && !arg.startsWith(`${root}/`),
1502
+ );
1503
+ }
1504
+
1505
+ // VS Code scans both `.github/agents` and `.claude/agents`, so the orchestrator
1506
+ // would list twice — once with VS Code tools (.github/agents) and once with
1507
+ // Claude tool names VS Code ignores (.claude/agents). `chat.agentFilesLocations`
1508
+ // is the documented lever to pin the scan; its power over the built-in
1509
+ // `.claude/agents` compatibility scan is version-dependent, so this is a
1510
+ // best-effort hint, not a guarantee. Merge only into a strictly-parseable file
1511
+ // and never overwrite an existing choice, so a commented (JSONC) or
1512
+ // user-customized settings.json is left untouched.
1513
+
1514
+ export function pinVscodeAgentLocations(targetDir) {
1515
+ if (DRY_RUN) return;
1516
+ const settingsPath = path.join(targetDir, ".vscode", "settings.json");
1517
+ let settings = {};
1518
+ if (fs.existsSync(settingsPath)) {
1519
+ try { settings = JSON.parse(fs.readFileSync(settingsPath, "utf8")) || {}; }
1520
+ catch { return; }
1521
+ }
1522
+ if (settings["chat.agentFilesLocations"]) return;
1523
+ settings["chat.agentFilesLocations"] = { ".github/agents": true, ".claude/agents": false };
1524
+ mkdirp(path.dirname(settingsPath));
1525
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
1526
+ }
1527
+
1435
1528
  function syncVSCode(targetDir = null, wants = true) {
1436
1529
  const registryMcp = scopedManagedMcpDefs({ projectScope: Boolean(targetDir) });
1437
1530
  if (Object.keys(registryMcp).length === 0) return false;
@@ -1480,13 +1573,15 @@ function syncVSCode(targetDir = null, wants = true) {
1480
1573
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1481
1574
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1482
1575
  const transportMismatch = registryWantsCommand && existingIsRemote;
1483
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1576
+ const staleToolkitPath = mcpEntryPointsOutsideToolkit(existingEntry, root);
1577
+ if (existingEntry && !hasPlaceholder && !transportMismatch && !staleToolkitPath) continue;
1484
1578
  config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
1485
1579
  }
1486
1580
  if (!DRY_RUN) {
1487
1581
  mkdirp(path.dirname(mcpPath));
1488
1582
  fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
1489
1583
  }
1584
+ pinVscodeAgentLocations(targetDir);
1490
1585
  return true;
1491
1586
  }
1492
1587
 
@@ -1504,7 +1599,8 @@ function syncVSCode(targetDir = null, wants = true) {
1504
1599
  const registryWantsCommand = !mcpDef.type && Array.isArray(mcpDef.args);
1505
1600
  const existingIsRemote = existingEntry && (existingEntry.type === 'http' || existingEntry.type === 'remote');
1506
1601
  const transportMismatch = registryWantsCommand && existingIsRemote;
1507
- if (existingEntry && !hasPlaceholder && !transportMismatch) continue;
1602
+ const staleToolkitPath = mcpEntryPointsOutsideToolkit(existingEntry, root);
1603
+ if (existingEntry && !hasPlaceholder && !transportMismatch && !staleToolkitPath) continue;
1508
1604
  config.servers[id] = buildClaudeMcpEntry(id, mcpDef, process.env, { host: "vscode" });
1509
1605
  }
1510
1606
  if (!DRY_RUN) fs.writeFileSync(mcpPath, JSON.stringify(config, null, 2) + "\n");
@@ -9,6 +9,10 @@
9
9
  "outputs": {
10
10
  "formats": ["pdf", "docx", "doc", "deck", "pptx", "html", "rtf", "odt", "epub", "tex", "txt", "md", "mdx"],
11
11
  "branding": "construct"
12
+ },
13
+ "qualityContract": {
14
+ "gateLevel": "standard",
15
+ "requiredStates": ["exported"]
12
16
  }
13
17
  },
14
18
  "artifacts": {
@@ -31,6 +35,13 @@
31
35
  "proseMinimum": 3,
32
36
  "requiredReviewers": ["cx-devil-advocate"],
33
37
  "optionalReviewers": ["cx-ux-researcher"]
38
+ },
39
+ "qualityContract": {
40
+ "gateLevel": "render-smoke",
41
+ "requiredStates": ["exported", "file-valid", "renderable"],
42
+ "perFormat": {
43
+ "pdf": { "requiredStates": ["file-valid", "renderable"], "a11y": "wcag-2.1-aa" }
44
+ }
34
45
  }
35
46
  },
36
47
  "prd-platform": {
@@ -318,6 +329,13 @@
318
329
  "proseMinimum": 2,
319
330
  "requiredReviewers": ["cx-devil-advocate"],
320
331
  "optionalReviewers": []
332
+ },
333
+ "qualityContract": {
334
+ "gateLevel": "render-smoke",
335
+ "requiredStates": ["exported", "file-valid", "renderable"],
336
+ "perFormat": {
337
+ "pdf": { "requiredStates": ["file-valid", "renderable"] }
338
+ }
321
339
  }
322
340
  },
323
341
  "customer-profile": {
@@ -41,7 +41,8 @@
41
41
  "authorChain": { "type": "array", "items": { "type": "string" } },
42
42
  "reviewerChain": { "type": "array", "items": { "type": "string" } },
43
43
  "validation": { "$ref": "#/$defs/validation" },
44
- "outputs": { "$ref": "#/$defs/outputs" }
44
+ "outputs": { "$ref": "#/$defs/outputs" },
45
+ "qualityContract": { "$ref": "#/$defs/qualityContract" }
45
46
  }
46
47
  },
47
48
  "validation": {
@@ -58,6 +59,48 @@
58
59
  "branding": { "type": "string", "enum": ["construct", "plain"] }
59
60
  }
60
61
  },
62
+ "gateLevel": {
63
+ "type": "string",
64
+ "enum": ["fast", "standard", "render-smoke", "full-certification", "human-reviewed"]
65
+ },
66
+ "completionState": {
67
+ "type": "string",
68
+ "enum": [
69
+ "planned",
70
+ "authored",
71
+ "structurally-valid",
72
+ "source-linted",
73
+ "exported",
74
+ "file-valid",
75
+ "renderable",
76
+ "screenshot-captured",
77
+ "visually-reviewed",
78
+ "accessibility-reviewed",
79
+ "approved",
80
+ "completed"
81
+ ]
82
+ },
83
+ "qualityContract": {
84
+ "type": "object",
85
+ "additionalProperties": false,
86
+ "properties": {
87
+ "gateLevel": { "$ref": "#/$defs/gateLevel" },
88
+ "requiredStates": { "type": "array", "items": { "$ref": "#/$defs/completionState" } },
89
+ "perFormat": {
90
+ "type": "object",
91
+ "additionalProperties": {
92
+ "type": "object",
93
+ "additionalProperties": false,
94
+ "properties": {
95
+ "gateLevel": { "$ref": "#/$defs/gateLevel" },
96
+ "requiredStates": { "type": "array", "items": { "$ref": "#/$defs/completionState" } },
97
+ "a11y": { "type": "string" },
98
+ "fontFloorPt": { "type": "number" }
99
+ }
100
+ }
101
+ }
102
+ }
103
+ },
61
104
  "artifactEntry": {
62
105
  "type": "object",
63
106
  "required": ["template", "primaryOwners", "toneDefault", "releaseGate"],
@@ -76,7 +119,8 @@
76
119
  "authorChain": { "type": "array", "items": { "type": "string" } },
77
120
  "reviewerChain": { "type": "array", "items": { "type": "string" } },
78
121
  "validation": { "$ref": "#/$defs/validation" },
79
- "outputs": { "$ref": "#/$defs/outputs" }
122
+ "outputs": { "$ref": "#/$defs/outputs" },
123
+ "qualityContract": { "$ref": "#/$defs/qualityContract" }
80
124
  }
81
125
  }
82
126
  }
@@ -280,8 +280,7 @@
280
280
  #v(0.28em)
281
281
  #set text(font: construct-font-sans, size: fs-micro, fill: ink-faint)
282
282
  #align(center)[
283
- #text(weight: wt-bold, fill: ink, tracking: 0.04em)[Construct]
284
- #text[ #sym.dot.c #footer-label]
283
+ #text(weight: wt-bold, fill: ink, tracking: 0.04em)[#footer-label]
285
284
  #if classification != "" [ #sym.dot.c #upper(classification)]
286
285
  ]
287
286
  ]
@@ -1,15 +1,14 @@
1
1
  <!--
2
- templates/distribution/construct-deck.html Construct branded 16:9 slide deck
3
- HTML for `construct export --to=deck`. Pandoc emits section divs (--section-divs)
4
- from markdown slides separated by ---; Space Grotesk via Google Fonts; monochrome ink
5
- tokens mirror construct-brand.typ. Metadata variables fill the title slide.
2
+ Standalone 16:9 slide-deck HTML export template. Pandoc emits section divs
3
+ (--section-divs) from markdown slides separated by ---; Space Grotesk via Google
4
+ Fonts; monochrome ink tokens. Metadata variables fill the title slide.
6
5
  -->
7
6
  <!DOCTYPE html>
8
7
  <html lang="en">
9
8
  <head>
10
9
  <meta charset="utf-8">
11
10
  <meta name="viewport" content="width=device-width, initial-scale=1">
12
- <title>$if(title)$$title$$else$Construct deck$endif$</title>
11
+ <title>$if(title)$$title$$else$Slides$endif$</title>
13
12
  <link rel="preconnect" href="https://fonts.googleapis.com">
14
13
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
15
14
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -24,57 +23,58 @@
24
23
  }
25
24
  * { box-sizing:border-box; }
26
25
  html { scroll-behavior:smooth; -webkit-text-size-adjust:100%; }
27
- body { margin:0; background:#e8e9ec; color:var(--ink-body); font-family:var(--sans); font-size:22px; line-height:1.45; -webkit-font-smoothing:antialiased; }
26
+ body { margin:0; background:#e8e9ec; color:var(--ink-body); font-family:var(--sans); font-size:17px; line-height:1.5; -webkit-font-smoothing:antialiased; }
28
27
  .deck { display:flex; flex-direction:column; align-items:center; gap:32px; padding:40px 24px 80px; }
29
- section.slide {
28
+ .deck > section {
30
29
  width:min(100%, var(--slide-w));
31
30
  aspect-ratio:16/9;
32
31
  background:var(--paper);
33
32
  border:1px solid var(--hairline);
34
33
  border-radius:8px;
35
34
  box-shadow:0 18px 48px rgba(10,12,16,.08);
36
- padding:56px 64px 48px;
35
+ padding:48px 56px 40px;
37
36
  overflow:hidden;
38
37
  display:flex;
39
38
  flex-direction:column;
40
39
  position:relative;
41
40
  }
42
- section.slide::after {
41
+ .deck > section::after {
43
42
  content:"";
44
43
  position:absolute;
45
44
  left:0; right:0; bottom:0;
46
45
  height:4px;
47
46
  background:linear-gradient(90deg, var(--ink) 46px, var(--hairline) 46px);
48
47
  }
49
- section.slide.title-slide { justify-content:center; }
50
- section.slide.title-slide h1 { font-size:52px; line-height:1.06; letter-spacing:-.024em; margin:0 0 16px; color:var(--ink); font-weight:600; }
51
- section.slide.title-slide .subtitle { font-size:26px; color:var(--muted); margin:0 0 28px; letter-spacing:-.005em; }
52
- section.slide.title-slide .meta { font-size:15px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; font-weight:600; }
53
- section.slide.title-slide .meta .sep { color:var(--faint); font-weight:400; }
54
- section.slide h1 { font-size:40px; line-height:1.08; letter-spacing:-.02em; margin:0 0 20px; color:var(--ink); font-weight:600; }
55
- section.slide h1::after { content:""; display:block; width:36px; height:3px; background:var(--ink); margin-top:14px; }
56
- section.slide h2 { font-size:28px; font-weight:600; color:var(--ink-strong); margin:28px 0 12px; letter-spacing:-.01em; }
57
- section.slide h3 { font-size:22px; font-weight:600; color:var(--ink-strong); margin:20px 0 8px; }
58
- section.slide p { margin:0 0 14px; max-width:52em; }
59
- section.slide ul, section.slide ol { margin:0 0 16px; padding-left:1.2em; }
60
- section.slide li { margin:0 0 10px; }
61
- section.slide li::marker { color:var(--ink); }
62
- section.slide strong { color:var(--ink); font-weight:600; }
63
- section.slide code { font-family:var(--mono); font-size:.84em; background:var(--surface-alt); padding:1px 6px; border-radius:4px; }
64
- section.slide pre { background:var(--surface); border:1px solid var(--hairline); border-radius:8px; padding:16px 18px; overflow:auto; font-size:17px; }
65
- section.slide pre code { background:none; padding:0; }
66
- section.slide blockquote { margin:16px 0; padding:14px 18px; background:var(--surface-alt); border-left:3px solid var(--ink); border-radius:0 4px 4px 0; }
67
- section.slide table { width:100%; border-collapse:collapse; font-size:18px; margin:12px 0; }
68
- section.slide th, section.slide td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--hairline); }
69
- section.slide thead th { font-weight:700; color:var(--ink); border-bottom:2px solid var(--ink); }
70
- section.slide figure { margin:16px 0; text-align:center; }
71
- section.slide figure img { max-width:58%; max-height:42vh; width:auto; height:auto; object-fit:contain; border:1px solid var(--hairline); border-radius:6px; padding:8px; }
48
+ .deck > section.title-slide { justify-content:center; }
49
+ .deck > section.title-slide h1 { font-size:38px; line-height:1.1; letter-spacing:-.022em; margin:0 0 14px; color:var(--ink); font-weight:600; }
50
+ .deck > section.title-slide .subtitle { font-size:19px; color:var(--muted); margin:0 0 24px; letter-spacing:-.005em; }
51
+ .deck > section.title-slide .meta { font-size:13px; color:var(--muted); letter-spacing:.04em; text-transform:uppercase; font-weight:600; }
52
+ .deck > section.title-slide .meta .sep { color:var(--faint); font-weight:400; }
53
+ .deck > section h1 { font-size:28px; line-height:1.12; letter-spacing:-.018em; margin:0 0 16px; color:var(--ink); font-weight:600; }
54
+ .deck > section h1::after { content:""; display:block; width:30px; height:2px; background:var(--ink); margin-top:12px; }
55
+ .deck > section h2 { font-size:20px; font-weight:600; color:var(--ink-strong); margin:22px 0 10px; letter-spacing:-.01em; }
56
+ .deck > section h3 { font-size:16px; font-weight:600; color:var(--ink-strong); margin:16px 0 6px; }
57
+ .deck > section p { margin:0 0 12px; max-width:54em; }
58
+ .deck > section ul, .deck > section ol { margin:0 0 12px; padding-left:1.2em; }
59
+ .deck > section li { margin:0 0 7px; }
60
+ .deck > section li::marker { color:var(--ink); }
61
+ .deck > section strong { color:var(--ink); font-weight:600; }
62
+ .deck > section code { font-family:var(--mono); font-size:.84em; background:var(--surface-alt); padding:1px 6px; border-radius:4px; }
63
+ .deck > section pre { background:var(--surface); border:1px solid var(--hairline); border-radius:8px; padding:14px 16px; overflow:auto; font-size:13.5px; }
64
+ .deck > section pre code { background:none; padding:0; }
65
+ .deck > section blockquote { margin:16px 0; padding:14px 18px; background:var(--surface-alt); border-left:3px solid var(--ink); border-radius:0 4px 4px 0; }
66
+ .deck > section table { width:100%; border-collapse:collapse; font-size:15px; margin:10px 0; }
67
+ .deck > section th, .deck > section td { text-align:left; padding:8px 12px; border-bottom:1px solid var(--hairline); }
68
+ .deck > section thead th { font-weight:700; color:var(--ink); border-bottom:2px solid var(--ink); }
69
+ .deck > section figure { margin:12px 0; text-align:center; flex:1 1 auto; min-height:0; display:flex; flex-direction:column; align-items:center; justify-content:center; }
70
+ .deck > section figure img { max-width:80%; max-height:100%; width:auto; height:auto; object-fit:contain; border:1px solid var(--hairline); border-radius:6px; padding:8px; }
71
+ .deck > section > img { display:block; margin:12px auto; flex:1 1 auto; min-height:0; max-width:84%; max-height:100%; width:auto; height:auto; object-fit:contain; border:1px solid var(--hairline); border-radius:6px; padding:8px; }
72
72
  .slide-foot { margin-top:auto; padding-top:16px; font-size:13px; color:var(--faint); display:flex; justify-content:space-between; align-items:center; }
73
73
  .slide-foot b { color:var(--ink); font-weight:600; }
74
74
  @media print {
75
75
  body { background:var(--paper); }
76
76
  .deck { gap:0; padding:0; }
77
- section.slide { box-shadow:none; border:none; border-radius:0; page-break-after:always; width:100%; height:100vh; }
77
+ .deck > section { box-shadow:none; border:none; border-radius:0; page-break-after:always; width:100%; height:100vh; }
78
78
  }
79
79
  </style>
80
80
  </head>
@@ -86,7 +86,7 @@ $if(artifactType)$<div class="meta">$artifactType$$if(docId)$ <span class="sep">
86
86
  <h1>$title$</h1>
87
87
  $if(subtitle)$<p class="subtitle">$subtitle$</p>$endif$
88
88
  $if(owner)$<p class="meta">$owner$$if(date)$ <span class="sep">&middot;</span> $date$$endif$$if(status)$ <span class="sep">&middot;</span> $status$$endif$</p>$endif$
89
- <div class="slide-foot"><span><b>Construct</b>$if(classification)$ &middot; $classification$$endif$</span></div>
89
+ <div class="slide-foot"><span>$if(classification)$<b>$classification$</b>$endif$$if(date)$$if(classification)$ &middot; $endif$$date$$endif$</span></div>
90
90
  </section>
91
91
  $endif$
92
92
  $body$
@@ -16,7 +16,7 @@
16
16
  version: "$if(version)$$version$$endif$",
17
17
  ),
18
18
  footer: construct-running-footer(
19
- "Construct brief",
19
+ "Brief",
20
20
  classification: "$if(classification)$$classification$$endif$",
21
21
  ),
22
22
  )
@@ -1,18 +1,16 @@
1
1
  <!--
2
- templates/distribution/construct-web.html Construct branded standalone HTML
3
- template for `construct export --to=html`. Monochrome design mirroring the PDF
4
- brand (construct-brand.typ): Space Grotesk webfont, masthead from frontmatter metadata,
5
- bold-header hairline tables, callout blockquotes, framed figures. Pandoc fills
6
- $title$/$subtitle$/$status$/$owner$/$date$/$artifactType$/$version$/$docId$/
7
- $classification$ and $body$; export runs with --embed-resources so diagrams and
8
- fonts inline into a single portable file.
2
+ Standalone branded HTML export template. Monochrome design: Space Grotesk
3
+ webfont, masthead from frontmatter metadata, bold-header hairline tables, callout
4
+ blockquotes, framed figures. Pandoc fills the title, subtitle, status, owner,
5
+ date, artifactType, version, docId, classification, and document body; export
6
+ runs with --embed-resources so diagrams and fonts inline into one portable file.
9
7
  -->
10
8
  <!DOCTYPE html>
11
9
  <html lang="en">
12
10
  <head>
13
11
  <meta charset="utf-8">
14
12
  <meta name="viewport" content="width=device-width, initial-scale=1">
15
- <title>$if(title)$$title$$else$Construct document$endif$</title>
13
+ <title>$if(title)$$title$$else$Document$endif$</title>
16
14
  <link rel="preconnect" href="https://fonts.googleapis.com">
17
15
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
18
16
  <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
@@ -79,14 +77,14 @@
79
77
  $if(title)$
80
78
  <header class="masthead">
81
79
  $if(artifactType)$ <div class="chips">$artifactType$$if(docId)$ <span class="sep">&middot;</span> $docId$$endif$$if(version)$ <span class="sep">&middot;</span> v$version$$endif$$if(classification)$ <span class="sep">&middot;</span> $classification$$endif$</div>
82
- $endif$ <div class="title">$title$</div>
80
+ $endif$ <h1 class="title">$title$</h1>
83
81
  $if(subtitle)$ <p class="subtitle">$subtitle$</p>
84
82
  $endif$ <div class="byline">$if(status)$<span class="pill">$status$</span>$endif$$if(owner)$<span>$owner$</span>$endif$$if(date)$<span class="sep">&middot;</span><span>$date$</span>$endif$</div>
85
83
  </header>
86
84
  <div class="rule"></div>
87
85
  $endif$
88
86
  $body$
89
- <div class="doc-foot"><b>Construct</b>$if(classification)$ &middot; $classification$$endif$</div>
87
+ <div class="doc-foot">$if(classification)$<b>$classification$</b>$endif$$if(date)$$if(classification)$ &middot; $endif$$date$$endif$</div>
90
88
  </main>
91
89
  </body>
92
90
  </html>