diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4ef8e50..037a184 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,6 +27,9 @@ jobs: - name: Set up Python run: uv python install ${{ matrix.python-version }} + - name: Create virtual environment + run: uv venv --python ${{ matrix.python-version }} + - name: Install dependencies run: uv pip install -e ".[dev,images]" diff --git a/README.md b/README.md index c43eca8..7d7a9c1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Presentation decks written as JSON, driven from your phone, and built into a single HTML file that works when the Wi-Fi doesn't. ```bash -uv tool install glissade # or: pipx install glissade +uv tool install --from git+https://github.com/techmuch/glissade glissade mkdir my-talk && cd my-talk glissade init # scaffold a deck and an AI-agent guide @@ -16,18 +16,26 @@ anywhere, and they document the whole system. ## Installing +Glissade is not on PyPI yet, so install it from GitHub for now. + | | | | --- | --- | -| **uv** (recommended) | `uv tool install glissade` — installs Python too if you don't have it | -| **pipx** | `pipx install glissade` | -| **pip** | `pip install glissade` | -| **Try without installing** | `uvx glissade demo` | +| **uv** (recommended) | `uv tool install --from git+https://github.com/techmuch/glissade glissade` — installs Python too if you don't have it | +| **pipx** | `pipx install git+https://github.com/techmuch/glissade.git` | +| **pip** | `pip install git+https://github.com/techmuch/glissade.git` | +| **Try without installing** | `uvx --from git+https://github.com/techmuch/glissade glissade demo` | One universal wheel covers Windows, macOS and Linux — every dependency is pure Python, so installation never needs a compiler. Python 3.10 or newer. -Add `glissade[images]` to pull in Pillow, which downscales oversized images -before embedding them. Without it images embed at full size; nothing breaks. +If you want Pillow for image downscaling, install the `images` extra from Git +instead, for example: + +```bash +pip install 'git+https://github.com/techmuch/glissade.git#egg=glissade[images]' +``` + +Without Pillow, images embed at full size; nothing breaks. ## Commands @@ -270,12 +278,12 @@ Pick a `layout` and fill the fields it uses. Nothing is required. Layouts: `title`, `title-content`, `section`, `title-only`, `two-content`, `comparison`, `content-caption`, `picture-caption`, `media-right`, -`media-left`, `media-full`, `media-caption`, `grid`, `blank`. +`media-left`, `media-full`, `media-caption`, `grid`, `quad-chart`, `blank`. `cls` adds modifiers independent of layout: `"ask"` (dark, for questions), `"story"`, `"center"`. -Run `glissade demo --deck gallery` to see all fourteen. +Run `glissade demo --deck gallery` to see all fifteen. ## Media @@ -333,10 +341,40 @@ presentation and an apology. ## Developing +Use `uv` plus the cross-platform task script: + ```bash git clone https://github.com/techmuch/glissade && cd glissade -uv venv && uv pip install -e ".[images]" -glissade demo +uv run python scripts/dev.py install +uv run python scripts/dev.py run +``` + +That installs Python 3.12 if needed, creates `.venv`, and installs the editable +project with the `dev` and `images` extras. Override the Python version if you +need to: + +```bash +uv run python scripts/dev.py install --python 3.11 +``` + +Common tasks: + +```bash +uv run python scripts/dev.py install # bootstrap .venv +uv run python scripts/dev.py test # run the full test suite +uv run python scripts/dev.py test -- tests/test_check.py -q +uv run python scripts/dev.py run # defaults to: glissade demo +uv run python scripts/dev.py run -- start +uv run python scripts/dev.py build # build dist/ packages +``` + +If you prefer the raw `uv` commands: + +```bash +uv python install 3.12 +uv venv .venv --python 3.12 +uv pip install --python .venv/bin/python -e ".[dev,images]" # Windows: .venv\Scripts\python.exe +uv run --python .venv/bin/python pytest -q # Windows: .venv\Scripts\python.exe ``` `src/glissade/` is the package; `templates/` holds the deck and remote HTML; @@ -344,5 +382,5 @@ glissade demo shipped inside the wheel so the tool works from any directory. ```bash -python -m build # sdist + universal wheel into dist/ +uv build # sdist + universal wheel into dist/ ``` diff --git a/pyproject.toml b/pyproject.toml index 0719e65..403c6d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "glissade" -version = "0.10.0" +version = "0.12.0" description = "Presentation decks written as JSON, driven from your phone, built into a single self-contained HTML file" readme = "README.md" requires-python = ">=3.10" @@ -46,6 +46,7 @@ images = ["pillow>=10.0"] dev = [ "pytest>=8.0", "httpx>=0.27", + "ruff>=0.6.0", ] [tool.pytest.ini_options] diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..86d74c6 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +DEFAULT_PYTHON = "3.12" +DEFAULT_EXTRAS = "dev,images" + + +def uv() -> str: + found = shutil.which("uv") + if not found: + raise SystemExit("uv is required. Install it from https://docs.astral.sh/uv/") + return found + + +def venv_python(venv: Path) -> Path: + if os.name == "nt": + return venv / "Scripts" / "python.exe" + return venv / "bin" / "python" + + +def run(cmd: list[str], *, cwd: Path = ROOT) -> None: + print("+", " ".join(cmd)) + subprocess.run(cmd, cwd=cwd, check=True) + + +def require_venv(venv: Path) -> Path: + python = venv_python(venv) + if not python.exists(): + raise SystemExit( + f"{python} not found. Run `uv run python scripts/dev.py install` first." + ) + return python + + +def cmd_install(args: argparse.Namespace) -> None: + venv = ROOT / args.venv + run([uv(), "python", "install", args.python]) + run([uv(), "venv", str(venv), "--python", args.python]) + run([ + uv(), + "pip", + "install", + "--python", + str(venv_python(venv)), + "-e", + f".[{args.extras}]", + ]) + + +def passthrough(argv: list[str]) -> list[str]: + return argv[1:] if argv and argv[0] == "--" else argv + + +def cmd_test(args: argparse.Namespace) -> None: + python = require_venv(ROOT / args.venv) + extra = passthrough(args.args) + cmd = [str(python), "-m", "pytest"] + if not extra: + cmd.append("-q") + cmd.extend(extra) + run(cmd) + + +def cmd_run(args: argparse.Namespace) -> None: + python = require_venv(ROOT / args.venv) + glissade_args = passthrough(args.args) or ["demo"] + run([str(python), "-m", "glissade", *glissade_args]) + + +def cmd_build(args: argparse.Namespace) -> None: + run([uv(), "build", *passthrough(args.args)]) + + +def parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + description="Cross-platform developer tasks for Glissade." + ) + sub = p.add_subparsers(dest="command", required=True) + + install = sub.add_parser( + "install", + help="Install Python if needed, create .venv, and install dev dependencies.", + ) + install.add_argument( + "--python", + default=DEFAULT_PYTHON, + help=f"Python version for the local venv (default: {DEFAULT_PYTHON})", + ) + install.add_argument( + "--venv", + default=".venv", + help="Virtualenv directory (default: .venv)", + ) + install.add_argument( + "--extras", + default=DEFAULT_EXTRAS, + help=f"Extras to install from the project (default: {DEFAULT_EXTRAS})", + ) + install.set_defaults(func=cmd_install) + + test = sub.add_parser("test", help="Run pytest inside the local .venv.") + test.add_argument( + "--venv", + default=".venv", + help="Virtualenv directory (default: .venv)", + ) + test.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed through to pytest", + ) + test.set_defaults(func=cmd_test) + + run_p = sub.add_parser( + "run", + help="Run Glissade inside the local .venv. Defaults to `glissade demo`.", + ) + run_p.add_argument( + "--venv", + default=".venv", + help="Virtualenv directory (default: .venv)", + ) + run_p.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed through to `python -m glissade`", + ) + run_p.set_defaults(func=cmd_run) + + build = sub.add_parser( + "build", + help="Build distributable packages into dist/ using `uv build`.", + ) + build.add_argument( + "args", + nargs=argparse.REMAINDER, + help="Arguments passed through to `uv build`", + ) + build.set_defaults(func=cmd_build) + + return p + + +def main() -> int: + args = parser().parse_args() + args.func(args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/glissade/__init__.py b/src/glissade/__init__.py index 59b140c..3bb923f 100644 --- a/src/glissade/__init__.py +++ b/src/glissade/__init__.py @@ -13,7 +13,7 @@ glissade check validate decks before you rely on them """ -__version__ = "0.10.0" +__version__ = "0.12.0" from .project import ( # noqa: F401 DATA_DIR, diff --git a/src/glissade/assets.py b/src/glissade/assets.py index 4036fa6..49a034c 100644 --- a/src/glissade/assets.py +++ b/src/glissade/assets.py @@ -226,6 +226,13 @@ def prepare_slides( if isinstance(block.get("media"), dict): _inline_media(block["media"], base, warnings) + for quad in slide.get("quads") or []: + if isinstance(quad, dict): + if isinstance(quad.get("image"), dict): + _inline_image(quad["image"], base, warnings) + if isinstance(quad.get("media"), dict): + _inline_media(quad["media"], base, warnings) + if isinstance(slide.get("media"), dict): _inline_media(slide["media"], base, warnings) @@ -241,14 +248,24 @@ def external_media(slides: list[dict[str, Any]]) -> list[tuple[Any, str]]: found = [] for position, slide in enumerate(slides, start=1): slide.setdefault("n", position) - for node in ( + left = slide.get("left") if isinstance(slide.get("left"), dict) else {} + right = slide.get("right") if isinstance(slide.get("right"), dict) else {} + nodes = [ slide.get("media"), - (slide.get("left") or {}).get("media") if isinstance(slide.get("left"), dict) else None, - (slide.get("right") or {}).get("media") if isinstance(slide.get("right"), dict) else None, - ): + left.get("media"), + right.get("media"), + ] + for quad in slide.get("quads") or []: + if isinstance(quad, dict): + nodes.append(quad.get("media")) + for node in nodes: if isinstance(node, dict) and node.get("external") and node.get("src"): found.append((slide.get("n", "?"), node["src"])) - img = slide.get("image") - if isinstance(img, dict) and img.get("remote"): - found.append((slide.get("n", "?"), img.get("src", ""))) + images = [slide.get("image")] + for quad in slide.get("quads") or []: + if isinstance(quad, dict): + images.append(quad.get("image")) + for img in images: + if isinstance(img, dict) and img.get("remote"): + found.append((slide.get("n", "?"), img.get("src", ""))) return found diff --git a/src/glissade/check.py b/src/glissade/check.py index e95ff17..ce233be 100644 --- a/src/glissade/check.py +++ b/src/glissade/check.py @@ -45,7 +45,7 @@ def _known(defn: str) -> set: or [ "title", "title-content", "section", "title-only", "two-content", "comparison", "content-caption", "picture-caption", "media-right", - "media-left", "media-full", "media-caption", "grid", "blank", + "media-left", "media-full", "media-caption", "grid", "quad-chart", "blank", ] ) MODIFIERS = {"ask", "story", "center"} @@ -55,6 +55,7 @@ def _known(defn: str) -> set: MEDIA_LAYOUTS = { "two-content", "comparison", "content-caption", "picture-caption", "media-right", "media-left", "media-full", "media-caption", "grid", + "quad-chart", } TEXT_ONLY = {"title", "title-only", "section", "title-content"} @@ -148,7 +149,11 @@ def _check_image(node: Any, where: str, base: Path, out: list[Issue]) -> None: if not (base / str(src)).is_file(): out.append(Issue("error", where, f"image not found: {src}")) if node.get("fit") and node["fit"] not in ("contain", "cover"): - out.append(Issue("warning", where, f"fit should be 'contain' or 'cover', got {node['fit']!r}")) + out.append(Issue( + "warning", + where, + f"fit should be 'contain' or 'cover', got {node['fit']!r}", + )) def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: @@ -198,7 +203,7 @@ def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: # Something has to render. visible = any(slide.get(k) for k in ( "heading", "subheading", "body", "bullets", "quote", "caption", - "image", "media", "images", "left", "right", "html", "eyebrow", + "image", "media", "images", "left", "right", "quads", "html", "eyebrow", )) if not visible: out.append(Issue("error", where, "slide has no visible content")) @@ -218,12 +223,16 @@ def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: if images is not None: if known and layout != "grid": out.append(Issue( - "warning", where, f"`images` only renders on the grid layout, not {layout!r}" + "warning", + where, + f"`images` only renders on the grid layout, not {layout!r}", )) if not isinstance(images, list) or not 2 <= len(images) <= 4: + count = len(images) if isinstance(images, list) else "?" out.append(Issue( - "error", where, - f"grid needs two to four images, got {len(images) if isinstance(images, list) else '?'}", + "error", + where, + f"grid needs two to four images, got {count}", )) else: for i, img in enumerate(images, start=1): @@ -245,9 +254,41 @@ def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: _check_image(block["image"], f"{where} {side}", base, out) if block.get("media"): _check_media(block["media"], f"{where} {side}", base, out) - if layout in ("two-content", "comparison") and not (slide.get("left") or slide.get("right")): + if layout in ("two-content", "comparison") and not ( + slide.get("left") or slide.get("right") + ): out.append(Issue("error", where, f"{layout} needs `left` and `right`")) + quads = slide.get("quads") + if quads is not None: + if known and layout != "quad-chart": + out.append(Issue( + "warning", + where, + f"`quads` only renders on the quad-chart layout, not {layout!r}", + )) + if not isinstance(quads, list) or len(quads) != 4: + out.append(Issue("error", where, "quad-chart needs exactly four `quads`")) + else: + for q_idx, quad in enumerate(quads, start=1): + q_where = f"{where} quad {q_idx}" + if not isinstance(quad, dict): + out.append(Issue("error", q_where, "quad must be an object")) + continue + _unknown_fields(quad, "quad", q_where, out) + if quad.get("image"): + _check_image(quad["image"], q_where, base, out) + if quad.get("media"): + _check_media(quad["media"], q_where, base, out) + q_visible = any( + quad.get(k) + for k in ("subheading", "body", "bullets", "image", "media") + ) + if not q_visible: + out.append(Issue("error", q_where, "quad has no visible content")) + elif layout == "quad-chart": + out.append(Issue("error", where, "quad-chart layout with no `quads`")) + if layout in TEXT_ONLY and (slide.get("image") or slide.get("media")): out.append(Issue( "warning", where, diff --git a/src/glissade/data/demo/decks/gallery.json b/src/glissade/data/demo/decks/gallery.json index d3f8b2d..5dc68f3 100644 --- a/src/glissade/data/demo/decks/gallery.json +++ b/src/glissade/data/demo/decks/gallery.json @@ -256,6 +256,43 @@ }, { "n": 17, + "layout": "quad-chart", + "title": "quad-chart", + "tag": "PowerPoint set", + "eyebrow": "Four views at once", + "heading": "quad-chart", + "subheading": "Each quadrant gets a subtitle and either text or media.", + "quads": [ + { + "subheading": "Problem", + "body": "
Summarise the constraint or tension in one short paragraph.
" + }, + { + "subheading": "Evidence", + "image": { + "src": "media/images/gradient-amber.jpg", + "alt": "Abstract gradient", + "fit": "cover" + }, + "aspect": "4:3" + }, + { + "subheading": "Options", + "bullets": [ + "Maintain the current path", + "Run a pilot in one region", + "Commit fully next quarter" + ] + }, + { + "subheading": "Recommendation", + "body": "Use the fourth box for the call to action, the next step, or the decision.
" + } + ], + "notes": "layout: \"quad-chart\"quads with exactly four entries. Each quadrant takes a subheading, plus body, bullets, an image, or media."
+ },
+ {
+ "n": 18,
"layout": "blank",
"title": "blank (freeform)",
"tag": "Escape hatch",
@@ -263,11 +300,11 @@
"notes": "layout: \"blank\" (or omit layout entirely)html and is used verbatim. The deck's own classes are available: eyebrow, lead, byline, dialogue, plus h1–h3, blockquote, cite, ul."
},
{
- "n": 18,
+ "n": 19,
"layout": "title-content",
"cls": "center",
"title": "End",
- "eyebrow": "That's all fourteen",
+ "eyebrow": "That's all fifteen",
"heading": "Copy what you need",
"body": "Every slide here is a working example in decks/gallery.json.
", "notes": "Close. Switch back to Glissade — A Tour from the remote for the how-and-why." diff --git a/src/glissade/data/demo/decks/tour.json b/src/glissade/data/demo/decks/tour.json index 2dea2be..f806907 100644 --- a/src/glissade/data/demo/decks/tour.json +++ b/src/glissade/data/demo/decks/tour.json @@ -127,7 +127,7 @@ "eyebrow": "decks/your-deck.json", "heading": "Anatomy of a slide", "caption": "Pick a layout, fill the fields it uses. Skip any you don't need — nothing is required.
Text fields accept HTML, so emphasis and quotation marks behave.
", - "notes": "layout: \"content-caption\" — a wide media region beside a narrower text column.eyebrow, heading, subheading, body, bullets, quote, image, media, images, left, right, caption, notes, tag, cls.eyebrow, heading, subheading, body, bullets, quote, image, media, images, left, right, quads, caption, notes, tag, cls.Glissade — A Tour
This one.
Layout Gallery
All fourteen layouts, one per slide.
Switch between them from the remote.
" + "body": "Glissade — A Tour
This one.
Layout Gallery
All fifteen layouts, one per slide.
Switch between them from the remote.
" }, "notes": "B is underused. Blanking the screen during a discussion pulls attention back to the room better than any slide can.' + esc(s.subheading) + '
'; - const media = s.media || (s.left && s.left.media) || (s.right && s.right.media); - const image = s.image || (s.left && s.left.image) || (s.right && s.right.image); + const firstQuad = Array.isArray(s.quads) ? s.quads.find(q => q && typeof q === 'object') : null; + const media = s.media || (s.left && s.left.media) || (s.right && s.right.media) || (firstQuad && firstQuad.media); + const image = s.image || (s.left && s.left.image) || (s.right && s.right.image) || (firstQuad && firstQuad.image); if(image && image.src) h += '' + s.caption + '
'; return h; } diff --git a/src/glissade/templates/deck.html b/src/glissade/templates/deck.html index c6e4a36..550a73f 100644 --- a/src/glissade/templates/deck.html +++ b/src/glissade/templates/deck.html @@ -196,6 +196,16 @@ .slide.lay p{margin-bottom:0} .slide.lay .stack ul{margin-top:.6vh} .slide.lay li{margin-bottom:1.8vh} + /* Title-content slides should use the full readable width of the slide + rather than the narrower default prose measure. Leave a right margin that + matches the slide's left gutter so the text block still feels framed. */ + .slide.lay[data-layout="title-content"] .text-region{width:100%} + .slide.lay[data-layout="title-content"] .text-region .subhead, + .slide.lay[data-layout="title-content"] .text-region p, + .slide.lay[data-layout="title-content"] .text-region ul, + .slide.lay[data-layout="title-content"] .text-region blockquote{ + max-width:calc(100% - 6vw); + } .subhead{ font-size:calc(2.6vh * var(--scale)); line-height:1.35; @@ -269,6 +279,48 @@ } .grid .media-box{background:rgba(0,0,0,.06)} + /* ---- quad chart ---- */ + .quad-chart{ + display:grid; + grid-template-columns:repeat(2, minmax(0, 1fr)); + grid-template-rows:repeat(2, minmax(0, 1fr)); + gap:2.6vh 2.2vw; + width:100%; + flex:1 1 auto; + min-height:0; + } + .quad-card{ + display:flex; + flex-direction:column; + min-width:0; + min-height:0; + padding:2.6vh 2.1vw; + border:1px solid var(--rule); + border-radius:1.1vh; + background:rgba(255,255,255,.4); + } + .slide.ask .quad-card{ + background:rgba(255,255,255,.04); + border-color:rgba(255,255,255,.12); + } + .quad-card .caption{max-width:none} + .quad-card > .figure{margin-top:1.6vh} + .quad-card > .text-region{flex:1 1 auto; justify-content:flex-start} + .quad-card > .text-region h2{font-size:calc(3vh * var(--scale)); margin-bottom:0} + .quad-card > .text-region .subhead{font-size:calc(2.2vh * var(--scale)); max-width:none} + .quad-card > .text-region p{font-size:calc(2.2vh * var(--scale)); max-width:none} + .quad-card > .text-region ul{max-width:none} + .quad-card > .text-region li{ + font-size:calc(2.25vh * var(--scale)); + margin-bottom:1.25vh; + padding-left:calc(3.2vh * var(--scale)); + } + .quad-card > .text-region li:before{ + top:calc(1.05vh * var(--scale)); + width:calc(1.2vh * var(--scale)); + height:calc(1.2vh * var(--scale)); + } + /* ---- section header ---- */ .slide.section-head{justify-content:center} .slide.section-head .rule{ @@ -683,6 +735,20 @@ items.forEach(i => g.appendChild(figure(i, {fit:'cover', aspect: s.aspect || 'auto'}))); out.push(g); return out; + }, + 'quad-chart': s => { + const out = []; + if(s.heading || s.eyebrow || s.subheading) out.push(textRegion({eyebrow:s.eyebrow, heading:s.heading, subheading:s.subheading})); + const grid = el('div', 'quad-chart'); + (s.quads || []).forEach(quad => { + const card = el('div', 'quad-card'); + const media = pickMedia(quad); + card.appendChild(textRegion({subheading: quad.subheading, body: quad.body, bullets: quad.bullets})); + if(media) card.appendChild(figure(media, {aspect: quad.aspect || s.aspect || 'auto', fit:'cover'})); + grid.appendChild(card); + }); + out.push(grid); + return out; } }; diff --git a/tests/test_build.py b/tests/test_build.py index 48ba26c..362ffbf 100644 --- a/tests/test_build.py +++ b/tests/test_build.py @@ -42,3 +42,30 @@ def test_build_all(tmp_path, sample_deck_file): results = build_all(project, [deck]) assert len(results) == 1 assert results[0].path.exists() + + +def test_build_deck_quad_chart(tmp_path, sample_deck_file): + deck = { + "id": "talk", + "title": "Test Deck", + "path": str(sample_deck_file), + "slides": [ + { + "layout": "quad-chart", + "heading": "Built quad chart", + "quads": [ + {"subheading": "One", "body": "Alpha
"}, + {"subheading": "Two", "body": "Beta
"}, + {"subheading": "Three", "body": "Gamma
"}, + {"subheading": "Four", "body": "Delta
"}, + ], + "notes": "Notes" + } + ] + } + out_dir = tmp_path / "build" + result = build_deck(deck, out_dir) + content = result.path.read_text(encoding="utf-8") + assert "Built quad chart" in content + assert "quad-chart" in content + assert "Alpha" in content diff --git a/tests/test_check.py b/tests/test_check.py index adf36d3..4e95746 100644 --- a/tests/test_check.py +++ b/tests/test_check.py @@ -53,6 +53,43 @@ def test_check_slides_missing_notes_warning(tmp_path): warnings = [i for i in issues if i.level == "warning"] assert any("no speaker notes" in w.message for w in warnings) + +def test_check_slides_quad_chart_valid(tmp_path): + slides = [ + { + "layout": "quad-chart", + "heading": "Quarterly view", + "quads": [ + {"subheading": "North", "body": "Steady growth.
"}, + {"subheading": "South", "body": "Launch in September.
"}, + {"subheading": "East", "body": "Partner-led pipeline.
"}, + {"subheading": "West", "body": "Margin recovery.
"}, + ], + "notes": "Talk through each region clockwise." + } + ] + issues = check_slides(slides, tmp_path) + errors = [i for i in issues if i.level == "error"] + assert len(errors) == 0 + + +def test_check_slides_quad_chart_requires_four_quads(tmp_path): + slides = [ + { + "layout": "quad-chart", + "heading": "Quarterly view", + "quads": [ + {"subheading": "North", "body": "Steady growth.
"}, + {"subheading": "South", "body": "Launch in September.
"}, + {"subheading": "East", "body": "Partner-led pipeline.
"}, + ], + "notes": "Talk through each region clockwise." + } + ] + issues = check_slides(slides, tmp_path) + errors = [i for i in issues if i.level == "error"] + assert any("quad-chart needs exactly four `quads`" in e.message for e in errors) + def test_check_requirement_satisfied(): deck = {"requires": ">=0.1.0"} issues = check_requirement(deck)