scrolly 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. scrolly/__init__.py +3 -0
  2. scrolly/_cli/__init__.py +0 -0
  3. scrolly/_cli/_cli.py +120 -0
  4. scrolly/deck/__init__.py +43 -0
  5. scrolly/deck/inference.py +61 -0
  6. scrolly/deck/model.py +103 -0
  7. scrolly/deck/parser.py +168 -0
  8. scrolly/deck/schema.py +104 -0
  9. scrolly/deck/validator.py +94 -0
  10. scrolly/errors.py +34 -0
  11. scrolly/pipeline/__init__.py +6 -0
  12. scrolly/pipeline/assets.py +119 -0
  13. scrolly/pipeline/orchestrator.py +90 -0
  14. scrolly/pipeline/writer.py +38 -0
  15. scrolly/render/__init__.py +6 -0
  16. scrolly/render/assembler.py +69 -0
  17. scrolly/render/assets/canvas.css +770 -0
  18. scrolly/render/assets/canvas.js +1324 -0
  19. scrolly/render/bundled_assets.py +55 -0
  20. scrolly/render/fan.py +110 -0
  21. scrolly/render/nav_data.py +138 -0
  22. scrolly/render/templates/index.html.j2 +86 -0
  23. scrolly/slide/__init__.py +50 -0
  24. scrolly/slide/compilers/__init__.py +0 -0
  25. scrolly/slide/compilers/storyboard.py +102 -0
  26. scrolly/slide/html.py +47 -0
  27. scrolly/slide/ir/__init__.py +28 -0
  28. scrolly/slide/ir/_framework/__init__.py +0 -0
  29. scrolly/slide/ir/_framework/animation.py +43 -0
  30. scrolly/slide/ir/_framework/base.py +35 -0
  31. scrolly/slide/ir/_framework/element.py +80 -0
  32. scrolly/slide/ir/_framework/utils.py +98 -0
  33. scrolly/slide/ir/scrollimation.py +93 -0
  34. scrolly/slide/ir/static.py +176 -0
  35. scrolly/slide/ir/storyboard.py +86 -0
  36. scrolly/slide/processor.py +35 -0
  37. scrolly/slide/registry.py +100 -0
  38. scrolly/slide/renderers/__init__.py +0 -0
  39. scrolly/slide/renderers/scrollimation.py +295 -0
  40. scrolly/slide/renderers/static.py +80 -0
  41. scrolly-0.1.0.dist-info/METADATA +65 -0
  42. scrolly-0.1.0.dist-info/RECORD +45 -0
  43. scrolly-0.1.0.dist-info/WHEEL +4 -0
  44. scrolly-0.1.0.dist-info/entry_points.txt +2 -0
  45. scrolly-0.1.0.dist-info/licenses/LICENSE +21 -0
