Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .eamos-core/orchestrator/os/deck-ir/_schema.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ generator: eamos-render # provenance of the artifact
deliverable: <type, e.g. presentation> # from the manifest's deliverables[] (RFC-0002 §4)
format: <e.g. speaker | detailed> # presentation render mode
length: <short | medium | long>
theme: <os/themes/<name>.yaml> # resolved theme NAME (#64); emitters load the tokens
archetype: <archetype id>
altitude: <audience altitude>
output_lang: <ISO code; rendered prose language>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml (#64)
emitter: emit_svg
vocab:
node_kind: [app, erp, plm, cad, sso, ad, data_store, external]
Expand Down
1 change: 1 addition & 0 deletions .eamos-core/orchestrator/os/deliverables/mindmap.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml (#64)
intent: { kind: free_text }
emitter: emit_svg
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.yaml (#64)
intent: { kind: free_text } # shapes form/emphasis, never facts (RFC-0002 §7)
emitter: emit_pptx
13 changes: 13 additions & 0 deletions .eamos-core/orchestrator/os/themes/professional.yaml
Original file line number Diff line number Diff line change
@@ -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"
10 changes: 10 additions & 0 deletions .eamos-core/orchestrator/os/themes/scientific.yaml
Original file line number Diff line number Diff line change
@@ -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"
7 changes: 4 additions & 3 deletions .eamos-core/tools/emit_docx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)
Expand Down
25 changes: 14 additions & 11 deletions .eamos-core/tools/emit_pptx.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand All @@ -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", [])
Expand Down
24 changes: 14 additions & 10 deletions .eamos-core/tools/emit_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -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/<name>.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):
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down
15 changes: 12 additions & 3 deletions .eamos-core/tools/render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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


Expand Down
5 changes: 5 additions & 0 deletions .eamos-core/tools/tests/test_gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
21 changes: 21 additions & 0 deletions .eamos-core/tools/tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
42 changes: 42 additions & 0 deletions .eamos-core/tools/themes.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env python3
"""Theme-token loader (#64) — corporate identity as data.

os/themes/<name>.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))
Loading