diff --git a/.eamos-core/orchestrator/os/deck-ir/_schema.md b/.eamos-core/orchestrator/os/deck-ir/_schema.md index 1dac8f8..417f0d0 100644 --- a/.eamos-core/orchestrator/os/deck-ir/_schema.md +++ b/.eamos-core/orchestrator/os/deck-ir/_schema.md @@ -11,6 +11,7 @@ generator: eamos-render # provenance of the artifact deliverable: # from the manifest's deliverables[] (RFC-0002 §4) format: # presentation render mode length: +theme: .yaml> # resolved theme NAME (#64); emitters load the tokens archetype: altitude: output_lang: diff --git a/.eamos-core/orchestrator/os/deliverables/architecture.yaml b/.eamos-core/orchestrator/os/deliverables/architecture.yaml index 6d3b8cd..5f3b15a 100644 --- a/.eamos-core/orchestrator/os/deliverables/architecture.yaml +++ b/.eamos-core/orchestrator/os/deliverables/architecture.yaml @@ -6,6 +6,7 @@ type: architecture ir_family: topology-IR params: layout: { enum: [layered], default: layered } # one canonical layout in v1 (RFC-0004 §8) + theme: { enum: [professional, scientific], default: professional } # os/themes/.yaml (#64) emitter: emit_svg vocab: node_kind: [app, erp, plm, cad, sso, ad, data_store, external] diff --git a/.eamos-core/orchestrator/os/deliverables/mindmap.yaml b/.eamos-core/orchestrator/os/deliverables/mindmap.yaml index 9a6fa39..9a6058d 100644 --- a/.eamos-core/orchestrator/os/deliverables/mindmap.yaml +++ b/.eamos-core/orchestrator/os/deliverables/mindmap.yaml @@ -3,5 +3,6 @@ type: mindmap ir_family: graph-IR params: orientation: { enum: [horizontal, vertical], default: horizontal } + theme: { enum: [professional, scientific], default: professional } # os/themes/.yaml (#64) intent: { kind: free_text } emitter: emit_svg diff --git a/.eamos-core/orchestrator/os/deliverables/presentation.yaml b/.eamos-core/orchestrator/os/deliverables/presentation.yaml index f5e3252..d711783 100644 --- a/.eamos-core/orchestrator/os/deliverables/presentation.yaml +++ b/.eamos-core/orchestrator/os/deliverables/presentation.yaml @@ -4,5 +4,6 @@ ir_family: slide-IR params: format: { enum: [detailed, speaker], default: speaker } # detailed = read-alone; speaker = key points length: { enum: [short, medium, long], default: medium } + theme: { enum: [professional, scientific], default: professional } # os/themes/.yaml (#64) intent: { kind: free_text } # shapes form/emphasis, never facts (RFC-0002 §7) emitter: emit_pptx diff --git a/.eamos-core/orchestrator/os/themes/professional.yaml b/.eamos-core/orchestrator/os/themes/professional.yaml new file mode 100644 index 0000000..e0dbe7e --- /dev/null +++ b/.eamos-core/orchestrator/os/themes/professional.yaml @@ -0,0 +1,13 @@ +# Theme tokens (#64) — corporate identity as DATA, one file per theme. The default theme: its +# tokens are byte-for-byte the constants the emitters used to hardcode, so default output is +# unchanged. Add a corporate theme by copying this file — never by editing an emitter. +# NOTE: the amber "to verify" color is NOT a token — it is the grounding signal (RFC-0001 §6), +# reserved in code (tools/themes.py); a theme that camouflages assumptions defeats the safety model. +theme: professional +font: Calibri +colors: + accent: "1E2761" # titles / headings / emphasis + body: "2B2B2B" # body text + muted: "707070" # captions, connectors + card: "EEF1F7" # card / box fill + background: "FFFFFF" diff --git a/.eamos-core/orchestrator/os/themes/scientific.yaml b/.eamos-core/orchestrator/os/themes/scientific.yaml new file mode 100644 index 0000000..4ccdad6 --- /dev/null +++ b/.eamos-core/orchestrator/os/themes/scientific.yaml @@ -0,0 +1,10 @@ +# Theme tokens (#64): the scientific/technical palette (deep petrol accents), previously hardcoded +# in emit_svg.THEMES. The amber grounding signal is reserved in code and identical across themes. +theme: scientific +font: Calibri +colors: + accent: "0B3D5C" + body: "22303A" + muted: "5A6B73" + card: "EAF1F5" + background: "FFFFFF" diff --git a/.eamos-core/tools/emit_docx.py b/.eamos-core/tools/emit_docx.py index 7a94c22..091d4a6 100644 --- a/.eamos-core/tools/emit_docx.py +++ b/.eamos-core/tools/emit_docx.py @@ -18,11 +18,11 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +import themes # theme tokens as data (#64) SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) -AMBER = (0xB8, 0x6B, 0x00) -MUTED = (0x70, 0x70, 0x70) +AMBER = themes.rgb(themes.AMBER) # reserved: the grounding signal (RFC-0001 §6) is not themable def _lab(lang, key): @@ -44,12 +44,13 @@ def build_docx(ir, out_path): from docx.shared import Pt lang = ir.get("output_lang", "en") + muted = themes.rgb(themes.color(themes.load(ir.get("theme")), "muted")) # theme tokens (#64) doc = Document() doc.add_heading(ir.get("objective", ""), level=0) cap = doc.add_paragraph() _run(cap, f"{_lab(lang, 'preread')} · {ir.get('archetype', '')} · {ir.get('altitude', '')} · " - f"{ir.get('deliverable', '')}/{ir.get('format', '')} · {lang}", color=MUTED) + f"{ir.get('deliverable', '')}/{ir.get('format', '')} · {lang}", color=muted) for slide in ir.get("slides", []): doc.add_heading(slide.get("title", slide.get("id", "")), level=1) diff --git a/.eamos-core/tools/emit_pptx.py b/.eamos-core/tools/emit_pptx.py index f2eadea..2fcdc76 100644 --- a/.eamos-core/tools/emit_pptx.py +++ b/.eamos-core/tools/emit_pptx.py @@ -18,13 +18,11 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +import themes # theme tokens as data (#64) SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) -NAVY = (0x1E, 0x27, 0x61) # title -BODY = (0x2B, 0x2B, 0x2B) # body text -AMBER = (0xB8, 0x6B, 0x00) # assumed / to-verify -MUTED = (0x70, 0x70, 0x70) # captions +AMBER = themes.rgb(themes.AMBER) # reserved: the grounding signal (RFC-0001 §6) is not themable def _lab(lang, key): @@ -60,6 +58,11 @@ def build_pptx(ir, out_path): from pptx.enum.text import PP_ALIGN lang = ir.get("output_lang", "en") + tokens = themes.load(ir.get("theme")) # theme tokens as data (#64) + accent = themes.rgb(themes.color(tokens, "accent")) + body_color = themes.rgb(themes.color(tokens, "body")) + muted = themes.rgb(themes.color(tokens, "muted")) + font = tokens.get("font") or "Calibri" prs = Presentation() prs.slide_width = Inches(13.333) prs.slide_height = Inches(7.5) @@ -78,7 +81,7 @@ def para(tf, text, size, color, bold=False, bullet=False, first=False): run.text = ("• " + text) if bullet else text run.font.size = Pt(size) run.font.bold = bold - run.font.name = "Calibri" + run.font.name = font # Assumed/to-verify values carry the ⟨…⟩ marker from the IR — flag them in amber. run.font.color.rgb = RGBColor(*(AMBER if "⟨" in text else color)) p.space_after = Pt(6) @@ -87,27 +90,27 @@ def para(tf, text, size, color, bold=False, bullet=False, first=False): # Title slide. s0 = prs.slides.add_slide(blank) t = textbox(s0, 0.7, 2.4, 12.0, 2.4) - para(t, ir.get("objective", ""), 40, NAVY, bold=True, first=True) + para(t, ir.get("objective", ""), 40, accent, bold=True, first=True) cap = textbox(s0, 0.7, 4.7, 12.0, 0.6) para(cap, f"{ir.get('archetype', '')} · {ir.get('altitude', '')} · " - f"{ir.get('deliverable', '')}/{ir.get('format', '')} · {lang}", 16, MUTED, first=True) + f"{ir.get('deliverable', '')}/{ir.get('format', '')} · {lang}", 16, muted, first=True) # Content slides. for slide in ir.get("slides", []): sl = prs.slides.add_slide(blank) head = textbox(sl, 0.7, 0.5, 12.0, 1.0) - para(head, slide.get("title", slide.get("id", "")), 32, NAVY, bold=True, first=True) + para(head, slide.get("title", slide.get("id", "")), 32, accent, bold=True, first=True) body = textbox(sl, 0.7, 1.7, 12.0, 5.3) for i, b in enumerate(slide.get("blocks", [])): line = _line(b, lang) if not line: continue if b.get("type") == "lead": - para(body, line, 18, BODY, bold=True, first=(i == 0)) + para(body, line, 18, body_color, bold=True, first=(i == 0)) elif b.get("type") == "kpi_row": - para(body, line, 16, BODY, bullet=True, first=(i == 0)) + para(body, line, 16, body_color, bullet=True, first=(i == 0)) else: - para(body, line, 16, BODY, bullet=True, first=(i == 0)) + para(body, line, 16, body_color, bullet=True, first=(i == 0)) # Review appendix slide. appendix = ir.get("review_appendix", []) diff --git a/.eamos-core/tools/emit_svg.py b/.eamos-core/tools/emit_svg.py index 0711c9c..f9357e4 100644 --- a/.eamos-core/tools/emit_svg.py +++ b/.eamos-core/tools/emit_svg.py @@ -20,15 +20,19 @@ import _cli # utf8_stdio (#54) import labels # chrome labels as data (#61) +import themes # theme tokens as data (#64) SUPPORTED_IR_VERSION = 1 # the IR contract this emitter was written for (#62) -# Per visual_style palette (background, accent, body, amber, muted, card). Unknown styles fall back -# to professional. Layout is shared; only the palette/theme changes (RFC-0002 §11-1). -THEMES = { - "professional": ("#FFFFFF", "#1E2761", "#2B2B2B", "#B86B00", "#707070", "#EEF1F7"), - "scientific": ("#FFFFFF", "#0B3D5C", "#22303A", "#B8860B", "#5A6B73", "#EAF1F5"), -} + +def _palette(name): + """(bg, accent, body, amber, muted, card) from the theme tokens (os/themes/.yaml, #64). + An unknown name falls back to the default theme; layout is shared, only the palette changes + (RFC-0002 §11-1). Amber is reserved — the grounding signal is identical across themes.""" + t = themes.load(name) + return (f"#{themes.color(t, 'background') or 'FFFFFF'}", f"#{themes.color(t, 'accent') or '1E2761'}", + f"#{themes.color(t, 'body') or '2B2B2B'}", f"#{themes.AMBER}", + f"#{themes.color(t, 'muted') or '707070'}", f"#{themes.color(t, 'card') or 'EEF1F7'}") def _lab(lang, key): @@ -64,7 +68,7 @@ def _text(x, y, lines, size, fill, weight="normal", line_h=None): def build_svg(ir): lang = ir.get("output_lang", "en") - bg, accent, body, amber, muted, card = THEMES.get(ir.get("visual_style"), THEMES["professional"]) + bg, accent, body, amber, muted, card = _palette(ir.get("visual_style")) orient = ir.get("orientation", "portrait") W, H = {"landscape": (1160, 820), "square": (940, 940)}.get(orient, (820, 1160)) cols = 3 if orient == "landscape" else 2 @@ -146,7 +150,7 @@ def _box(parts, x, y, w, text, fill, txt, size=14, bold=False): def _mindmap_horizontal(ir): - bg, accent, body, amber, muted, card = THEMES["professional"] + bg, accent, body, amber, muted, card = _palette(ir.get("theme")) branches = ir.get("branches", []) row_h = 46 rows = sum(max(1, len(b.get("leaves", []))) for b in branches) or 1 @@ -184,7 +188,7 @@ def link(x1, y1, x2, y2): def _mindmap_vertical(ir): """The vertical layout (#63): root on top, one column per branch, leaves stacked beneath. Deterministic like its horizontal twin — a pure function of the branch order.""" - bg, accent, body, amber, muted, card = THEMES["professional"] + bg, accent, body, amber, muted, card = _palette(ir.get("theme")) branches = ir.get("branches", []) col_w, box_w, row_h, m = 320, 300, 30, 30 n = max(1, len(branches)) @@ -220,7 +224,7 @@ def build_topology_svg(ir): """A deterministic 'layered' system-topology diagram from a topology-IR (RFC-0004): typed nodes placed in a grid by DECLARATION ORDER (a pure function of the order — no force-directed, no randomness, RFC-0002 §11-1) + directed, pattern-labeled edges. Assumed nodes/edges are amber.""" - bg, accent, body, amber, muted, card = THEMES["professional"] + bg, accent, body, amber, muted, card = _palette(ir.get("theme")) lang = ir.get("output_lang", "en") nodes, edges = ir.get("nodes", []) or [], ir.get("edges", []) or [] cols, nbw, nbh, gx, gy, m, top = 3, 240, 66, 90, 76, 50, 96 diff --git a/.eamos-core/tools/render.py b/.eamos-core/tools/render.py index 286a77d..676bc37 100644 --- a/.eamos-core/tools/render.py +++ b/.eamos-core/tools/render.py @@ -364,12 +364,17 @@ def build_deck_ir(manifest, archetype, altitude=None, function=None): for k in sorted(acc["assumed"]) ] + # Theme tokens as data (#64): the IR carries the resolved theme NAME; emitters load the tokens. + theme = _resolve_params(load_deliverable("presentation"), + _find_deliverable(manifest, "presentation")).get("theme") or "professional" + deck_ir = { "ir_version": IR_VERSION, "generator": GENERATOR, "deliverable": deliverable.get("type", "presentation"), "format": deliverable.get("format", "speaker"), "length": deliverable.get("length", "medium"), + "theme": theme, "archetype": archetype.get("archetype", ""), "altitude": altitude, "output_lang": lang, @@ -538,16 +543,17 @@ def build_graph_ir(manifest, archetype, altitude=None, function=None): leaves = [t for t in (_flatten_block(b) for b in slide["blocks"]) if t][:3] branches.append({"label": slide["title"], "leaves": leaves}) - # The orientation param is live (#63): stamped into the IR, the SVG emitter lays out per value. + # The orientation param is live (#63); theme name rides along (#64) — the emitter loads tokens. params = _resolve_params(load_deliverable("mindmap"), _find_deliverable(manifest, "mindmap")) orientation = params.get("orientation") or "horizontal" + theme = params.get("theme") or "professional" review_appendix = [ {"binding": k, "value": "" if ledger[k].get("value") is None else str(ledger[k].get("value")), "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] return {"ir_version": IR_VERSION, "generator": GENERATOR, - "deliverable": "mindmap", "orientation": orientation, + "deliverable": "mindmap", "orientation": orientation, "theme": theme, "output_lang": lang, "root": manifest.get("objective", ""), "branches": branches, "review_appendix": review_appendix}, acc @@ -635,8 +641,11 @@ def build_topology_ir(manifest, archetype=None, altitude=None, function=None): "assumption": ledger[k].get("assumption", ""), "fill_from": ledger[k].get("fill_from", "")} for k in sorted(acc["assumed"]) ] + theme = _resolve_params(load_deliverable("architecture"), + _find_deliverable(manifest, "architecture")).get("theme") or "professional" return {"ir_version": IR_VERSION, "generator": GENERATOR, - "deliverable": "architecture", "output_lang": lang, "title": manifest.get("objective", ""), + "deliverable": "architecture", "theme": theme, + "output_lang": lang, "title": manifest.get("objective", ""), "nodes": nodes, "edges": edges, "review_appendix": review_appendix}, acc diff --git a/.eamos-core/tools/tests/test_gates.py b/.eamos-core/tools/tests/test_gates.py index c05e613..a32384c 100644 --- a/.eamos-core/tools/tests/test_gates.py +++ b/.eamos-core/tools/tests/test_gates.py @@ -92,6 +92,11 @@ def test_param_out_of_bounds(self): _deliverable(m, "infographic")["visual_style"] = "bricks" # board allows only professional/scientific self.assertIn("deliverable-params-in-bounds", run_gates(m)) + def test_unknown_theme_rejected(self): + m = load("qbr-c-level") + _deliverable(m, "presentation")["theme"] = "neon" # not a shipped theme file (#64) + self.assertIn("deliverable-params", run_gates(m)) + def test_undeclared_param(self): m = load("qbr-c-level") _deliverable(m, "infographic")["color"] = "blue" # not a declared registry param diff --git a/.eamos-core/tools/tests/test_render.py b/.eamos-core/tools/tests/test_render.py index 9c5a04f..21a8e62 100644 --- a/.eamos-core/tools/tests/test_render.py +++ b/.eamos-core/tools/tests/test_render.py @@ -168,6 +168,27 @@ def test_quiz_cap_is_stable_under_manifest_reordering(self): quiz, _ = render.build_quiz_ir(m, arch) self.assertEqual([q["q"] for q in quiz["questions"] if q["kind"] == "graded"], a) + def test_theme_tokens_restyle_the_svg(self): + import emit_svg + m, arch = load("qbr-c-level") + mind, _ = render.build_graph_ir(m, arch) + self.assertEqual(mind["theme"], "professional") # default = the old constants (#64) + prof = emit_svg.build_mindmap_svg(mind) + next(d for d in m["deliverables"] if d["type"] == "mindmap")["theme"] = "scientific" + mind_s, _ = render.build_graph_ir(m, arch) + sci = emit_svg.build_mindmap_svg(mind_s) + self.assertNotEqual(prof, sci) # one YAML file restyles the deliverable + self.assertIn("#0B3D5C", sci) # scientific accent from the tokens + self.assertIn("#B86B00", sci) # amber grounding signal is reserved + + def test_deck_ir_stamps_theme(self): + m, arch = load("qbr-c-level") + deck, _ = render.build_deck_ir(m, arch) + self.assertEqual(deck["theme"], "professional") + next(d for d in m["deliverables"] if d["type"] == "presentation")["theme"] = "scientific" + deck_s, _ = render.build_deck_ir(m, arch) + self.assertEqual(deck_s["theme"], "scientific") + def test_mindmap_orientation_changes_layout(self): import emit_svg m, arch = load("qbr-c-level") diff --git a/.eamos-core/tools/themes.py b/.eamos-core/tools/themes.py new file mode 100644 index 0000000..87d9a20 --- /dev/null +++ b/.eamos-core/tools/themes.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Theme-token loader (#64) — corporate identity as data. + +os/themes/.yaml holds the design tokens (font + palette) a deliverable renders with; the +IR carries the resolved theme name, so the emitters stay IR-driven and an enterprise consumer +restyles every deliverable by adding one YAML file — never by editing an emitter. + +The amber "to verify" color is deliberately NOT a token: it is the grounding signal (RFC-0001 §6), +reserved here as a constant — a theme that camouflages assumptions defeats the safety model. +""" + +import os + +TOOLS = os.path.dirname(os.path.abspath(__file__)) +THEMES_DIR = os.path.join(os.path.dirname(TOOLS), "orchestrator", "os", "themes") +DEFAULT = "professional" +AMBER = "B86B00" # reserved: the grounding signal is identical across themes + +_cache = {} + + +def load(name): + """The token table for theme `name` (cached); an unknown/absent name falls back to the default.""" + name = name or DEFAULT + if name not in _cache: + path = os.path.join(THEMES_DIR, f"{name}.yaml") + if not os.path.exists(path): + path = os.path.join(THEMES_DIR, f"{DEFAULT}.yaml") + import yamlmini + with open(path, encoding="utf-8") as fh: + _cache[name] = yamlmini.load_yaml(fh.read()) + return _cache[name] + + +def color(theme, key): + """One palette token as a hex string (no '#').""" + return (theme.get("colors") or {}).get(key, "") + + +def rgb(hexstr): + """'1E2761' -> (30, 39, 97) for the binary emitters.""" + return tuple(int(hexstr[i:i + 2], 16) for i in (0, 2, 4))