scrolly/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ from importlib.metadata import version
2
+
3
+ __version__ = version("scrolly")
File without changes
scrolly/_cli/_cli.py ADDED
@@ -0,0 +1,120 @@
1
+ import json
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from scrolly import __version__
9
+ from scrolly.errors import ScrollyError
10
+ from scrolly.pipeline import build_deck, validate_deck_sources
11
+
12
+ _err_console = Console(stderr=True, highlight=False)
13
+
14
+
15
+ @click.group()
16
+ @click.version_option(__version__, prog_name="scrolly")
17
+ def cli() -> None:
18
+ """scrolly — compile a JSON5 deck into a self-contained 2D-canvas HTML presentation."""
19
+
20
+
21
+ @cli.command()
22
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
23
+ @click.option(
24
+ "--out",
25
+ "out_dir",
26
+ required=True,
27
+ type=click.Path(file_okay=False, path_type=Path),
28
+ help="Output directory.",
29
+ )
30
+ @click.option("--force", is_flag=True, help="Overwrite a non-empty output directory.")
31
+ @click.option("--no-inline", is_flag=True, help="Write assets as separate files instead of inlining.")
32
+ def build(deck_path: Path, out_dir: Path, force: bool, no_inline: bool) -> None:
33
+ """Build a deck into a self-contained HTML presentation."""
34
+ try:
35
+ deck = build_deck(deck_path, out_dir, force=force, inline=not no_inline)
36
+ except ScrollyError as e:
37
+ _err_console.print(f"[red]error:[/red] {e}")
38
+ sys.exit(1)
39
+
40
+ click.echo(f"Built '{deck.title or '(untitled)'}': {len(deck.slides)} slides, {len(deck.edges)} edges → {out_dir}")
41
+
42
+
43
+ @cli.command()
44
+ @click.argument("type_name", required=False)
45
+ def schema(type_name: str | None) -> None:
46
+ """Show source file schemas. Lists types when called without an argument."""
47
+ from scrolly.deck import deck_source_schema
48
+ from scrolly.slide import registered_ir_types
49
+
50
+ ir_types = registered_ir_types()
51
+
52
+ if type_name is None:
53
+ click.echo("Available schemas:\n")
54
+ click.echo(f" {'deck':<17}{'.deck.json':<24}Deck structure (slides + edges)")
55
+ for name in sorted(ir_types):
56
+ cls = ir_types[name]
57
+ click.echo(f" {name:<17}{cls.SUFFIX:<24}{cls.DESCRIPTION}")
58
+ return
59
+
60
+ if type_name == "deck":
61
+ click.echo(json.dumps(deck_source_schema(), indent=2))
62
+ return
63
+
64
+ if type_name not in ir_types:
65
+ known = ", ".join(sorted(["deck", *ir_types]))
66
+ _err_console.print(f"[red]error:[/red] unknown type '{type_name}' (known: {known})")
67
+ sys.exit(1)
68
+
69
+ click.echo(json.dumps(ir_types[type_name].source_schema(), indent=2))
70
+
71
+
72
+ @cli.command()
73
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
74
+ def validate(deck_path: Path) -> None:
75
+ """Validate a deck and all its slide sources without building."""
76
+ try:
77
+ deck = validate_deck_sources(deck_path)
78
+ except ScrollyError as e:
79
+ _err_console.print(f"[red]error:[/red] {e}")
80
+ sys.exit(1)
81
+
82
+ click.echo(f"Valid: {len(deck.slides)} slides, {len(deck.edges)} edges")
83
+
84
+
85
+ _INIT_DECK = """\
86
+ {
87
+ title: "My Deck",
88
+ slides: [
89
+ { id: "intro", position: [0, 0], source: "slides/intro.static.md" },
90
+ ],
91
+ edges: [],
92
+ }
93
+ """
94
+
95
+ _INIT_SLIDE = """\
96
+ ---
97
+ initial_scroll_position: 0
98
+ ---
99
+
100
+ # My Deck
101
+
102
+ Welcome to your new presentation.
103
+ """
104
+
105
+
106
+ @cli.command()
107
+ @click.argument("dir_path", type=click.Path(path_type=Path))
108
+ def init(dir_path: Path) -> None:
109
+ """Scaffold a minimal deck in DIR_PATH."""
110
+ if dir_path.exists() and any(dir_path.iterdir()):
111
+ _err_console.print(f"[red]error:[/red] directory is not empty: {dir_path}")
112
+ sys.exit(1)
113
+
114
+ slides_dir = dir_path / "slides"
115
+ slides_dir.mkdir(parents=True, exist_ok=True)
116
+
117
+ (dir_path / "deck.deck.json").write_text(_INIT_DECK)
118
+ (slides_dir / "intro.static.md").write_text(_INIT_SLIDE)
119
+
120
+ click.echo(f"Created deck in {dir_path}")
@@ -0,0 +1,43 @@
1
+ """Deck model, parsing, validation, and inference (Layer A).
2
+
3
+ Pipeline:
4
+ raw_deck = parse_deck(path)
5
+ validate_raw_deck(raw_deck)
6
+ deck = infer_edges(raw_deck)
7
+ validate_deck(deck)
8
+ """
9
+
10
+ from scrolly.deck.inference import infer_edges
11
+ from scrolly.deck.model import (
12
+ Deck,
13
+ Edge,
14
+ Endpoint,
15
+ Position,
16
+ RawDeck,
17
+ RawEdge,
18
+ RawEndpoint,
19
+ Side,
20
+ Slide,
21
+ SlideGroup,
22
+ )
23
+ from scrolly.deck.parser import parse_deck
24
+ from scrolly.deck.schema import deck_source_schema
25
+ from scrolly.deck.validator import validate_deck, validate_raw_deck
26
+
27
+ __all__ = [
28
+ "Deck",
29
+ "Edge",
30
+ "Endpoint",
31
+ "SlideGroup",
32
+ "Position",
33
+ "RawDeck",
34
+ "RawEdge",
35
+ "RawEndpoint",
36
+ "Side",
37
+ "Slide",
38
+ "deck_source_schema",
39
+ "infer_edges",
40
+ "parse_deck",
41
+ "validate_deck",
42
+ "validate_raw_deck",
43
+ ]
@@ -0,0 +1,61 @@
1
+ """Fill in omitted edge sides by inferring from slide positions.
2
+
3
+ An endpoint with `side=None` has its side inferred from where the other
4
+ endpoint's slide sits relative to this one on the grid:
5
+
6
+ dx > 0 → right dx < 0 → left
7
+ dy > 0 → bottom dy < 0 → top
8
+
9
+ If the two slides are diagonal (dx ≠ 0 and dy ≠ 0), there is no unique
10
+ side and a `DeckInferenceError` is raised.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from scrolly.deck.model import Deck, Edge, Endpoint, Position, RawDeck, Side
16
+ from scrolly.errors import DeckInferenceError
17
+
18
+
19
+ def infer_edges(deck: RawDeck) -> Deck:
20
+ """Return a `Deck` with every edge's sides fully specified."""
21
+ positions = {slide.id: slide.position for slide in deck.slides}
22
+ inferred: list[Edge] = []
23
+
24
+ for idx, edge in enumerate(deck.edges):
25
+ a_pos = positions[edge.a.slide_id]
26
+ b_pos = positions[edge.b.slide_id]
27
+
28
+ a_side = edge.a.side or _infer_side(a_pos, b_pos, idx, edge.a.slide_id, edge.b.slide_id)
29
+ b_side = edge.b.side or _infer_side(b_pos, a_pos, idx, edge.b.slide_id, edge.a.slide_id)
30
+
31
+ inferred.append(
32
+ Edge(
33
+ a=Endpoint(slide_id=edge.a.slide_id, side=a_side),
34
+ b=Endpoint(slide_id=edge.b.slide_id, side=b_side),
35
+ )
36
+ )
37
+
38
+ return Deck(title=deck.title, slides=deck.slides, edges=tuple(inferred), groups=deck.groups)
39
+
40
+
41
+ def _infer_side(
42
+ from_pos: Position,
43
+ to_pos: Position,
44
+ edge_idx: int,
45
+ from_id: str,
46
+ to_id: str,
47
+ ) -> Side:
48
+ """Return the side of `from_pos`'s slide that faces `to_pos`'s slide."""
49
+ dx = to_pos.x - from_pos.x
50
+ dy = to_pos.y - from_pos.y
51
+
52
+ if dx != 0 and dy == 0:
53
+ return Side.RIGHT if dx > 0 else Side.LEFT
54
+ if dy != 0 and dx == 0:
55
+ return Side.BOTTOM if dy > 0 else Side.TOP
56
+
57
+ # Diagonal or same cell — no unique side.
58
+ raise DeckInferenceError(
59
+ f"edges[{edge_idx}]: cannot infer side for '{from_id}' — "
60
+ f"'{from_id}' and '{to_id}' are not on the same row or column"
61
+ )
scrolly/deck/model.py ADDED
@@ -0,0 +1,103 @@
1
+ """Data model for the deck (Layer A).
2
+
3
+ The parser produces a `RawDeck` (edges may have omitted sides). The inference
4
+ step fills in omitted sides and produces a `Deck` (every edge side is known).
5
+ Downstream layers work exclusively with the fully-specified `Deck`.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from enum import Enum
12
+ from pathlib import Path
13
+
14
+
15
+ class Side(Enum):
16
+ """One of the four sides of a slide. Edges attach to sides."""
17
+
18
+ TOP = "top"
19
+ BOTTOM = "bottom"
20
+ LEFT = "left"
21
+ RIGHT = "right"
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Position:
26
+ """Integer grid position. x increases left → right, y increases top → bottom."""
27
+
28
+ x: int
29
+ y: int
30
+
31
+
32
+ @dataclass(frozen=True)
33
+ class Slide:
34
+ """One slide on the canvas.
35
+
36
+ The slide's *type* (which renderer handles it) is encoded in the
37
+ `source` filename's suffix and resolved at render time via
38
+ `scrolly.slide.get_renderer_for_path`. No `type` field on the model.
39
+ """
40
+
41
+ id: str
42
+ position: Position
43
+ source: Path
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RawEndpoint:
48
+ """Edge endpoint pre-inference — the side may be omitted."""
49
+
50
+ slide_id: str
51
+ side: Side | None
52
+
53
+
54
+ @dataclass(frozen=True)
55
+ class Endpoint:
56
+ """Edge endpoint post-inference — the side is always known."""
57
+
58
+ slide_id: str
59
+ side: Side
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class RawEdge:
64
+ """Undirected edge between two slides, pre-inference."""
65
+
66
+ a: RawEndpoint
67
+ b: RawEndpoint
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class Edge:
72
+ """Undirected edge between two slides, fully specified."""
73
+
74
+ a: Endpoint
75
+ b: Endpoint
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class SlideGroup:
80
+ """A named group of slides, visualised as a background rectangle in deck view."""
81
+
82
+ label: str
83
+ slide_ids: tuple[str, ...]
84
+
85
+
86
+ @dataclass(frozen=True)
87
+ class RawDeck:
88
+ """Deck as parsed from disk, with edges potentially missing side info."""
89
+
90
+ title: str | None
91
+ slides: tuple[Slide, ...]
92
+ edges: tuple[RawEdge, ...]
93
+ groups: tuple[SlideGroup, ...] = ()
94
+
95
+
96
+ @dataclass(frozen=True)
97
+ class Deck:
98
+ """Deck after inference, with every edge side fully specified."""
99
+
100
+ title: str | None
101
+ slides: tuple[Slide, ...]
102
+ edges: tuple[Edge, ...]
103
+ groups: tuple[SlideGroup, ...] = ()
scrolly/deck/parser.py ADDED
@@ -0,0 +1,168 @@
1
+ """Parse a JSON5 deck file into a `RawDeck`.
2
+
3
+ Purely syntactic: checks file shape (required fields, types) and returns a
4
+ `RawDeck` whose edges may still have omitted sides. Semantic checks (unique
5
+ ids, edges reference real slides, one-slide-per-cell, diagonal-inference
6
+ rejection) live in `validator.py` and `inference.py`.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import json5
15
+
16
+ from scrolly.deck.model import Position, RawDeck, RawEdge, RawEndpoint, Side, Slide, SlideGroup
17
+ from scrolly.errors import DeckParseError
18
+
19
+
20
+ def parse_deck(deck_path: Path) -> RawDeck:
21
+ """Load and parse a JSON5 deck file.
22
+
23
+ Slide source paths are resolved relative to the deck file's directory.
24
+ """
25
+ try:
26
+ raw = json5.loads(deck_path.read_text())
27
+ except ValueError as e:
28
+ raise DeckParseError(f"{deck_path}: could not parse JSON5: {e}") from e
29
+
30
+ if not isinstance(raw, dict):
31
+ raise DeckParseError(f"{deck_path}: deck file must be a JSON5 object, got {type(raw).__name__}")
32
+
33
+ title = raw.get("title")
34
+ if title is not None and not isinstance(title, str):
35
+ raise DeckParseError(f"{deck_path}: field 'title' must be a string if present")
36
+
37
+ slides_raw = _require_list(raw, "slides", deck_path)
38
+ slides, groups = _parse_slides_and_groups(slides_raw, deck_path)
39
+
40
+ edges_raw = raw.get("edges", [])
41
+ if not isinstance(edges_raw, list):
42
+ raise DeckParseError(f"{deck_path}: field 'edges' must be a list if present")
43
+ edges = tuple(_parse_edge(e, idx) for idx, e in enumerate(edges_raw))
44
+
45
+ return RawDeck(title=title, slides=slides, edges=edges, groups=groups)
46
+
47
+
48
+ def _parse_slides_and_groups(slides_raw: list, deck_path: Path) -> tuple[tuple[Slide, ...], tuple[SlideGroup, ...]]:
49
+ deck_dir = deck_path.parent
50
+ slides: list[Slide] = []
51
+ groups: list[SlideGroup] = []
52
+ flat_idx = 0
53
+
54
+ for top_idx, item in enumerate(slides_raw):
55
+ ctx = f"slides[{top_idx}]"
56
+ if not isinstance(item, dict):
57
+ raise DeckParseError(f"{ctx}: must be an object, got {type(item).__name__}")
58
+
59
+ if "group" in item:
60
+ label = item["group"]
61
+ if not isinstance(label, str) or not label.strip():
62
+ raise DeckParseError(f"{ctx}: 'group' must be a non-empty string")
63
+ label = label.strip()
64
+
65
+ if "slides" not in item or not isinstance(item["slides"], list):
66
+ raise DeckParseError(f"{ctx}: group must have a 'slides' list")
67
+
68
+ group_slide_ids: list[str] = []
69
+ for inner_idx, inner in enumerate(item["slides"]):
70
+ inner_ctx = f"slides[{top_idx}].slides[{inner_idx}]"
71
+ if isinstance(inner, dict) and "group" in inner:
72
+ raise DeckParseError(f"{inner_ctx}: nested groups are not allowed")
73
+ slide = _parse_slide(inner, deck_dir, flat_idx, inner_ctx)
74
+ slides.append(slide)
75
+ group_slide_ids.append(slide.id)
76
+ flat_idx += 1
77
+
78
+ groups.append(SlideGroup(label=label, slide_ids=tuple(group_slide_ids)))
79
+ else:
80
+ slide = _parse_slide(item, deck_dir, flat_idx, ctx)
81
+ slides.append(slide)
82
+ flat_idx += 1
83
+
84
+ return tuple(slides), tuple(groups)
85
+
86
+
87
+ def _require_list(d: dict, key: str, deck_path: Path) -> list:
88
+ if key not in d:
89
+ raise DeckParseError(f"{deck_path}: missing required field '{key}'")
90
+ v = d[key]
91
+ if not isinstance(v, list):
92
+ raise DeckParseError(f"{deck_path}: field '{key}' must be a list, got {type(v).__name__}")
93
+ return v
94
+
95
+
96
+ def _parse_slide(raw: Any, deck_dir: Path, idx: int, ctx: str | None = None) -> Slide:
97
+ if ctx is None:
98
+ ctx = f"slides[{idx}]"
99
+ if not isinstance(raw, dict):
100
+ raise DeckParseError(f"{ctx}: must be an object, got {type(raw).__name__}")
101
+
102
+ slide_id = _require_str(raw, "id", ctx)
103
+ position = _parse_position(raw, ctx)
104
+ source_raw = _require_str(raw, "source", ctx)
105
+
106
+ source = (deck_dir / source_raw).resolve()
107
+ return Slide(id=slide_id, position=position, source=source)
108
+
109
+
110
+ def _parse_position(raw_slide: dict, ctx: str) -> Position:
111
+ if "position" not in raw_slide:
112
+ raise DeckParseError(f"{ctx}: missing required field 'position'")
113
+ raw_pos = raw_slide["position"]
114
+ if not isinstance(raw_pos, list) or len(raw_pos) != 2:
115
+ raise DeckParseError(f"{ctx}: 'position' must be a two-element array [x, y]")
116
+ x, y = raw_pos
117
+ if not _is_int(x) or not _is_int(y):
118
+ raise DeckParseError(f"{ctx}: 'position' entries must be integers")
119
+ return Position(x=x, y=y)
120
+
121
+
122
+ def _require_str(d: dict, key: str, ctx: str) -> str:
123
+ if key not in d:
124
+ raise DeckParseError(f"{ctx}: missing required field '{key}'")
125
+ v = d[key]
126
+ if not isinstance(v, str):
127
+ raise DeckParseError(f"{ctx}: field '{key}' must be a string, got {type(v).__name__}")
128
+ return v
129
+
130
+
131
+ def _is_int(v: Any) -> bool:
132
+ # bool is a subclass of int in Python — exclude it.
133
+ return isinstance(v, int) and not isinstance(v, bool)
134
+
135
+
136
+ def _parse_edge(raw: Any, idx: int) -> RawEdge:
137
+ ctx = f"edges[{idx}]"
138
+ if not isinstance(raw, list):
139
+ raise DeckParseError(f"{ctx}: must be a two-element array, got {type(raw).__name__}")
140
+ if len(raw) != 2:
141
+ raise DeckParseError(f"{ctx}: must be a two-element array, got {len(raw)} elements")
142
+
143
+ return RawEdge(
144
+ a=_parse_endpoint(raw[0], f"{ctx}[0]"),
145
+ b=_parse_endpoint(raw[1], f"{ctx}[1]"),
146
+ )
147
+
148
+
149
+ def _parse_endpoint(raw: Any, ctx: str) -> RawEndpoint:
150
+ if not isinstance(raw, str):
151
+ raise DeckParseError(f"{ctx}: endpoint must be a string, got {type(raw).__name__}")
152
+
153
+ parts = raw.split("|", 1)
154
+ slide_id = parts[0].strip()
155
+ if not slide_id:
156
+ raise DeckParseError(f"{ctx}: endpoint has empty slide id")
157
+
158
+ if len(parts) == 1:
159
+ return RawEndpoint(slide_id=slide_id, side=None)
160
+
161
+ side_str = parts[1].strip()
162
+ try:
163
+ side = Side(side_str)
164
+ except ValueError:
165
+ valid = ", ".join(s.value for s in Side)
166
+ raise DeckParseError(f"{ctx}: side '{side_str}' is not one of ({valid})") from None
167
+
168
+ return RawEndpoint(slide_id=slide_id, side=side)
scrolly/deck/schema.py ADDED
@@ -0,0 +1,104 @@
1
+ """JSON Schema for the deck source format."""
2
+
3
+
4
+ def deck_source_schema() -> dict:
5
+ """Return a JSON Schema describing the deck JSON5 source format."""
6
+ return {
7
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
8
+ "title": "Deck source format",
9
+ "description": (
10
+ "A JSON5 file describing the presentation: a list of slides "
11
+ "on an integer grid, plus optional edges connecting them."
12
+ ),
13
+ "type": "object",
14
+ "properties": {
15
+ "title": {
16
+ "type": "string",
17
+ "description": "Human-readable deck title.",
18
+ },
19
+ "slides": {
20
+ "type": "array",
21
+ "description": "The slides in the deck. Items are bare slide objects or group wrappers.",
22
+ "items": {
23
+ "oneOf": [
24
+ {
25
+ "type": "object",
26
+ "description": "A single slide.",
27
+ "properties": {
28
+ "id": {
29
+ "type": "string",
30
+ "description": "Unique slide identifier, referenced by edges.",
31
+ },
32
+ "position": {
33
+ "type": "array",
34
+ "description": "Integer grid position [x, y]. x increases left-to-right, y increases top-to-bottom.",
35
+ "items": {"type": "integer"},
36
+ "minItems": 2,
37
+ "maxItems": 2,
38
+ },
39
+ "source": {
40
+ "type": "string",
41
+ "description": (
42
+ "Path to the slide source file, relative to the deck file. "
43
+ "The filename suffix determines the slide type "
44
+ "(e.g. .static.md, .scrollimation.json, .storyboard.json)."
45
+ ),
46
+ },
47
+ },
48
+ "required": ["id", "position", "source"],
49
+ "additionalProperties": False,
50
+ },
51
+ {
52
+ "type": "object",
53
+ "description": "A group wrapper containing slides. Rendered as a labelled rectangle in deck view.",
54
+ "properties": {
55
+ "group": {
56
+ "type": "string",
57
+ "description": "Group label, displayed above the group rectangle in deck view.",
58
+ },
59
+ "slides": {
60
+ "type": "array",
61
+ "description": "Slides in this group.",
62
+ "items": {
63
+ "type": "object",
64
+ "properties": {
65
+ "id": {"type": "string"},
66
+ "position": {
67
+ "type": "array",
68
+ "items": {"type": "integer"},
69
+ "minItems": 2,
70
+ "maxItems": 2,
71
+ },
72
+ "source": {"type": "string"},
73
+ },
74
+ "required": ["id", "position", "source"],
75
+ "additionalProperties": False,
76
+ },
77
+ "minItems": 1,
78
+ },
79
+ },
80
+ "required": ["group", "slides"],
81
+ "additionalProperties": False,
82
+ },
83
+ ],
84
+ },
85
+ },
86
+ "edges": {
87
+ "type": "array",
88
+ "description": "Navigation edges connecting slides.",
89
+ "items": {
90
+ "type": "array",
91
+ "description": (
92
+ "A two-element array of endpoint strings. "
93
+ 'Each endpoint is "slide_id" (side inferred from positions) '
94
+ 'or "slide_id|side" (side explicit: top, bottom, left, right).'
95
+ ),
96
+ "items": {"type": "string"},
97
+ "minItems": 2,
98
+ "maxItems": 2,
99
+ },
100
+ },
101
+ },
102
+ "required": ["slides"],
103
+ "additionalProperties": False,
104
+ }