scrolly 0.2.0__tar.gz → 0.2.2__tar.gz

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 (125) hide show
  1. scrolly-0.2.2/LICENSE +28 -0
  2. {scrolly-0.2.0 → scrolly-0.2.2}/PKG-INFO +7 -3
  3. {scrolly-0.2.0 → scrolly-0.2.2}/README.md +6 -2
  4. {scrolly-0.2.0 → scrolly-0.2.2}/pyproject.toml +16 -1
  5. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/_cli/_cli.py +69 -10
  6. scrolly-0.2.2/scrolly/_cli/_errors.py +54 -0
  7. scrolly-0.2.2/scrolly/_cli/_introspect/__init__.py +46 -0
  8. scrolly-0.2.2/scrolly/_cli/_introspect/_assets.py +43 -0
  9. scrolly-0.2.2/scrolly/_cli/_introspect/_common.py +136 -0
  10. scrolly-0.2.2/scrolly/_cli/_introspect/_dom.py +47 -0
  11. scrolly-0.2.2/scrolly/_cli/_introspect/_elements.py +43 -0
  12. scrolly-0.2.2/scrolly/_cli/_introspect/_slides.py +40 -0
  13. scrolly-0.2.2/scrolly/_cli/_introspect/_snaps.py +44 -0
  14. scrolly-0.2.2/scrolly/_cli/_introspect/_snapshot.py +64 -0
  15. scrolly-0.2.2/scrolly/_cli/_introspect/_timeline.py +46 -0
  16. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/inference.py +5 -2
  17. scrolly-0.2.2/scrolly/deck/introspect.py +75 -0
  18. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/parser.py +35 -23
  19. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/validator.py +21 -8
  20. scrolly-0.2.2/scrolly/errors/__init__.py +59 -0
  21. scrolly-0.2.2/scrolly/errors/_catalog.py +64 -0
  22. scrolly-0.2.2/scrolly/errors/_codes.py +54 -0
  23. scrolly-0.2.2/scrolly/errors/_report.py +41 -0
  24. scrolly-0.2.2/scrolly/errors/_validation_error.py +96 -0
  25. scrolly-0.2.2/scrolly/errors/catalog/E001.md +22 -0
  26. scrolly-0.2.2/scrolly/errors/catalog/E002.md +17 -0
  27. scrolly-0.2.2/scrolly/errors/catalog/E003.md +18 -0
  28. scrolly-0.2.2/scrolly/errors/catalog/E004.md +16 -0
  29. scrolly-0.2.2/scrolly/errors/catalog/E005.md +15 -0
  30. scrolly-0.2.2/scrolly/errors/catalog/E006.md +17 -0
  31. scrolly-0.2.2/scrolly/errors/catalog/E007.md +17 -0
  32. scrolly-0.2.2/scrolly/errors/catalog/E008.md +16 -0
  33. scrolly-0.2.2/scrolly/errors/catalog/E009.md +17 -0
  34. scrolly-0.2.2/scrolly/errors/catalog/E010.md +21 -0
  35. scrolly-0.2.2/scrolly/errors/catalog/E011.md +21 -0
  36. scrolly-0.2.2/scrolly/errors/catalog/E012.md +20 -0
  37. scrolly-0.2.2/scrolly/errors/catalog/E101.md +19 -0
  38. scrolly-0.2.2/scrolly/errors/catalog/E102.md +19 -0
  39. scrolly-0.2.2/scrolly/errors/catalog/E103.md +22 -0
  40. scrolly-0.2.2/scrolly/errors/catalog/E201.md +17 -0
  41. scrolly-0.2.2/scrolly/errors/catalog/E202.md +18 -0
  42. scrolly-0.2.2/scrolly/errors/catalog/E203.md +18 -0
  43. scrolly-0.2.2/scrolly/errors/catalog/E204.md +18 -0
  44. scrolly-0.2.2/scrolly/errors/catalog/E205.md +18 -0
  45. scrolly-0.2.2/scrolly/errors/catalog/E206.md +19 -0
  46. scrolly-0.2.2/scrolly/errors/catalog/E207.md +21 -0
  47. scrolly-0.2.2/scrolly/errors/catalog/E299.md +21 -0
  48. scrolly-0.2.2/scrolly/errors/catalog/E301.md +18 -0
  49. scrolly-0.2.2/scrolly/errors/catalog/E302.md +17 -0
  50. scrolly-0.2.2/scrolly/errors/catalog/E303.md +18 -0
  51. scrolly-0.2.2/scrolly/errors/catalog/E304.md +19 -0
  52. scrolly-0.2.2/scrolly/errors/catalog/E305.md +18 -0
  53. scrolly-0.2.2/scrolly/errors/catalog/E306.md +20 -0
  54. scrolly-0.2.2/scrolly/errors/catalog/E307.md +18 -0
  55. scrolly-0.2.2/scrolly/errors/catalog/E308.md +18 -0
  56. scrolly-0.2.2/scrolly/errors/catalog/E401.md +19 -0
  57. scrolly-0.2.2/scrolly/errors/catalog/E402.md +21 -0
  58. scrolly-0.2.2/scrolly/errors/catalog/E403.md +17 -0
  59. scrolly-0.2.2/scrolly/errors/catalog/E501.md +18 -0
  60. scrolly-0.2.2/scrolly/errors/catalog/E502.md +20 -0
  61. scrolly-0.2.2/scrolly/errors/catalog/E503.md +21 -0
  62. scrolly-0.2.2/scrolly/errors/catalog/E504.md +16 -0
  63. scrolly-0.2.2/scrolly/errors/catalog/E505.md +19 -0
  64. scrolly-0.2.2/scrolly/errors/catalog/E601.md +17 -0
  65. scrolly-0.2.2/scrolly/errors/catalog/E602.md +18 -0
  66. scrolly-0.2.2/scrolly/errors/catalog/E603.md +17 -0
  67. scrolly-0.2.2/scrolly/errors/catalog/E701.md +20 -0
  68. scrolly-0.2.2/scrolly/errors/catalog/E702.md +18 -0
  69. scrolly-0.2.2/scrolly/errors/catalog/__init__.py +31 -0
  70. scrolly-0.2.2/scrolly/pipeline/__init__.py +21 -0
  71. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/pipeline/assets.py +8 -5
  72. scrolly-0.2.2/scrolly/pipeline/introspect.py +90 -0
  73. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/pipeline/lint.py +2 -3
  74. scrolly-0.2.2/scrolly/pipeline/loader.py +61 -0
  75. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/pipeline/orchestrator.py +47 -40
  76. scrolly-0.2.2/scrolly/pipeline/writer.py +52 -0
  77. scrolly-0.2.2/scrolly/render/__init__.py +6 -0
  78. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/assembler.py +25 -6
  79. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/assets/canvas.css +42 -0
  80. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/assets/canvas.js +236 -123
  81. scrolly-0.2.0/LICENSE → scrolly-0.2.2/scrolly/render/assets/mermaid-LICENSE +2 -2
  82. scrolly-0.2.2/scrolly/render/assets/mermaid.min.js +3405 -0
  83. scrolly-0.2.2/scrolly/render/bundled_assets.py +147 -0
  84. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/registry.py +8 -2
  85. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/rendered.py +1 -1
  86. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/image_sequence.py +65 -51
  87. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/html.py +1 -1
  88. scrolly-0.2.2/scrolly/slide/introspect.py +429 -0
  89. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/_framework/animated_values.py +91 -8
  90. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/_framework/element.py +79 -27
  91. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/_framework/utils.py +14 -6
  92. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/slide.py +29 -11
  93. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/registry.py +4 -1
  94. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/renderers/slide.py +101 -29
  95. scrolly-0.2.0/scrolly/errors.py +0 -34
  96. scrolly-0.2.0/scrolly/pipeline/__init__.py +0 -6
  97. scrolly-0.2.0/scrolly/pipeline/writer.py +0 -38
  98. scrolly-0.2.0/scrolly/render/__init__.py +0 -6
  99. scrolly-0.2.0/scrolly/render/bundled_assets.py +0 -55
  100. {scrolly-0.2.0 → scrolly-0.2.2}/.gitignore +0 -0
  101. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/__init__.py +0 -0
  102. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/_cli/__init__.py +0 -0
  103. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/__init__.py +0 -0
  104. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/model.py +0 -0
  105. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/deck/schema.py +0 -0
  106. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/pipeline/_bundler.py +0 -0
  107. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/fan.py +0 -0
  108. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/nav_data.py +0 -0
  109. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/templates/index.html.j2 +0 -0
  110. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/render/zoom_control.py +0 -0
  111. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/__init__.py +0 -0
  112. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/__init__.py +0 -0
  113. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/ir.py +0 -0
  114. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/processor.py +0 -0
  115. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/__init__.py +0 -0
  116. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/_shared.py +0 -0
  117. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/html.py +0 -0
  118. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/iframe.py +0 -0
  119. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/image.py +0 -0
  120. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/markdown.py +0 -0
  121. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/element_ir/renderers/mermaid.py +0 -0
  122. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/__init__.py +0 -0
  123. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/ir/_framework/__init__.py +0 -0
  124. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/processor.py +0 -0
  125. {scrolly-0.2.0 → scrolly-0.2.2}/scrolly/slide/renderers/__init__.py +0 -0
