scrolly 0.2.1__tar.gz → 0.2.3__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 (124) hide show
  1. scrolly-0.2.3/LICENSE +28 -0
  2. {scrolly-0.2.1 → scrolly-0.2.3}/PKG-INFO +7 -3
  3. {scrolly-0.2.1 → scrolly-0.2.3}/README.md +6 -2
  4. {scrolly-0.2.1 → scrolly-0.2.3}/pyproject.toml +16 -1
  5. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_cli.py +10 -0
  6. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/introspect.py +2 -1
  7. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/model.py +1 -0
  8. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/parser.py +34 -11
  9. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/schema.py +11 -0
  10. scrolly-0.2.3/scrolly/errors/catalog/E306.md +20 -0
  11. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/lint.py +2 -3
  12. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/orchestrator.py +31 -3
  13. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/writer.py +18 -7
  14. scrolly-0.2.3/scrolly/render/__init__.py +6 -0
  15. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/assembler.py +25 -6
  16. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/assets/canvas.css +90 -4
  17. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/assets/canvas.js +280 -130
  18. scrolly-0.2.1/LICENSE → scrolly-0.2.3/scrolly/render/assets/mermaid-LICENSE +2 -2
  19. scrolly-0.2.3/scrolly/render/assets/mermaid.min.js +3405 -0
  20. scrolly-0.2.3/scrolly/render/bundled_assets.py +147 -0
  21. scrolly-0.2.3/scrolly/render/color.py +66 -0
  22. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/nav_data.py +15 -4
  23. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/templates/index.html.j2 +12 -2
  24. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/rendered.py +1 -1
  25. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/image_sequence.py +65 -51
  26. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/html.py +1 -1
  27. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/introspect.py +6 -5
  28. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/_framework/element.py +41 -29
  29. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/renderers/slide.py +18 -1
  30. scrolly-0.2.1/scrolly/errors/catalog/E306.md +0 -20
  31. scrolly-0.2.1/scrolly/render/__init__.py +0 -6
  32. scrolly-0.2.1/scrolly/render/bundled_assets.py +0 -55
  33. {scrolly-0.2.1 → scrolly-0.2.3}/.gitignore +0 -0
  34. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/__init__.py +0 -0
  35. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/__init__.py +0 -0
  36. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_errors.py +0 -0
  37. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/__init__.py +0 -0
  38. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_assets.py +0 -0
  39. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_common.py +0 -0
  40. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_dom.py +0 -0
  41. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_elements.py +0 -0
  42. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_slides.py +0 -0
  43. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_snaps.py +0 -0
  44. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_snapshot.py +0 -0
  45. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/_cli/_introspect/_timeline.py +0 -0
  46. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/__init__.py +0 -0
  47. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/inference.py +0 -0
  48. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/deck/validator.py +0 -0
  49. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/__init__.py +0 -0
  50. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/_catalog.py +0 -0
  51. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/_codes.py +0 -0
  52. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/_report.py +0 -0
  53. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/_validation_error.py +0 -0
  54. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E001.md +0 -0
  55. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E002.md +0 -0
  56. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E003.md +0 -0
  57. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E004.md +0 -0
  58. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E005.md +0 -0
  59. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E006.md +0 -0
  60. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E007.md +0 -0
  61. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E008.md +0 -0
  62. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E009.md +0 -0
  63. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E010.md +0 -0
  64. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E011.md +0 -0
  65. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E012.md +0 -0
  66. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E101.md +0 -0
  67. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E102.md +0 -0
  68. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E103.md +0 -0
  69. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E201.md +0 -0
  70. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E202.md +0 -0
  71. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E203.md +0 -0
  72. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E204.md +0 -0
  73. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E205.md +0 -0
  74. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E206.md +0 -0
  75. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E207.md +0 -0
  76. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E299.md +0 -0
  77. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E301.md +0 -0
  78. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E302.md +0 -0
  79. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E303.md +0 -0
  80. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E304.md +0 -0
  81. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E305.md +0 -0
  82. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E307.md +0 -0
  83. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E308.md +0 -0
  84. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E401.md +0 -0
  85. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E402.md +0 -0
  86. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E403.md +0 -0
  87. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E501.md +0 -0
  88. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E502.md +0 -0
  89. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E503.md +0 -0
  90. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E504.md +0 -0
  91. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E505.md +0 -0
  92. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E601.md +0 -0
  93. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E602.md +0 -0
  94. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E603.md +0 -0
  95. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E701.md +0 -0
  96. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/E702.md +0 -0
  97. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/errors/catalog/__init__.py +0 -0
  98. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/__init__.py +0 -0
  99. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/_bundler.py +0 -0
  100. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/assets.py +0 -0
  101. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/introspect.py +0 -0
  102. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/pipeline/loader.py +0 -0
  103. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/fan.py +0 -0
  104. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/render/zoom_control.py +0 -0
  105. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/__init__.py +0 -0
  106. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/__init__.py +0 -0
  107. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/ir.py +0 -0
  108. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/processor.py +0 -0
  109. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/registry.py +0 -0
  110. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/__init__.py +0 -0
  111. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/_shared.py +0 -0
  112. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/html.py +0 -0
  113. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/iframe.py +0 -0
  114. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/image.py +0 -0
  115. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/markdown.py +0 -0
  116. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/mermaid.py +0 -0
  117. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/__init__.py +0 -0
  118. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/_framework/__init__.py +0 -0
  119. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/_framework/animated_values.py +0 -0
  120. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/_framework/utils.py +0 -0
  121. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/ir/slide.py +0 -0
  122. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/processor.py +0 -0
  123. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/registry.py +0 -0
  124. {scrolly-0.2.1 → scrolly-0.2.3}/scrolly/slide/renderers/__init__.py +0 -0
