scrolly 0.2.2__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 (121) hide show
  1. {scrolly-0.2.2 → scrolly-0.2.3}/PKG-INFO +2 -2
  2. {scrolly-0.2.2 → scrolly-0.2.3}/README.md +1 -1
  3. {scrolly-0.2.2 → scrolly-0.2.3}/pyproject.toml +2 -2
  4. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/introspect.py +2 -1
  5. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/model.py +1 -0
  6. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/parser.py +34 -11
  7. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/schema.py +11 -0
  8. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/assets/canvas.css +48 -4
  9. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/assets/canvas.js +44 -7
  10. scrolly-0.2.3/scrolly/render/color.py +66 -0
  11. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/nav_data.py +15 -4
  12. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/templates/index.html.j2 +12 -2
  13. {scrolly-0.2.2 → scrolly-0.2.3}/.gitignore +0 -0
  14. {scrolly-0.2.2 → scrolly-0.2.3}/LICENSE +0 -0
  15. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/__init__.py +0 -0
  16. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/__init__.py +0 -0
  17. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_cli.py +0 -0
  18. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_errors.py +0 -0
  19. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/__init__.py +0 -0
  20. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_assets.py +0 -0
  21. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_common.py +0 -0
  22. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_dom.py +0 -0
  23. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_elements.py +0 -0
  24. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_slides.py +0 -0
  25. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_snaps.py +0 -0
  26. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_snapshot.py +0 -0
  27. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/_cli/_introspect/_timeline.py +0 -0
  28. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/__init__.py +0 -0
  29. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/inference.py +0 -0
  30. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/deck/validator.py +0 -0
  31. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/__init__.py +0 -0
  32. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/_catalog.py +0 -0
  33. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/_codes.py +0 -0
  34. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/_report.py +0 -0
  35. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/_validation_error.py +0 -0
  36. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E001.md +0 -0
  37. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E002.md +0 -0
  38. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E003.md +0 -0
  39. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E004.md +0 -0
  40. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E005.md +0 -0
  41. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E006.md +0 -0
  42. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E007.md +0 -0
  43. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E008.md +0 -0
  44. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E009.md +0 -0
  45. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E010.md +0 -0
  46. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E011.md +0 -0
  47. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E012.md +0 -0
  48. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E101.md +0 -0
  49. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E102.md +0 -0
  50. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E103.md +0 -0
  51. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E201.md +0 -0
  52. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E202.md +0 -0
  53. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E203.md +0 -0
  54. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E204.md +0 -0
  55. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E205.md +0 -0
  56. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E206.md +0 -0
  57. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E207.md +0 -0
  58. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E299.md +0 -0
  59. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E301.md +0 -0
  60. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E302.md +0 -0
  61. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E303.md +0 -0
  62. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E304.md +0 -0
  63. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E305.md +0 -0
  64. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E306.md +0 -0
  65. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E307.md +0 -0
  66. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E308.md +0 -0
  67. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E401.md +0 -0
  68. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E402.md +0 -0
  69. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E403.md +0 -0
  70. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E501.md +0 -0
  71. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E502.md +0 -0
  72. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E503.md +0 -0
  73. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E504.md +0 -0
  74. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E505.md +0 -0
  75. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E601.md +0 -0
  76. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E602.md +0 -0
  77. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E603.md +0 -0
  78. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E701.md +0 -0
  79. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/E702.md +0 -0
  80. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/errors/catalog/__init__.py +0 -0
  81. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/__init__.py +0 -0
  82. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/_bundler.py +0 -0
  83. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/assets.py +0 -0
  84. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/introspect.py +0 -0
  85. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/lint.py +0 -0
  86. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/loader.py +0 -0
  87. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/orchestrator.py +0 -0
  88. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/pipeline/writer.py +0 -0
  89. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/__init__.py +0 -0
  90. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/assembler.py +0 -0
  91. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/assets/mermaid-LICENSE +0 -0
  92. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/assets/mermaid.min.js +0 -0
  93. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/bundled_assets.py +0 -0
  94. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/fan.py +0 -0
  95. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/render/zoom_control.py +0 -0
  96. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/__init__.py +0 -0
  97. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/__init__.py +0 -0
  98. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/ir.py +0 -0
  99. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/processor.py +0 -0
  100. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/registry.py +0 -0
  101. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/rendered.py +0 -0
  102. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/__init__.py +0 -0
  103. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/_shared.py +0 -0
  104. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/html.py +0 -0
  105. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/iframe.py +0 -0
  106. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/image.py +0 -0
  107. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/image_sequence.py +0 -0
  108. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/markdown.py +0 -0
  109. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/element_ir/renderers/mermaid.py +0 -0
  110. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/html.py +0 -0
  111. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/introspect.py +0 -0
  112. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/__init__.py +0 -0
  113. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/_framework/__init__.py +0 -0
  114. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/_framework/animated_values.py +0 -0
  115. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/_framework/element.py +0 -0
  116. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/_framework/utils.py +0 -0
  117. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/ir/slide.py +0 -0
  118. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/processor.py +0 -0
  119. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/registry.py +0 -0
  120. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/renderers/__init__.py +0 -0
  121. {scrolly-0.2.2 → scrolly-0.2.3}/scrolly/slide/renderers/slide.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: scrolly