scrolly-0.2.2/LICENSE ADDED
@@ -0,0 +1,28 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026, Bert Pluymers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ----
24
+
25
+ scrolly bundles third-party assets that ship under their own licenses,
26
+ located alongside the asset in the package tree. See for example
27
+ `scrolly/render/assets/mermaid-LICENSE` (MIT — Copyright (c) 2014-2022
28
+ Knut Sveidqvist).
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrolly
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: CLI that compiles a JSON5 deck + slide files into a self-contained 2D-canvas HTML presentation.
5
5
  Project-URL: Source, https://github.com/bertpl/scrolly
6
6
  Project-URL: Changelog, https://github.com/bertpl/scrolly/blob/main/CHANGELOG.md
@@ -33,6 +33,10 @@ Description-Content-Type: text/markdown
33
33
 
34
34
  Compile a JSON5 deck into a self-contained, scrollable 2D-canvas HTML presentation.
35
35
 
36
+ *Liked by humans; understood by agents.*
37
+
38
+ [![scrolly — scroll-driven 2D-canvas presentations](https://raw.githubusercontent.com/bertpl/scrolly/main/docs/assets/hero-v1.gif)](https://github.com/bertpl/scrolly)
39
+
36
40
  [![CI](https://img.shields.io/github/actions/workflow/status/bertpl/scrolly/push_to_main.yml?branch=main&label=CI)](https://github.com/bertpl/scrolly/actions/workflows/push_to_main.yml)
37
41
  [![PyPI](https://img.shields.io/pypi/v/scrolly.svg)](https://pypi.org/project/scrolly/)
38
42
  [![Python](https://img.shields.io/pypi/pyversions/scrolly.svg)](https://pypi.org/project/scrolly/)
@@ -53,11 +57,11 @@ uv tool install scrolly
53
57
  ## Quickstart
54
58
 
55
59
  ```bash
56
- scrolly build examples/worked-example/deck.deck.json --out /tmp/scrolly-out --force
60
+ scrolly build examples/stacked-diffs/deck.deck.json --out /tmp/scrolly-out --force
57
61
  open /tmp/scrolly-out/index.html
58
62
  ```
59
63
 
60
- See [`examples/worked-example/`](examples/worked-example/) for a reference deck.
64
+ See [`examples/stacked-diffs/`](examples/stacked-diffs/) for a complete example deck.
61
65
 
62
66
  ## Source format
63
67
 
@@ -2,6 +2,10 @@
2
2
 
3
3
  Compile a JSON5 deck into a self-contained, scrollable 2D-canvas HTML presentation.
4
4
 
5
+ *Liked by humans; understood by agents.*
6
+
7
+ [![scrolly — scroll-driven 2D-canvas presentations](https://raw.githubusercontent.com/bertpl/scrolly/main/docs/assets/hero-v1.gif)](https://github.com/bertpl/scrolly)
8
+
5
9
  [![CI](https://img.shields.io/github/actions/workflow/status/bertpl/scrolly/push_to_main.yml?branch=main&label=CI)](https://github.com/bertpl/scrolly/actions/workflows/push_to_main.yml)
6
10
  [![PyPI](https://img.shields.io/pypi/v/scrolly.svg)](https://pypi.org/project/scrolly/)
7
11
  [![Python](https://img.shields.io/pypi/pyversions/scrolly.svg)](https://pypi.org/project/scrolly/)
@@ -22,11 +26,11 @@ uv tool install scrolly
22
26
  ## Quickstart
23
27
 
24
28
  ```bash
25
- scrolly build examples/worked-example/deck.deck.json --out /tmp/scrolly-out --force
29
+ scrolly build examples/stacked-diffs/deck.deck.json --out /tmp/scrolly-out --force
26
30
  open /tmp/scrolly-out/index.html
27
31
  ```
28
32
 
29
- See [`examples/worked-example/`](examples/worked-example/) for a reference deck.
33
+ See [`examples/stacked-diffs/`](examples/stacked-diffs/) for a complete example deck.
30
34
 
31
35
  ## Source format
32
36
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrolly"
3
- version = "0.2.0"
3
+ version = "0.2.2"
4
4
  description = "CLI that compiles a JSON5 deck + slide files into a self-contained 2D-canvas HTML presentation."
5
5
  readme = "README.md"
6
6
  license = "MIT"
@@ -37,6 +37,17 @@ dev = [
37
37
  "pytest-cov>=6.0",
38
38
  "ruff>=0.14.0",
39
39
  "pre-commit>=4.0",
40
+ # Exact pin: gitsvg is pre-1.0 and its output schema can shift
41
+ # between releases. Used to regenerate the stacked-diffs example's
42
+ # gitsvg frames.
43
+ "gitsvg==0.2.2",
44
+ ]
45
+ # Optional group for the hero-animation capture pipeline (docs/_gen).
46
+ # Heavy (Playwright ships browser binaries via `make capture-setup`),
47
+ # so it's separate from `dev` — casual contributors don't pay the cost.
48
+ capture = [
49
+ "playwright>=1.40",
50
+ "pillow>=10.0",
40
51
  ]
41
52
 
42
53
  [project.scripts]
@@ -66,6 +77,10 @@ select = ["I"]
66
77
 
67
78
  [tool.pytest.ini_options]
68
79
  testpaths = ["tests/python"]
80
+ # The hero-animation engine lives under docs/_gen (dev-only tooling, not
81
+ # shipped in the wheel); put it on the path so its pure-logic unit tests
82
+ # can import it without the optional `capture` group installed.
83
+ pythonpath = ["docs/_gen"]
69
84
 
70
85
  [tool.coverage.run]
71
86
  source = ["scrolly"]
@@ -6,8 +6,10 @@ import click
6
6
  from rich.console import Console
7
7
 
8
8
  from scrolly import __version__
9
- from scrolly.errors import ScrollyError
10
- from scrolly.pipeline import build_deck, validate_deck_sources
9
+ from scrolly._cli._errors import errors_command
10
+ from scrolly._cli._introspect import introspect
11
+ from scrolly.errors import ScrollyError, ValidationError
12
+ from scrolly.pipeline import build_deck, load_deck
11
13
  from scrolly.pipeline.lint import lint_deck
12
14
 
13
15
  _err_console = Console(stderr=True, highlight=False)
@@ -41,6 +43,14 @@ def cli() -> None:
41
43
  is_flag=True,
42
44
  help="Disable gzip compression of inlined assets.",
43
45
  )
46
+ @click.option(
47
+ "--offline",
48
+ is_flag=True,
49
+ help=(
50
+ "Skip the mermaid CDN download and use the wheel-bundled mermaid for "
51
+ "byte-reproducibility. SCROLLY_OFFLINE=1 in the environment is equivalent."
52
+ ),
53
+ )
44
54
  def build(
45
55
  deck_path: Path,
46
56
  out_dir: Path,
@@ -49,6 +59,7 @@ def build(
49
59
  strict: bool,
50
60
  simplified_zoom_control: bool,
51
61
  no_compress: bool,
62
+ offline: bool,
52
63
  ) -> None:
53
64
  """Build a deck into a self-contained HTML presentation."""
54
65
  try:
@@ -59,6 +70,7 @@ def build(
59
70
  inline=not no_inline,
60
71
  simplified_zoom_control=simplified_zoom_control,
61
72
  compress=not no_compress,
73
+ offline=offline,
62
74
  )
63
75
  except ScrollyError as e:
64
76
  _err_console.print(f"[red]error:[/red] {e}")
@@ -72,12 +84,30 @@ def build(
72
84
 
73
85
  @cli.command()
74
86
  @click.argument("type_name", required=False)
75
- def schema(type_name: str | None) -> None:
76
- """Show source file schemas. Lists types when called without an argument."""
87
+ @click.option(
88
+ "--list-types",
89
+ "list_types",
90
+ is_flag=True,
91
+ help="Print bare type names one per line (no descriptions) for scripting use.",
92
+ )
93
+ def schema(type_name: str | None, list_types: bool) -> None:
94
+ """Show source file schemas.
95
+
96
+ \b
97
+ scrolly schema → formatted index of available types
98
+ scrolly schema <type> → JSON Schema for <type>
99
+ scrolly schema --list-types → bare type names, one per line (agent / scripting)
100
+ """
77
101
  from scrolly.deck import deck_source_schema
78
102
  from scrolly.slide import registered_ir_types
79
103
 
80
104
  ir_types = registered_ir_types()
105
+ all_type_names = sorted(["deck", *ir_types])
106
+
107
+ if list_types:
108
+ for name in all_type_names:
109
+ click.echo(name)
110
+ return
81
111
 
82
112
  if type_name is None:
83
113
  click.echo("Available schemas:\n")
@@ -92,8 +122,7 @@ def schema(type_name: str | None) -> None:
92
122
  return
93
123
 
94
124
  if type_name not in ir_types:
95
- known = ", ".join(sorted(["deck", *ir_types]))
96
- _err_console.print(f"[red]error:[/red] unknown type '{type_name}' (known: {known})")
125
+ _err_console.print(f"[red]error:[/red] unknown type '{type_name}' (known: {', '.join(all_type_names)})")
97
126
  sys.exit(1)
98
127
 
99
128
  click.echo(json.dumps(ir_types[type_name].source_schema(), indent=2))
@@ -102,18 +131,44 @@ def schema(type_name: str | None) -> None:
102
131
  @cli.command()
103
132
  @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
104
133
  @click.option("--strict", is_flag=True, help="Enable additional lint checks (e.g. out-of-range keyframes).")
105
- def validate(deck_path: Path, strict: bool) -> None:
134
+ @click.option(
135
+ "--json",
136
+ "as_json",
137
+ is_flag=True,
138
+ help='Emit machine-readable JSON instead of text: {"ok": bool, "errors": [...]}.',
139
+ )
140
+ def validate(deck_path: Path, strict: bool, as_json: bool) -> None:
106
141
  """Validate a deck and all its slide sources without building."""
107
142
  try:
108
- deck = validate_deck_sources(deck_path)
143
+ deck, _ = load_deck(deck_path)
109
144
  except ScrollyError as e:
110
- _err_console.print(f"[red]error:[/red] {e}")
145
+ if as_json:
146
+ click.echo(json.dumps({"ok": False, "errors": [_error_to_dict(e)]}, indent=2))
147
+ else:
148
+ _err_console.print(f"[red]error:[/red] {e}")
111
149
  sys.exit(1)
112
150
 
113
151
  if strict:
114
152
  _report_diagnostics(deck)
115
153
 
116
- click.echo(f"Valid: {len(deck.slides)} slides, {len(deck.edges)} edges")
154
+ if as_json:
155
+ click.echo(json.dumps({"ok": True, "errors": []}, indent=2))
156
+ else:
157
+ click.echo(f"Valid: {len(deck.slides)} slides, {len(deck.edges)} edges")
158
+
159
+
160
+ def _error_to_dict(err: ScrollyError) -> dict:
161
+ """Serialise a ``ScrollyError`` for JSON output."""
162
+ if isinstance(err, ValidationError):
163
+ return {
164
+ "code": err.code,
165
+ "message": err.message,
166
+ "file": err.file,
167
+ "line": err.line,
168
+ "field": err.field,
169
+ "suggestion": err.suggestion,
170
+ }
171
+ return {"code": None, "message": str(err)}
117
172
 
118
173
 
119
174
  def _report_diagnostics(deck) -> None:
@@ -158,3 +213,7 @@ def init(dir_path: Path) -> None:
158
213
  (slides_dir / "intro.slide.json").write_text(_INIT_SLIDE)
159
214
 
160
215
  click.echo(f"Created deck in {dir_path}")
216
+
217
+
218
+ cli.add_command(errors_command)
219
+ cli.add_command(introspect)
@@ -0,0 +1,54 @@
1
+ """``scrolly errors`` — look up registered error codes from the CLI.
2
+
3
+ Three forms, mirroring the progressive-disclosure pattern used by
4
+ ``scrolly schema``:
5
+
6
+ * ``scrolly errors`` — formatted index of all codes with summaries.
7
+ * ``scrolly errors <code>`` — long-form catalog entry for ``<code>``.
8
+ * ``scrolly errors --list-codes`` — bare codes, one per line (scripting use).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+
15
+ import click
16
+ from rich.console import Console
17
+
18
+ from scrolly.errors import is_registered_code, registered_codes
19
+ from scrolly.errors._catalog import load_body, load_summary
20
+
21
+ _err_console = Console(stderr=True, highlight=False)
22
+
23
+
24
+ @click.command(name="errors")
25
+ @click.argument("code", required=False)
26
+ @click.option(
27
+ "--list-codes",
28
+ "list_codes",
29
+ is_flag=True,
30
+ help="Print bare codes one per line (no summaries) for scripting use.",
31
+ )
32
+ def errors_command(code: str | None, list_codes: bool) -> None:
33
+ """Look up registered error codes.
34
+
35
+ \b
36
+ scrolly errors → formatted index of all codes with summaries
37
+ scrolly errors <code> → long-form catalog entry for <code>
38
+ scrolly errors --list-codes → bare codes, one per line (agent / scripting)
39
+ """
40
+ if list_codes:
41
+ for c in sorted(registered_codes()):
42
+ click.echo(c)
43
+ return
44
+
45
+ if code is None:
46
+ for c in sorted(registered_codes()):
47
+ click.echo(f" {c:<6} {load_summary(c)}")
48
+ return
49
+
50
+ if not is_registered_code(code):
51
+ _err_console.print(f"[red]error:[/red] unknown error code '{code}'")
52
+ sys.exit(1)
53
+
54
+ click.echo(load_body(code))
@@ -0,0 +1,46 @@
1
+ """``scrolly introspect <sub>`` — build-time introspection of a resolved deck.
2
+
3
+ Each subcommand surfaces some aspect of what the renderer / browser will
4
+ see, scoped to the agent's biggest blind spot: visual output is unreadable
5
+ to a non-browser consumer, so introspection commands close the loop by
6
+ returning structured JSON describing the deck's resolved state.
7
+
8
+ Common conventions enforced via ``_common.run_introspect_command``:
9
+
10
+ * **JSON-only output**, default to stdout, ``-o PATH`` for a file
11
+ destination.
12
+ * **Validation gate first** — broken decks emit error messages to stderr
13
+ and exit non-zero with no JSON, so consumers never parse stale state.
14
+ * **``--slide <id>``** (repeatable) on subcommands that produce per-slide
15
+ output, restricting the result to the named slides. Unknown ids exit
16
+ non-zero with a clear message.
17
+
18
+ Output format may change before scrolly v1.0 — pin a version when caching
19
+ the schema.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import click
25
+
26
+ from scrolly._cli._introspect._assets import assets_command
27
+ from scrolly._cli._introspect._dom import dom_command
28
+ from scrolly._cli._introspect._elements import elements_command
29
+ from scrolly._cli._introspect._slides import slides_command
30
+ from scrolly._cli._introspect._snaps import snaps_command
31
+ from scrolly._cli._introspect._snapshot import snapshot_command
32
+ from scrolly._cli._introspect._timeline import timeline_command
33
+
34
+
35
+ @click.group(name="introspect")
36
+ def introspect() -> None:
37
+ """Inspect a resolved deck — JSON-only views for downstream consumers."""
38
+
39
+
40
+ introspect.add_command(slides_command)
41
+ introspect.add_command(elements_command)
42
+ introspect.add_command(snaps_command)
43
+ introspect.add_command(timeline_command)
44
+ introspect.add_command(snapshot_command)
45
+ introspect.add_command(dom_command)
46
+ introspect.add_command(assets_command)
@@ -0,0 +1,43 @@
1
+ """``scrolly introspect assets`` — per-asset metadata + slide references."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from scrolly._cli._introspect._common import run_introspect_command
10
+ from scrolly.pipeline.introspect import assets_to_json
11
+
12
+
13
+ @click.command(name="assets")
14
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
15
+ @click.option(
16
+ "--slide",
17
+ "slide_ids",
18
+ multiple=True,
19
+ help="Restrict the asset walk to elements within the named slide(s). Repeatable.",
20
+ )
21
+ @click.option(
22
+ "--output",
23
+ "-o",
24
+ "output_path",
25
+ type=click.Path(dir_okay=False, path_type=Path),
26
+ help="Write JSON to this file instead of stdout.",
27
+ )
28
+ def assets_command(deck_path: Path, slide_ids: tuple[str, ...], output_path: Path | None) -> None:
29
+ """Asset table — declared assets, per-slide references, byte sizes, mime types.
30
+
31
+ Walks the resolved slide IRs for ``ImageElement`` / ``ImageSequenceElement``
32
+ references. Each entry reports absolute path, name, size, mime, exists
33
+ flag, and the slides that reference it.
34
+
35
+ Output format may change before scrolly v1.0 — pin a version when
36
+ caching the schema.
37
+ """
38
+ run_introspect_command(
39
+ deck_path,
40
+ slide_ids=slide_ids,
41
+ output_path=output_path,
42
+ to_json_fn=assets_to_json,
43
+ )
@@ -0,0 +1,136 @@
1
+ """Shared machinery for the ``scrolly introspect`` subcommands.
2
+
3
+ Every subcommand goes through ``run_introspect_command``, which:
4
+
5
+ 1. Loads the deck through the shared validation gate (``load_deck``).
6
+ 2. On error, prints to stderr and exits non-zero with no JSON.
7
+ 3. If ``slide_ids`` is non-empty, validates each id against the
8
+ resolved slide list and exits with a clear message on unknown ids.
9
+ 4. Calls the supplied ``to_json_fn`` to produce the payload.
10
+ 5. Writes to ``output_path`` if given, otherwise stdout.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import sys
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ import click
21
+ from rich.console import Console
22
+
23
+ from scrolly.deck.model import Deck
24
+ from scrolly.errors import ScrollyError
25
+ from scrolly.pipeline import load_deck
26
+ from scrolly.slide.ir import SlideIR
27
+
28
+ _err_console = Console(stderr=True, highlight=False)
29
+
30
+ ToJsonFn = Callable[[Deck, dict[str, SlideIR], tuple[str, ...] | None], dict]
31
+
32
+
33
+ def run_introspect_command(
34
+ deck_path: Path,
35
+ slide_ids: tuple[str, ...],
36
+ output_path: Path | None,
37
+ *,
38
+ to_json_fn: ToJsonFn,
39
+ ) -> None:
40
+ """Run an introspect subcommand end-to-end: load → filter → serialize → output.
41
+
42
+ Args:
43
+ deck_path: Path to the ``.deck.json`` file.
44
+ slide_ids: Tuple of slide ids to filter to; empty tuple = no filter.
45
+ output_path: Optional file destination; ``None`` writes to stdout.
46
+ to_json_fn: Domain helper that produces the JSON-ready dict.
47
+
48
+ Raises:
49
+ SystemExit: Non-zero exit on validation gate failure or unknown
50
+ ``--slide`` ids; the error message goes to stderr.
51
+ """
52
+ try:
53
+ deck, slide_irs = load_deck(deck_path)
54
+ except ScrollyError as e:
55
+ _err_console.print(f"[red]error:[/red] {e}")
56
+ sys.exit(1)
57
+
58
+ if slide_ids:
59
+ known = {s.id for s in deck.slides}
60
+ unknown = [sid for sid in slide_ids if sid not in known]
61
+ if unknown:
62
+ _err_console.print(
63
+ f"[red]error:[/red] unknown slide id(s): {', '.join(unknown)}. Known: {', '.join(sorted(known))}"
64
+ )
65
+ sys.exit(1)
66
+
67
+ payload = to_json_fn(deck, slide_irs, slide_ids or None)
68
+ rendered = json.dumps(payload, indent=2)
69
+
70
+ if output_path is not None:
71
+ output_path.write_text(rendered, encoding="utf-8")
72
+ else:
73
+ click.echo(rendered)
74
+
75
+
76
+ def run_snapshot_command(
77
+ deck_path: Path,
78
+ slide_id: str,
79
+ scrolls: tuple[float, ...],
80
+ output_path: Path | None,
81
+ ) -> None:
82
+ """Run the snapshot subcommand: load → validate slide_id + scrolls → snapshot → output.
83
+
84
+ Snapshot differs from the other introspect commands in two ways:
85
+ ``--slide`` is mandatory and single-valued (scroll positions are
86
+ slide-local, so multi-slide queries are ambiguous), and ``--scroll N``
87
+ is mandatory and repeatable. This helper enforces both invariants
88
+ plus the per-slide scroll-range validation: any ``--scroll`` outside
89
+ ``[0, scroll_range]`` for numeric ``scroll_range``, or below 0 for
90
+ ``"auto"`` slides, rejects the whole invocation with a clear message
91
+ rather than silently clamping or extrapolating beyond what the
92
+ browser can physically reach.
93
+
94
+ Args:
95
+ deck_path: Path to the ``.deck.json`` file.
96
+ slide_id: Slide to snapshot (mandatory, single).
97
+ scrolls: Tuple of scroll positions (mandatory, non-empty).
98
+ output_path: Optional file destination; ``None`` writes to stdout.
99
+
100
+ Raises:
101
+ SystemExit: Non-zero exit on validation gate failure, unknown
102
+ ``slide_id``, or any out-of-range scroll value.
103
+ """
104
+ from scrolly.slide.introspect import snapshot_to_json
105
+
106
+ try:
107
+ deck, slide_irs = load_deck(deck_path)
108
+ except ScrollyError as e:
109
+ _err_console.print(f"[red]error:[/red] {e}")
110
+ sys.exit(1)
111
+
112
+ known = {s.id for s in deck.slides}
113
+ if slide_id not in known:
114
+ _err_console.print(f"[red]error:[/red] unknown slide id: '{slide_id}'. Known: {', '.join(sorted(known))}")
115
+ sys.exit(1)
116
+
117
+ ir = slide_irs[slide_id]
118
+ scroll_range = ir.scroll_range
119
+ invalid: list[tuple[float, str]] = []
120
+ for scroll in scrolls:
121
+ if scroll < 0:
122
+ invalid.append((scroll, "negative scroll values are never reachable"))
123
+ elif isinstance(scroll_range, (int, float)) and scroll > scroll_range:
124
+ invalid.append((scroll, f"exceeds slide's scroll_range ({scroll_range})"))
125
+ if invalid:
126
+ lines = "\n".join(f" scroll={s}: {reason}" for s, reason in invalid)
127
+ _err_console.print(f"[red]error:[/red] --scroll out-of-range for slide '{slide_id}':\n{lines}")
128
+ sys.exit(1)
129
+
130
+ payload = snapshot_to_json(deck, slide_irs, slide_id, scrolls)
131
+ rendered = json.dumps(payload, indent=2)
132
+
133
+ if output_path is not None:
134
+ output_path.write_text(rendered, encoding="utf-8")
135
+ else:
136
+ click.echo(rendered)
@@ -0,0 +1,47 @@
1
+ """``scrolly introspect dom`` — rendered HTML + scoped CSS per element."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from scrolly._cli._introspect._common import run_introspect_command
10
+ from scrolly.slide.introspect import dom_to_json
11
+
12
+
13
+ @click.command(name="dom")
14
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
15
+ @click.option(
16
+ "--slide",
17
+ "slide_ids",
18
+ multiple=True,
19
+ help=(
20
+ "Restrict output to the named slide id(s). Repeatable. Default = all slides — "
21
+ "but unfiltered output can be hundreds of KB; the filter is almost mandatory in practice."
22
+ ),
23
+ )
24
+ @click.option(
25
+ "--output",
26
+ "-o",
27
+ "output_path",
28
+ type=click.Path(dir_okay=False, path_type=Path),
29
+ help="Write JSON to this file instead of stdout.",
30
+ )
31
+ def dom_command(deck_path: Path, slide_ids: tuple[str, ...], output_path: Path | None) -> None:
32
+ """Rendered HTML + scoped CSS per element, sans deck-level chrome.
33
+
34
+ Answers "what does my config actually become" by running each
35
+ slide's element renderers and surfacing the per-element pieces —
36
+ HTML fragment and CSS rules — directly. No canvas runtime, no
37
+ scrollbar, no edge geometry, no inter-slide chrome.
38
+
39
+ Output format may change before scrolly v1.0 — pin a version when
40
+ caching the schema.
41
+ """
42
+ run_introspect_command(
43
+ deck_path,
44
+ slide_ids=slide_ids,
45
+ output_path=output_path,
46
+ to_json_fn=dom_to_json,
47
+ )
@@ -0,0 +1,43 @@
1
+ """``scrolly introspect elements`` — fully-resolved per-slide element tree."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from scrolly._cli._introspect._common import run_introspect_command
10
+ from scrolly.slide.introspect import element_tree_to_json
11
+
12
+
13
+ @click.command(name="elements")
14
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
15
+ @click.option(
16
+ "--slide",
17
+ "slide_ids",
18
+ multiple=True,
19
+ help="Restrict output to the named slide id(s). Repeatable. Default = all slides.",
20
+ )
21
+ @click.option(
22
+ "--output",
23
+ "-o",
24
+ "output_path",
25
+ type=click.Path(dir_okay=False, path_type=Path),
26
+ help="Write JSON to this file instead of stdout.",
27
+ )
28
+ def elements_command(deck_path: Path, slide_ids: tuple[str, ...], output_path: Path | None) -> None:
29
+ """Fully-resolved element tree per slide.
30
+
31
+ Defaults are filled in, ``*_file`` fields are inlined, asset paths
32
+ are absolute. Animated properties surface as their keyframe lists;
33
+ static properties surface as their values.
34
+
35
+ Output format may change before scrolly v1.0 — pin a version when
36
+ caching the schema.
37
+ """
38
+ run_introspect_command(
39
+ deck_path,
40
+ slide_ids=slide_ids,
41
+ output_path=output_path,
42
+ to_json_fn=element_tree_to_json,
43
+ )
@@ -0,0 +1,40 @@
1
+ """``scrolly introspect slides`` — deck topology view."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import click
8
+
9
+ from scrolly._cli._introspect._common import run_introspect_command
10
+ from scrolly.deck.introspect import slides_to_json
11
+
12
+
13
+ @click.command(name="slides")
14
+ @click.argument("deck_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
15
+ @click.option(
16
+ "--output",
17
+ "-o",
18
+ "output_path",
19
+ type=click.Path(dir_okay=False, path_type=Path),
20
+ help="Write JSON to this file instead of stdout.",
21
+ )
22
+ def slides_command(deck_path: Path, output_path: Path | None) -> None:
23
+ """Deck topology — slides, edges, groups, geometry.
24
+
25
+ Returns a deck-wide overview: every slide with its grid coord, title,
26
+ resolved scroll_range, element + snap counts; every edge with
27
+ fully-specified sides; every group with members and color.
28
+
29
+ No ``--slide`` filter — the value of this view is the relationships
30
+ between slides, which filtering would destroy.
31
+
32
+ Output format may change before scrolly v1.0 — pin a version when
33
+ caching the schema.
34
+ """
35
+ run_introspect_command(
36
+ deck_path,
37
+ slide_ids=(),
38
+ output_path=output_path,
39
+ to_json_fn=slides_to_json,
40
+ )