scrolly-0.2.3/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.1
3
+ Version: 0.2.3
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-v2.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-v2.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.1"
3
+ version = "0.2.3"
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.3",
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"]
@@ -43,6 +43,14 @@ def cli() -> None:
43
43
  is_flag=True,
44
44
  help="Disable gzip compression of inlined assets.",
45
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
+ )
46
54
  def build(
47
55
  deck_path: Path,
48
56
  out_dir: Path,
@@ -51,6 +59,7 @@ def build(
51
59
  strict: bool,
52
60
  simplified_zoom_control: bool,
53
61
  no_compress: bool,
62
+ offline: bool,
54
63
  ) -> None:
55
64
  """Build a deck into a self-contained HTML presentation."""
56
65
  try:
@@ -61,6 +70,7 @@ def build(
61
70
  inline=not no_inline,
62
71
  simplified_zoom_control=simplified_zoom_control,
63
72
  compress=not no_compress,
73
+ offline=offline,
64
74
  )
65
75
  except ScrollyError as e:
66
76
  _err_console.print(f"[red]error:[/red] {e}")
@@ -35,7 +35,7 @@ def slides_to_json(
35
35
  ``slides``: map of slide id → {position, title, scroll_range,
36
36
  element_count, snap_position_count}.
37
37
  ``edges``: list of {a: {slide, side}, b: {slide, side}}.
38
- ``groups``: list of {label, color, slide_ids}.
38
+ ``groups``: list of {label, color, label_color, slide_ids}.
39
39
  """
40
40
  del slide_ids # always deck-wide; param exists for signature uniformity
41
41
 
@@ -63,6 +63,7 @@ def slides_to_json(
63
63
  {
64
64
  "label": group.label,
65
65
  "color": group.color,
66
+ "label_color": group.label_color,
66
67
  "slide_ids": list(group.slide_ids),
67
68
  }
68
69
  for group in deck.groups
@@ -82,6 +82,7 @@ class SlideGroup:
82
82
  label: str
83
83
  slide_ids: tuple[str, ...]
84
84
  color: str | None = None
85
+ label_color: str | None = None
85
86
 
86
87
 
87
88
  @dataclass(frozen=True)
@@ -79,8 +79,16 @@ def _parse_slides_and_groups(slides_raw: list, deck_path: Path) -> tuple[tuple[S
79
79
  group_slide_ids.append(slide.id)
80
80
  flat_idx += 1
81
81
 
82
- color = _parse_group_color(item, ctx)
83
- groups.append(SlideGroup(label=label, slide_ids=tuple(group_slide_ids), color=color))
82
+ color = _parse_group_hex_color(item, ctx, "color")
83
+ label_color = _parse_group_hex_color(item, ctx, "label_color")
84
+ groups.append(
85
+ SlideGroup(
86
+ label=label,
87
+ slide_ids=tuple(group_slide_ids),
88
+ color=color,
89
+ label_color=label_color,
90
+ )
91
+ )
84
92
  else:
85
93
  slide = _parse_slide(item, deck_dir, flat_idx, ctx)
86
94
  slides.append(slide)
@@ -92,16 +100,31 @@ def _parse_slides_and_groups(slides_raw: list, deck_path: Path) -> tuple[tuple[S
92
100
  _HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
93
101
 
94
102
 
95
- def _parse_group_color(raw: dict, ctx: str) -> str | None:
96
- """Parse and validate an optional hex color from a group object."""
97
- if "color" not in raw:
103
+ def _parse_group_hex_color(raw: dict, ctx: str, key: str) -> str | None:
104
+ """Parse and validate an optional hex color field from a group object.
105
+
106
+ Args:
107
+ raw: The raw group object.
108
+ ctx: Error-context prefix (e.g. ``slides[2]``).
109
+ key: Which field to read — ``color`` (background) or ``label_color``
110
+ (label override).
111
+
112
+ Returns:
113
+ The validated ``#RGB`` / ``#RRGGBB`` string, or ``None`` if the field
114
+ is absent.
115
+
116
+ Raises:
117
+ DeckParseError: If present but not a string (E004) or not a valid hex
118
+ color (E009).
119
+ """
120
+ if key not in raw:
98
121
  return None
99
- color = raw["color"]
100
- if not isinstance(color, str):
101
- raise DeckParseError(code="E004", message=f"{ctx}: 'color' must be a string, got {type(color).__name__}")
102
- if not _HEX_COLOR_RE.match(color):
103
- raise DeckParseError(code="E009", message=f"{ctx}: 'color' must be #RGB or #RRGGBB, got '{color}'")
104
- return color
122
+ value = raw[key]
123
+ if not isinstance(value, str):
124
+ raise DeckParseError(code="E004", message=f"{ctx}: '{key}' must be a string, got {type(value).__name__}")
125
+ if not _HEX_COLOR_RE.match(value):
126
+ raise DeckParseError(code="E009", message=f"{ctx}: '{key}' must be #RGB or #RRGGBB, got '{value}'")
127
+ return value
105
128
 
106
129
 
107
130
  def _require_list(d: dict, key: str, deck_path: Path) -> list:
@@ -55,6 +55,17 @@ def deck_source_schema() -> dict:
55
55
  "type": "string",
56
56
  "description": "Group label, displayed above the group rectangle in deck view.",
57
57
  },
58
+ "color": {
59
+ "type": "string",
60
+ "description": "Group background fill as #RGB or #RRGGBB. Defaults to a neutral gray.",
61
+ },
62
+ "label_color": {
63
+ "type": "string",
64
+ "description": (
65
+ "Group label color as #RGB or #RRGGBB. Defaults to a "
66
+ "black-or-white auto-contrast pick against the background."
67
+ ),
68
+ },
58
69
  "slides": {
59
70
  "type": "array",
60
71
  "description": "Slides in this group.",
@@ -0,0 +1,20 @@
1
+ # E306 - image_sequence timing constraint violated
2
+
3
+ ## Cause
4
+
5
+ One of the timing constraints on `image_sequence` is violated:
6
+ `frame_distance` must be `> 0`; `hold_fraction` must be in `[0, 1)` (so the
7
+ crossfade `frame_distance * (1 - hold_fraction)` stays positive); `fade_in`
8
+ and `fade_out` must be `>= 0`.
9
+
10
+ ## Example
11
+
12
+ ```json5
13
+ { image_sequence: ["a.png", "b.png"], frame_distance: 400, hold_fraction: 1 }
14
+ // hold_fraction = 1 leaves no room to crossfade
15
+ ```
16
+
17
+ ## How to fix
18
+
19
+ Set `frame_distance > 0`, `0 <= hold_fraction < 1`, and `fade_in,
20
+ fade_out >= 0`. `hold_fraction` defaults to `0.2`, so it can be omitted.
@@ -145,9 +145,8 @@ def _check_image_sequence(
145
145
  diagnostics: list[Diagnostic],
146
146
  ) -> None:
147
147
  """Check the auto-generated opacity keyframes for an image sequence element."""
148
- n = len(el.image_sequence)
149
- timeline_start = el.scroll_offset - el.fade_in
150
- timeline_end = el.scroll_offset + (n - 1) * el.frame_distance + el.hold + el.fade_out
148
+ timeline_start = el.timeline_start()
149
+ timeline_end = el.timeline_end()
151
150
  if timeline_start < 0:
152
151
  diagnostics.append(
153
152
  Diagnostic(
@@ -12,6 +12,7 @@ from scrolly.pipeline.assets import copy_assets, rewrite_asset_refs
12
12
  from scrolly.pipeline.loader import load_deck
13
13
  from scrolly.pipeline.writer import write_output
14
14
  from scrolly.render.assembler import assemble
15
+ from scrolly.render.bundled_assets import MermaidAsset, mermaid_asset
15
16
  from scrolly.slide.html import SlideHTML
16
17
  from scrolly.slide.ir import SlideIR
17
18
  from scrolly.slide.registry import find_renderer
@@ -25,8 +26,28 @@ def build_deck(
25
26
  inline: bool = True,
26
27
  simplified_zoom_control: bool = False,
27
28
  compress: bool = True,
29
+ offline: bool = False,
28
30
  ) -> Deck:
29
- """Build a deck from `deck_path` into `out_dir`. Returns the fully-resolved `Deck`."""
31
+ """Build a deck from `deck_path` into `out_dir`. Returns the fully-resolved `Deck`.
32
+
33
+ Args:
34
+ deck_path: Path to the ``.deck.json`` source.
35
+ out_dir: Destination directory for ``index.html`` (and bundled
36
+ assets when ``inline=False``).
37
+ force: Allow overwriting a non-empty ``out_dir``.
38
+ inline: Inline CSS/JS/mermaid into ``index.html`` (default) vs.
39
+ emit separate files.
40
+ simplified_zoom_control: Use the legacy single-icon zoom-out
41
+ control instead of the default deck mini-map.
42
+ compress: Emit the combined-payload gzip+base64 bundle when the
43
+ ≥5% savings gate passes.
44
+ offline: Skip the mermaid CDN download and use the
45
+ wheel-bundled mermaid instead. Honored together with the
46
+ ``SCROLLY_OFFLINE`` environment variable.
47
+
48
+ Returns:
49
+ The fully-resolved ``Deck``.
50
+ """
30
51
  deck, slide_irs = load_deck(deck_path)
31
52
 
32
53
  # Bundler is the canonical "compressible payload tracker" whenever
@@ -56,6 +77,13 @@ def build_deck(
56
77
  if fallback:
57
78
  chunks = _substitute_fallback(chunks, fallback)
58
79
 
80
+ # Resolve mermaid once when any chunk needs it — threaded into both
81
+ # assemble (for inlined content + help-screen version) and write_output
82
+ # (for the standalone-file emission under `inline=False`).
83
+ mermaid: MermaidAsset | None = None
84
+ if any(chunk.has_mermaid for chunk in chunks.values()):
85
+ mermaid = mermaid_asset(offline=offline)
86
+
59
87
  html = assemble(
60
88
  deck,
61
89
  chunks,
@@ -63,10 +91,10 @@ def build_deck(
63
91
  simplified_zoom_control=simplified_zoom_control,
64
92
  compressed_payload_json=compressed_payload_json,
65
93
  bundle_stats=bundle_stats,
94
+ mermaid=mermaid,
66
95
  )
67
- has_mermaid = any(chunk.has_mermaid for chunk in chunks.values())
68
96
 
69
- write_output(out_dir, html, force=force, has_mermaid=has_mermaid, inline=inline)
97
+ write_output(out_dir, html, force=force, mermaid=mermaid, inline=inline)
70
98
  if not inline:
71
99
  copy_assets(chunks, out_dir)
72
100
 
@@ -5,7 +5,7 @@ from __future__ import annotations
5
5
  from pathlib import Path
6
6
 
7
7
  from scrolly.errors import OutputError
8
- from scrolly.render import iter_assets, mermaid_asset
8
+ from scrolly.render import MermaidAsset, iter_assets
9
9
 
10
10
 
11
11
  def write_output(
@@ -13,13 +13,25 @@ def write_output(
13
13
  html: str,
14
14
  *,
15
15
  force: bool = False,
16
- has_mermaid: bool = False,
16
+ mermaid: MermaidAsset | None = None,
17
17
  inline: bool = True,
18
18
  ) -> None:
19
19
  """Write `html` as `out_dir/index.html` and optionally copy bundled assets.
20
20
 
21
- If `out_dir` exists and is non-empty, `force=True` is required to overwrite.
22
- In inline mode, only `index.html` is written (CSS/JS are embedded in the HTML).
21
+ Args:
22
+ out_dir: Destination directory.
23
+ html: Assembled page HTML.
24
+ force: Allow overwriting a non-empty `out_dir`.
25
+ mermaid: Resolved mermaid asset (passed through from
26
+ :func:`build_deck`). When ``inline=False`` and ``mermaid``
27
+ is non-None, the mermaid JS file is written alongside the
28
+ other bundled assets.
29
+ inline: When ``True``, only ``index.html`` is written (CSS, JS,
30
+ and mermaid are embedded in the HTML).
31
+
32
+ Raises:
33
+ OutputError: ``out_dir`` exists but is not a directory, or is
34
+ non-empty without ``force=True``.
23
35
  """
24
36
  if out_dir.exists():
25
37
  if not out_dir.is_dir():
@@ -36,6 +48,5 @@ def write_output(
36
48
  if not inline:
37
49
  for name, content in iter_assets():
38
50
  (out_dir / name).write_bytes(content)
39
- if has_mermaid:
40
- name, content = mermaid_asset()
41
- (out_dir / name).write_bytes(content)
51
+ if mermaid is not None:
52
+ (out_dir / mermaid.name).write_bytes(mermaid.content)
@@ -0,0 +1,6 @@
1
+ """Final HTML assembly — canvas template + bundled static assets."""
2
+
3
+ from scrolly.render.assembler import assemble
4
+ from scrolly.render.bundled_assets import MermaidAsset, bundled_css, bundled_js, iter_assets, mermaid_asset
5
+
6
+ __all__ = ["MermaidAsset", "assemble", "bundled_css", "bundled_js", "iter_assets", "mermaid_asset"]
@@ -15,7 +15,7 @@ from jinja2 import Environment, PackageLoader, StrictUndefined, select_autoescap
15
15
  from scrolly import __version__
16
16
  from scrolly.deck import Deck
17
17
  from scrolly.pipeline._bundler import BundleStats
18
- from scrolly.render.bundled_assets import bundled_css, bundled_js, mermaid_js
18
+ from scrolly.render.bundled_assets import MermaidAsset, bundled_css, bundled_js
19
19
  from scrolly.render.nav_data import build_nav_data
20
20
  from scrolly.render.zoom_control import MinimapGeometry, compute_minimap_geometry
21
21
  from scrolly.slide import SlideHTML
@@ -43,6 +43,7 @@ def assemble(
43
43
  simplified_zoom_control: bool = False,
44
44
  compressed_payload_json: str | None = None,
45
45
  bundle_stats: BundleStats | None = None,
46
+ mermaid: MermaidAsset | None = None,
46
47
  ) -> str:
47
48
  """Render the deck and its chunks into a single HTML string.
48
49
 
@@ -59,6 +60,10 @@ def assemble(
59
60
  bundle_stats: Stats from the bundler's ``build()``, used to
60
61
  populate the help-screen statistics. ``None`` when no bundle
61
62
  was emitted.
63
+ mermaid: Resolved mermaid asset (content + version + source
64
+ tier), or ``None`` when the deck has no mermaid elements.
65
+ Inlined into the page when ``inline=True`` and present;
66
+ its version always lands in the help-screen meta.
62
67
 
63
68
  Returns:
64
69
  The rendered HTML page as a single string.
@@ -66,16 +71,16 @@ def assemble(
66
71
  template = _env().get_template("index.html.j2")
67
72
  nav_data = build_nav_data(deck, chunks)
68
73
  scoped_css_blocks = _collect_scoped_css(deck, chunks)
69
- has_mermaid = any(chunk.has_mermaid for chunk in chunks.values())
74
+ has_mermaid = mermaid is not None
70
75
  minimap: MinimapGeometry | None = None if simplified_zoom_control else compute_minimap_geometry(deck)
71
- meta = _build_meta(deck, chunks, bundle_stats=bundle_stats)
76
+ meta = _build_meta(deck, chunks, bundle_stats=bundle_stats, mermaid=mermaid)
72
77
 
73
78
  inline_vars = {}
74
79
  if inline:
75
80
  inline_vars["bundled_css"] = bundled_css()
76
81
  inline_vars["bundled_js"] = bundled_js()
77
- if has_mermaid:
78
- inline_vars["mermaid_js_content"] = mermaid_js()
82
+ if mermaid is not None:
83
+ inline_vars["mermaid_js_content"] = mermaid.content.decode("utf-8")
79
84
 
80
85
  html = template.render(
81
86
  title=deck.title or "scrolly",
@@ -100,8 +105,21 @@ def _build_meta(
100
105
  chunks: dict[str, SlideHTML],
101
106
  *,
102
107
  bundle_stats: BundleStats | None = None,
108
+ mermaid: MermaidAsset | None = None,
103
109
  ) -> dict[str, Any]:
104
- """Build the metadata dict injected into the HTML for the help screen."""
110
+ """Build the metadata dict injected into the HTML for the help screen.
111
+
112
+ Args:
113
+ deck: The fully-resolved deck.
114
+ chunks: Rendered per-slide HTML chunks.
115
+ bundle_stats: Bundler snapshot for the payload-stats section.
116
+ mermaid: Resolved mermaid asset, whose version is surfaced in
117
+ the help-screen statistics. ``None`` for decks without
118
+ mermaid elements.
119
+
120
+ Returns:
121
+ Help-screen metadata dict.
122
+ """
105
123
  return {
106
124
  "version": __version__,
107
125
  "author": "Bert Pluymers",
@@ -110,6 +128,7 @@ def _build_meta(
110
128
  "slides": len(deck.slides),
111
129
  "edges": len(deck.edges),
112
130
  "payloads": _payload_stats(bundle_stats),
131
+ "mermaid_version": mermaid.version if mermaid is not None else None,
113
132
  "file_size": "__FILE_SIZE_PLACEHOLDER__",
114
133
  },
115
134
  }
@@ -93,9 +93,13 @@ body {
93
93
  initial-value: 0.5;
94
94
  }
95
95
 
96
+ /* `--view-scale` is the deck→slide interpolated scale applied to `.canvas`.
97
+ * `inherits: true` so the connector overlay (`.canvas-edge` / `#edge-dot`)
98
+ * can divide its stroke widths by it and stay a constant on-screen size
99
+ * regardless of how far deck view zooms out. */
96
100
  @property --view-scale {
97
101
  syntax: "<number>";
98
- inherits: false;
102
+ inherits: true;
99
103
  initial-value: 1;
100
104
  }
101
105
 
@@ -314,6 +318,48 @@ body {
314
318
  overflow: hidden;
315
319
  }
316
320
 
321
+ /* ---- Mixed-font typography inside slide content ------------------------
322
+ *
323
+ * Slides routinely mix the body sans-serif with inline `<code>`, `<kbd>`,
324
+ * `<samp>` and `<pre>` blocks. By default the browser pairs `system-ui`
325
+ * (SF Pro / Segoe UI / Roboto) with the user's preferred generic
326
+ * `monospace` (Menlo / Consolas / Courier) — fonts not designed to share
327
+ * metrics — which makes inline `<code>` glyphs sit visibly higher or
328
+ * lower than adjacent sans-serif text whenever a layout depends on
329
+ * vertical alignment between lines.
330
+ *
331
+ * Two corrections, narrow scope (slide content only — UI chrome keeps
332
+ * its explicit per-component fonts):
333
+ *
334
+ * 1. Pin monospace to `ui-monospace` and OS-native pairs. `ui-monospace`
335
+ * picks the platform partner of `system-ui` (SF Mono on macOS,
336
+ * Cascadia Mono on Windows), drawn by the same designer to share
337
+ * metrics with the sans-serif.
338
+ *
339
+ * 2. Set `font-size-adjust: from-font` on the slide-element wrapper.
340
+ * The browser computes the body sans-serif's x-height ratio and
341
+ * inherits it down; when a `<code>` descendant falls back to its
342
+ * monospace family, the monospace font is rescaled so its x-height
343
+ * matches the sans-serif's. The visible "weight" of glyphs aligns
344
+ * across fonts even though their natural metrics differ.
345
+ *
346
+ * Together these reduce the residual mismatch to where it's no longer
347
+ * visible in normal slide layouts. The structural fix — a layout
348
+ * primitive that owns vertical pitch independently of font metrics —
349
+ * would supersede this CSS defaulting.
350
+ */
351
+ .slide-type-slide-json .slide-element {
352
+ font-size-adjust: from-font;
353
+ }
354
+
355
+ .slide-type-slide-json .slide-element code,
356
+ .slide-type-slide-json .slide-element kbd,
357
+ .slide-type-slide-json .slide-element samp,
358
+ .slide-type-slide-json .slide-element pre {
359
+ font-family: ui-monospace, SFMono-Regular, "SF Mono",
360
+ Menlo, Consolas, monospace;
361
+ }
362
+
317
363
  /* Animation-driven slides (numeric scroll_range) counter-translate the
318
364
  * chunk's scroll-driven translateY so content stays visually stationary
319
365
  * while --scroll-position drives the calc()-based keyframe expressions
@@ -817,7 +863,7 @@ body.view-deck .slide-container.selected:hover .subcanvas {
817
863
  .slide-group-label {
818
864
  position: absolute;
819
865
  transform: translate(-50%, -50%);
820
- color: #000000;
866
+ color: var(--slide-group-label-color, #000000);
821
867
  font-family: system-ui, -apple-system, sans-serif;
822
868
  font-size: 5dvmax;
823
869
  font-weight: 600;
@@ -841,7 +887,21 @@ body.view-deck .slide-container.selected:hover .subcanvas {
841
887
  display: block;
842
888
  z-index: 5;
843
889
  opacity: calc(1 - var(--view-zoom, 1));
844
- --connector-color: #4a4a4a;
890
+ --connector-color: #333333;
891
+ /* Connector line + end-dot sizes, in on-screen px. The rules below divide
892
+ * these by --view-scale so they stay visually constant regardless of how far
893
+ * deck view zooms out (which grows with the slide grid). Tune for thickness. */
894
+ --edge-width: 1.25;
895
+ --edge-dot: 4;
896
+ /* Sharp casing: a second, slightly wider set of connectors + dots is drawn
897
+ * in an opaque light tint (--edge-halo-color) directly behind the dark ones
898
+ * (.canvas-edge-halo / .edge-dot-cap-halo, emitted by BezierOverlay), giving
899
+ * a crisp light edge that separates them from dark backdrops without the
900
+ * softness of a blur. --edge-halo-ring is the extra on-screen px per side,
901
+ * divided by --view-scale to stay constant on-screen like the stroke widths
902
+ * above. */
903
+ --edge-halo-ring: 0.75;
904
+ --edge-halo-color: #cccccc;
845
905
  }
846
906
 
847
907
  /* ---- Slide-to-slide pan scale dip ------------------------------------- */
@@ -867,11 +927,37 @@ body.pan-transitioning .canvas {
867
927
  .canvas-edge {
868
928
  fill: none;
869
929
  stroke: var(--connector-color);
870
- stroke-width: 3;
930
+ /* Divide by --view-scale so the line keeps a constant on-screen width
931
+ * regardless of grid size: non-scaling-stroke cancels the SVG-internal
932
+ * viewBox scale, the division cancels the .canvas deck-view transform. */
933
+ stroke-width: calc(var(--edge-width) / var(--view-scale, 1));
871
934
  vector-effect: non-scaling-stroke;
872
935
  stroke-linecap: round;
873
936
  }
874
937
 
938
+ /* White casing drawn behind every connector (--edge-halo-ring px wider per
939
+ * side). BezierOverlay emits all casing paths before any dark path, so the
940
+ * casing never paints over a dark line where two connectors cross. */
941
+ .canvas-edge-halo {
942
+ fill: none;
943
+ stroke: var(--edge-halo-color);
944
+ stroke-width: calc((var(--edge-width) + 2 * var(--edge-halo-ring)) / var(--view-scale, 1));
945
+ vector-effect: non-scaling-stroke;
946
+ stroke-linecap: round;
947
+ }
948
+
949
+ /* End-of-connector dots: round caps on a zero-length line in the #edge-dot
950
+ * marker. Same constant-on-screen-size treatment as the connector line. */
951
+ .edge-dot-cap {
952
+ stroke-width: calc(var(--edge-dot) / var(--view-scale, 1));
953
+ }
954
+
955
+ /* Matching wider white casing for the dots, via the #edge-dot-halo marker. */
956
+ .edge-dot-cap-halo {
957
+ stroke: var(--edge-halo-color);
958
+ stroke-width: calc((var(--edge-dot) + 2 * var(--edge-halo-ring)) / var(--view-scale, 1));
959
+ }
960
+
875
961
  /* ---- Help button -------------------------------------------------------- */
876
962
 
877
963
  /* Styled like the simplified zoom control: same size, background, colour,