3
- Version: 0.2.2
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
@@ -35,7 +35,7 @@ Compile a JSON5 deck into a self-contained, scrollable 2D-canvas HTML presentati
35
35
 
36
36
  *Liked by humans; understood by agents.*
37
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)
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
39
 
40
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)
41
41
  [![PyPI](https://img.shields.io/pypi/v/scrolly.svg)](https://pypi.org/project/scrolly/)
@@ -4,7 +4,7 @@ Compile a JSON5 deck into a self-contained, scrollable 2D-canvas HTML presentati
4
4
 
5
5
  *Liked by humans; understood by agents.*
6
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)
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
8
 
9
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)
10
10
  [![PyPI](https://img.shields.io/pypi/v/scrolly.svg)](https://pypi.org/project/scrolly/)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "scrolly"
3
- version = "0.2.2"
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"
@@ -40,7 +40,7 @@ dev = [
40
40
  # Exact pin: gitsvg is pre-1.0 and its output schema can shift
41
41
  # between releases. Used to regenerate the stacked-diffs example's
42
42
  # gitsvg frames.
43
- "gitsvg==0.2.2",
43
+ "gitsvg==0.2.3",
44
44
  ]
45
45
  # Optional group for the hero-animation capture pipeline (docs/_gen).
46
46
  # Heavy (Playwright ships browser binaries via `make capture-setup`),
@@ -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.",
@@ -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
 
@@ -859,7 +863,7 @@ body.view-deck .slide-container.selected:hover .subcanvas {
859
863
  .slide-group-label {
860
864
  position: absolute;
861
865
  transform: translate(-50%, -50%);
862
- color: #000000;
866
+ color: var(--slide-group-label-color, #000000);
863
867
  font-family: system-ui, -apple-system, sans-serif;
864
868
  font-size: 5dvmax;
865
869
  font-weight: 600;
@@ -883,7 +887,21 @@ body.view-deck .slide-container.selected:hover .subcanvas {
883
887
  display: block;
884
888
  z-index: 5;
885
889
  opacity: calc(1 - var(--view-zoom, 1));
886
- --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;
887
905
  }
888
906
 
889
907
  /* ---- Slide-to-slide pan scale dip ------------------------------------- */
@@ -909,11 +927,37 @@ body.pan-transitioning .canvas {
909
927
  .canvas-edge {
910
928
  fill: none;
911
929
  stroke: var(--connector-color);
912
- 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));
913
934
  vector-effect: non-scaling-stroke;
914
935
  stroke-linecap: round;
915
936
  }
916
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
+
917
961
  /* ---- Help button -------------------------------------------------------- */
918
962
 
919
963
  /* Styled like the simplified zoom control: same size, background, colour,
@@ -601,6 +601,10 @@
601
601
  this._config = snapConfig;
602
602
  this._containerFn = containerFn;
603
603
  this._enabled = true;
604
+ // The idle auto-snap (settle to nearest snap after IDLE_MS of scroll
605
+ // inactivity) can be turned off independently of `_enabled`, leaving the
606
+ // snap control, dots, and manual up()/down() intact. See setIdleSnap().
607
+ this._idleSnap = true;
604
608
  this._timer = null;
605
609
  this._anim = null;
606
610
  this._animTarget = null;
@@ -658,11 +662,21 @@
658
662
 
659
663
  schedule(slideId) {
660
664
  this.cancel();
661
- if (!this._enabled) return;
665
+ if (!this._enabled || !this._idleSnap) return;
662
666
  if (!this._snapsFor(slideId)) return;
663
667
  this._timer = setTimeout(() => { this._timer = null; this._animateToNearest(slideId); }, SnapManager.IDLE_MS);
664
668
  }
665
669
 
670
+ // Toggle the idle auto-snap only. Unlike `_setEnabled`, this leaves the
671
+ // snap feature on (control, scrollbar snap dots, and manual up()/down()
672
+ // nav all keep working) — it just stops the post-idle settle from firing.
673
+ // The capture harness sets this off so scripted setScroll positions render
674
+ // exactly instead of being pulled toward a snap mid-capture.
675
+ setIdleSnap(enabled) {
676
+ this._idleSnap = enabled;
677
+ if (!enabled) this.cancel();
678
+ }
679
+
666
680
  cancel() {
667
681
  if (this._timer !== null) { clearTimeout(this._timer); this._timer = null; }
668
682
  if (this._anim !== null) { cancelAnimationFrame(this._anim); this._anim = null; }
@@ -689,8 +703,13 @@
689
703
 
690
704
  this._animTarget = target;
691
705
  const start = current;
692
- const t0 = performance.now();
706
+ // Anchor t0 to the first frame's timestamp, not performance.now():
707
+ // a rAF timestamp can predate a now() taken just before scheduling,
708
+ // making elapsed (and thus t) negative on frame 1, which drives
709
+ // easeOutQuad below 0 and kicks the position backward for one frame.
710
+ let t0 = null;
693
711
  const step = (now) => {
712
+ if (t0 === null) t0 = now;
694
713
  const elapsed = now - t0;
695
714
  const t = Math.min(1, elapsed / SnapManager.DURATION_MS);
696
715
  const ease = SnapManager.easeOutQuad(t);
@@ -1014,6 +1033,11 @@
1014
1033
  const label = document.createElement("span");
1015
1034
  label.className = "slide-group-label";
1016
1035
  label.textContent = group.label;
1036
+ // `label_color` is resolved server-side (override or auto-contrast
1037
+ // pick); fall back to the CSS default if absent.
1038
+ if (group.label_color) {
1039
+ label.style.setProperty("--slide-group-label-color", group.label_color);
1040
+ }
1017
1041
  this._elements.push({ group, svg, path, label });
1018
1042
  }
1019
1043
  const firstSlideContainer = this._canvas.querySelector(".slide-container");
@@ -1293,15 +1317,21 @@
1293
1317
  this._svg.style.height = data.height;
1294
1318
 
1295
1319
  const ns = "http://www.w3.org/2000/svg";
1296
- for (const d of data.paths) {
1320
+ // Two passes so every white casing sits behind every dark connector:
1321
+ // all halos first (wider, opaque white), then all dark lines on top.
1322
+ // Interleaving would let a later edge's casing paint over an earlier
1323
+ // edge's dark line at a crossing.
1324
+ const addPath = (d, cls, marker) => {
1297
1325
  const path = document.createElementNS(ns, "path");
1298
- path.setAttribute("class", "canvas-edge");
1299
- path.setAttribute("marker-start", "url(#edge-dot)");
1300
- path.setAttribute("marker-end", "url(#edge-dot)");
1326
+ path.setAttribute("class", cls);
1327
+ path.setAttribute("marker-start", `url(#${marker})`);
1328
+ path.setAttribute("marker-end", `url(#${marker})`);
1301
1329
  path.setAttribute("d", d);
1302
1330
  this._svg.appendChild(path);
1303
1331
  this._paths.push(path);
1304
- }
1332
+ };
1333
+ for (const d of data.paths) addPath(d, "canvas-edge-halo", "edge-dot-halo");
1334
+ for (const d of data.paths) addPath(d, "canvas-edge", "edge-dot");
1305
1335
  }
1306
1336
  }
1307
1337
 
@@ -2174,6 +2204,13 @@
2174
2204
  scrollManager,
2175
2205
  isAnimating: () => document.body.classList.contains("view-transitioning"),
2176
2206
  });
2207
+ // Turn off the idle auto-snap under automation: scripted setScroll
2208
+ // positions are exact, but the idle-snap timer would otherwise fire between
2209
+ // the capture's (slow) screenshots and animate the scroll toward the
2210
+ // nearest snap — a visible wobble + catch-up jump. The snap feature stays
2211
+ // enabled, so the scrollbar's snap dots (a key debug-mode cue) and the
2212
+ // scripted Shift+Arrow nav both keep working.
2213
+ snapManager.setIdleSnap(false);
2177
2214
  console.log("scrolly automation hook active");
2178
2215
  }
2179
2216
  })(typeof module !== "undefined" ? module.exports : {});
@@ -0,0 +1,66 @@
1
+ """Color utilities for rendering: luminance and legible-text-color picks."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ _HEX_COLOR_RE = re.compile(r"^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$")
8
+
9
+
10
+ def _hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
11
+ """Parse a ``#RGB`` or ``#RRGGBB`` string into 0-255 ``(r, g, b)`` bytes.
12
+
13
+ Args:
14
+ hex_color: A ``#RGB`` or ``#RRGGBB`` hex color string.
15
+
16
+ Returns:
17
+ The ``(r, g, b)`` byte triple.
18
+
19
+ Raises:
20
+ ValueError: If ``hex_color`` is not a ``#RGB`` or ``#RRGGBB`` color.
21
+ """
22
+ if not _HEX_COLOR_RE.match(hex_color):
23
+ raise ValueError(f"not a #RGB or #RRGGBB color: {hex_color!r}")
24
+ digits = hex_color[1:]
25
+ if len(digits) == 3:
26
+ digits = "".join(c * 2 for c in digits)
27
+ return int(digits[0:2], 16), int(digits[2:4], 16), int(digits[4:6], 16)
28
+
29
+
30
+ def _srgb_to_linear(channel: int) -> float:
31
+ """Linearize one sRGB 0-255 channel to its 0-1 linear-light value."""
32
+ c = channel / 255
33
+ return c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
34
+
35
+
36
+ def relative_luminance(hex_color: str) -> float:
37
+ """Compute the WCAG relative luminance of a hex color.
38
+
39
+ Args:
40
+ hex_color: A ``#RGB`` or ``#RRGGBB`` hex color string.
41
+
42
+ Returns:
43
+ Relative luminance in ``[0, 1]`` (sRGB channels linearized and weighted
44
+ per WCAG 2.x).
45
+ """
46
+ r, g, b = _hex_to_rgb(hex_color)
47
+ return 0.2126 * _srgb_to_linear(r) + 0.7152 * _srgb_to_linear(g) + 0.0722 * _srgb_to_linear(b)
48
+
49
+
50
+ def legible_text_color(background: str) -> str:
51
+ """Pick black or white for the most legible text on a background color.
52
+
53
+ Chooses whichever of black or white yields the higher WCAG contrast ratio
54
+ against ``background``. The crossover sits at luminance ≈ 0.179, so light
55
+ and mid-tone backgrounds get black and only genuinely dark ones get white.
56
+
57
+ Args:
58
+ background: A ``#RGB`` or ``#RRGGBB`` background color string.
59
+
60
+ Returns:
61
+ ``"#000000"`` or ``"#ffffff"``.
62
+ """
63
+ luminance = relative_luminance(background)
64
+ contrast_black = (luminance + 0.05) / 0.05
65
+ contrast_white = 1.05 / (luminance + 0.05)
66
+ return "#000000" if contrast_black >= contrast_white else "#ffffff"
@@ -23,9 +23,15 @@ from __future__ import annotations
23
23
  from typing import Any
24
24
 
25
25
  from scrolly.deck import Deck
26
+ from scrolly.render.color import legible_text_color
26
27
  from scrolly.render.fan import FAN_SPACING_FACTOR, compute_fan_offsets
27
28
  from scrolly.slide import SlideHTML
28
29
 
30
+ # Default group-background fill, used when a group sets no explicit `color`.
31
+ # Mirrors `.slide-group-bg { fill }` in canvas.css so the label auto-contrast
32
+ # pick matches the rendered background.
33
+ DEFAULT_GROUP_BACKGROUND = "#dcdcdc"
34
+
29
35
 
30
36
  def build_nav_data(deck: Deck, chunks: dict[str, SlideHTML]) -> dict[str, Any]:
31
37
  """Return a JSON-serialisable representation of the deck for the client.
@@ -93,10 +99,15 @@ def build_nav_data(deck: Deck, chunks: dict[str, SlideHTML]) -> dict[str, Any]:
93
99
 
94
100
  edges_data = _build_edges(deck, fan)
95
101
 
96
- groups_data = [
97
- {"label": g.label, "slide_ids": list(g.slide_ids), **({"color": g.color} if g.color else {})}
98
- for g in deck.groups
99
- ]
102
+ groups_data = []
103
+ for g in deck.groups:
104
+ entry: dict[str, Any] = {"label": g.label, "slide_ids": list(g.slide_ids)}
105
+ if g.color:
106
+ entry["color"] = g.color
107
+ # Resolve the label color here so the client just applies it: an explicit
108
+ # `label_color` override wins, else auto-contrast against the background.
109
+ entry["label_color"] = g.label_color or legible_text_color(g.color or DEFAULT_GROUP_BACKGROUND)
110
+ groups_data.append(entry)
100
111
 
101
112
  return {
102
113
  "initial_slide": deck.slides[0].id if deck.slides else None,
@@ -23,9 +23,19 @@
23
23
  markerWidth="0.001" markerHeight="0.001"
24
24
  refX="0" refY="0"
25
25
  overflow="visible">
26
- <line x1="0" y1="0" x2="0" y2="0"
26
+ <line class="edge-dot-cap"
27
+ x1="0" y1="0" x2="0" y2="0"
27
28
  stroke="var(--connector-color)"
28
- stroke-width="15"
29
+ stroke-linecap="round"
30
+ vector-effect="non-scaling-stroke"/>
31
+ </marker>
32
+ <marker id="edge-dot-halo"
33
+ markerUnits="userSpaceOnUse"
34
+ markerWidth="0.001" markerHeight="0.001"
35
+ refX="0" refY="0"
36
+ overflow="visible">
37
+ <line class="edge-dot-cap-halo"
38
+ x1="0" y1="0" x2="0" y2="0"
29
39
  stroke-linecap="round"
30
40
  vector-effect="non-scaling-stroke"/>
31
41
  </marker>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes