From 46f7f2907d694cf6461027fa28f86e525d212eac Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 15:03:14 -0400 Subject: [PATCH 1/4] feat: add quad-chart layout and uv dev workflow Add the new quad-chart slide layout across schema, validation, asset preparation, rendering, demo content, and tests so decks can split a slide into four labeled quadrants. Also switch the documented local workflow to a cross-platform uv task script, update install docs to use GitHub while the package is not on PyPI, widen title-content bullet lists, and bump the project version to 0.11.0_beta for the local beta build. --- README.md | 62 ++++++++-- pyproject.toml | 2 +- scripts/dev.py | 117 ++++++++++++++++++ src/glissade/__init__.py | 2 +- src/glissade/assets.py | 25 +++- src/glissade/check.py | 30 ++++- src/glissade/data/demo/decks/gallery.json | 41 +++++- src/glissade/data/demo/decks/tour.json | 4 +- src/glissade/data/scaffold/AGENTS.md | 1 + src/glissade/data/scaffold/decks/welcome.json | 2 +- src/glissade/data/schema.json | 37 +++++- src/glissade/templates/control.html | 11 +- src/glissade/templates/deck.html | 60 +++++++++ tests/test_build.py | 27 ++++ tests/test_check.py | 37 ++++++ 15 files changed, 429 insertions(+), 29 deletions(-) create mode 100644 scripts/dev.py 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..969cfec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "glissade" -version = "0.10.0" +version = "0.11.0_beta" 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" diff --git a/scripts/dev.py b/scripts/dev.py new file mode 100644 index 0000000..3867723 --- /dev/null +++ b/scripts/dev.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import os +import shutil +import subprocess +import sys +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..612704b 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.11.0_beta" from .project import ( # noqa: F401 DATA_DIR, diff --git a/src/glissade/assets.py b/src/glissade/assets.py index 4036fa6..77c1bd9 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,22 @@ 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 ( + 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, - ): + ] + 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..0e159b9 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"} @@ -198,7 +199,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")) @@ -248,6 +249,31 @@ def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: 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\"

Use 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)

Your markup goes in html and is used verbatim. The deck's own classes are available: eyebrow, lead, byline, dialogue, plus h1h3, 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.

Fields: eyebrow, heading, subheading, body, bullets, quote, image, media, images, left, right, caption, notes, tag, cls.

The Layout Gallery deck shows all fourteen layouts. Switch to it from the remote." + "notes": "layout: \"content-caption\" — a wide media region beside a narrower text column.

Fields: eyebrow, heading, subheading, body, bullets, quote, image, media, images, left, right, quads, caption, notes, tag, cls.

The Layout Gallery deck shows all fifteen layouts. Switch to it from the remote." }, { "n": 9, @@ -318,7 +318,7 @@ }, "right": { "subheading": "Two decks ship", - "body": "

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.

Now switch to the Layout Gallery from your phone — it picks up where this leaves off." }, diff --git a/src/glissade/data/scaffold/AGENTS.md b/src/glissade/data/scaffold/AGENTS.md index 007ec95..1fc7d19 100644 --- a/src/glissade/data/scaffold/AGENTS.md +++ b/src/glissade/data/scaffold/AGENTS.md @@ -85,6 +85,7 @@ Pick a `layout`, fill the fields it uses, skip the rest. Nothing is required. | `media-caption` | centred media at moderate size | | `media-full` | media fills the slide; heading and body overlay it | | `grid` | two to four `images` | +| `quad-chart` | heading plus four `quads`, each with its own subheading and text or media | | `blank` | raw `html`, used verbatim | `cls` is independent of layout and combines freely: `"ask"` (dark — for diff --git a/src/glissade/data/scaffold/decks/welcome.json b/src/glissade/data/scaffold/decks/welcome.json index 1a59a38..a3a441c 100644 --- a/src/glissade/data/scaffold/decks/welcome.json +++ b/src/glissade/data/scaffold/decks/welcome.json @@ -28,7 +28,7 @@ "sub": "Good for a source or an aside" } ], - "notes": "Every field here is optional. Delete what you don't need.

See AGENTS.md for the full field reference, or run glissade demo for a deck that demonstrates all fourteen layouts." + "notes": "Every field here is optional. Delete what you don't need.

See AGENTS.md for the full field reference, or run glissade demo for a deck that demonstrates all fifteen layouts." }, { "title": "Q: A discussion stop", diff --git a/src/glissade/data/schema.json b/src/glissade/data/schema.json index a44576c..214520c 100644 --- a/src/glissade/data/schema.json +++ b/src/glissade/data/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://glissade.dev/schema/v1.json", "x-glissade-schema": 1, - "x-glissade-version": "0.8.0", + "x-glissade-version": "0.11.0_beta", "title": "Glissade deck", "description": "A presentation deck. Either an object with metadata and a slides array, or a bare array of slides.", "oneOf": [ @@ -88,6 +88,7 @@ "media-full", "media-caption", "grid", + "quad-chart", "blank" ], "default": "blank", @@ -155,6 +156,15 @@ "right": { "$ref": "#/$defs/block" }, + "quads": { + "type": "array", + "minItems": 4, + "maxItems": 4, + "items": { + "$ref": "#/$defs/quad" + }, + "description": "quad-chart layout only. Exactly four quadrants, each with its own subheading and text or media content." + }, "aspect": { "type": "string" }, @@ -275,6 +285,31 @@ } }, "additionalProperties": false + }, + "quad": { + "type": "object", + "description": "One quadrant of a quad-chart slide. Give it a subheading and either text fields or an image/embed.", + "properties": { + "subheading": { + "type": "string" + }, + "body": { + "type": "string" + }, + "bullets": { + "$ref": "#/$defs/bullets" + }, + "image": { + "$ref": "#/$defs/image" + }, + "media": { + "$ref": "#/$defs/media" + }, + "aspect": { + "type": "string" + } + }, + "additionalProperties": false } } } diff --git a/src/glissade/templates/control.html b/src/glissade/templates/control.html index 4f937d3..ab839cc 100644 --- a/src/glissade/templates/control.html +++ b/src/glissade/templates/control.html @@ -573,8 +573,9 @@

if(s.heading) h += '

' + esc(s.heading) + '

'; if(s.subheading) h += '

' + 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 += ''; @@ -599,6 +600,12 @@

h += ''; } + if(Array.isArray(s.quads) && s.quads.length){ + h += ''; + } if(s.caption) h += '

' + s.caption + '

'; return h; } diff --git a/src/glissade/templates/deck.html b/src/glissade/templates/deck.html index c6e4a36..c484681 100644 --- a/src/glissade/templates/deck.html +++ b/src/glissade/templates/deck.html @@ -196,6 +196,10 @@ .slide.lay p{margin-bottom:0} .slide.lay .stack ul{margin-top:.6vh} .slide.lay li{margin-bottom:1.8vh} + /* Title-content slides can be bullet-heavy; let those lists run wider than + the default prose measure so they use more of the slide without changing + other layouts. */ + .slide.lay[data-layout="title-content"] .text-region > ul{max-width:60ch} .subhead{ font-size:calc(2.6vh * var(--scale)); line-height:1.35; @@ -269,6 +273,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 +729,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) From 51b0d9afe1c015639a1b01d244be0f4311e4c3f5 Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 15:42:27 -0400 Subject: [PATCH 2/4] chore: release v0.12.0 Bump the package, schema, and CLI version to 0.12.0 and keep the updated title-content width behavior in the shipped deck template. --- pyproject.toml | 2 +- src/glissade/__init__.py | 2 +- src/glissade/data/schema.json | 2 +- src/glissade/templates/deck.html | 14 ++++++++++---- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 969cfec..1305044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "glissade" -version = "0.11.0_beta" +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" diff --git a/src/glissade/__init__.py b/src/glissade/__init__.py index 612704b..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.11.0_beta" +__version__ = "0.12.0" from .project import ( # noqa: F401 DATA_DIR, diff --git a/src/glissade/data/schema.json b/src/glissade/data/schema.json index 214520c..f5bfdf0 100644 --- a/src/glissade/data/schema.json +++ b/src/glissade/data/schema.json @@ -2,7 +2,7 @@ "$schema": "http://json-schema.org/draft-07/schema#", "$id": "https://glissade.dev/schema/v1.json", "x-glissade-schema": 1, - "x-glissade-version": "0.11.0_beta", + "x-glissade-version": "0.12.0", "title": "Glissade deck", "description": "A presentation deck. Either an object with metadata and a slides array, or a bare array of slides.", "oneOf": [ diff --git a/src/glissade/templates/deck.html b/src/glissade/templates/deck.html index c484681..550a73f 100644 --- a/src/glissade/templates/deck.html +++ b/src/glissade/templates/deck.html @@ -196,10 +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 can be bullet-heavy; let those lists run wider than - the default prose measure so they use more of the slide without changing - other layouts. */ - .slide.lay[data-layout="title-content"] .text-region > ul{max-width:60ch} + /* 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; From ed22d0ff5b57e26f3011feff440e6968301e473b Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 16:07:13 -0400 Subject: [PATCH 3/4] ci: create uv venv in GitHub Actions --- .github/workflows/test.yml | 3 +++ 1 file changed, 3 insertions(+) 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]" From 1aec22d0e9cc27718395c11c73c0ac4cc362b664 Mon Sep 17 00:00:00 2001 From: david fullmer Date: Wed, 29 Jul 2026 16:08:43 -0400 Subject: [PATCH 4/4] ci: satisfy GitHub Actions lint setup --- pyproject.toml | 1 + scripts/dev.py | 69 +++++++++++++++++++++++++++++++++--------- src/glissade/assets.py | 6 ++-- src/glissade/check.py | 29 +++++++++++++----- 4 files changed, 82 insertions(+), 23 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1305044..403c6d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 index 3867723..86d74c6 100644 --- a/scripts/dev.py +++ b/scripts/dev.py @@ -5,7 +5,6 @@ import os import shutil import subprocess -import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent @@ -59,7 +58,6 @@ 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) @@ -81,27 +79,70 @@ def cmd_build(args: argparse.Namespace) -> None: def parser() -> argparse.ArgumentParser: - p = argparse.ArgumentParser(description="Cross-platform developer tasks for Glissade.") + 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 = 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.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 = 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 = 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 diff --git a/src/glissade/assets.py b/src/glissade/assets.py index 77c1bd9..49a034c 100644 --- a/src/glissade/assets.py +++ b/src/glissade/assets.py @@ -248,10 +248,12 @@ 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) + 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): diff --git a/src/glissade/check.py b/src/glissade/check.py index 0e159b9..ce233be 100644 --- a/src/glissade/check.py +++ b/src/glissade/check.py @@ -149,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]: @@ -219,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): @@ -246,14 +254,18 @@ 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}" + "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`")) @@ -268,7 +280,10 @@ def check_slides(slides: list[Any], base: Path, label: str = "") -> list[Issue]: _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")) + 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":