presently 0.23.0 → 0.24.0

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 38ca14929dc4ebe84fefad98f34e153f3c6423b9cbb147d73d08a84c2c64e4c2
4
- data.tar.gz: b3efe32e93f81c0857d2109231e149d67d510a1d46c3117d0ef862b52f3a6b04
3
+ metadata.gz: ff7fb5b5f79f21fe8d6ebae384ee3e8454fe85012c209568d244b40840898774
4
+ data.tar.gz: ba3b8d4703af77058b4abfc2343032cc778ce61487f52b70ef6cb7643547001a
5
5
  SHA512:
6
- metadata.gz: 1104e8b67d9ff964735a644cbbedb1f9616522af673cf0903bef63cced829e61312cea822cab9cc77a3b84a812e653fb19970582f23726cb846d5f92aa594c51
7
- data.tar.gz: d4ae5680354d4b51b1f3d6451a0cf224e03f36dd05255b8d2c9f630b63276dd420c5573b7bed72633e5377af479f1eca18856347c999c364809cae42f505cce5
6
+ metadata.gz: d75218dd736c68eb3022142ea6bb3305a0db487f823a915175f11f8774b01ef5a6cba0b2cfa4cabb2f881367896d5c9d331a944a1d5e09295d86c5237066f918
7
+ data.tar.gz: 13c3042a25f7f664efec66a0be216e38edd3d8a087248d3236727d35e509c9d3787e50698b06984e6cde3a3ec52e876c894a1a6110e5cca8d48aed812a096270
checksums.yaml.gz.sig CHANGED
Binary file
@@ -29,6 +29,20 @@ The script receives a `slide` object — an instance of the `Slide` class export
29
29
 
30
30
  If the script contains a syntax error or throws an exception, the error is logged to the browser console and the presentation continues unaffected.
31
31
 
32
+ ## Flicker-Free Initial State
33
+
34
+ Presently keeps a scripted slide hidden until its scripts have run. Any visibility or styling that defines the slide's initial state must therefore be applied synchronously at the top level of the script:
35
+
36
+ ``` javascript
37
+ const bullets = slide.find("li").builder({effect: "fade"})
38
+ bullets.show(0)
39
+ bullets.play(500)
40
+ ```
41
+
42
+ Use `slide.after()`, `setTimeout()`, promises, and `requestAnimationFrame()` only for changes that occur after the initial state is established. Deferring the initial `show()` call allows the slide to become visible before the callback runs and can expose its uninitialized content for a frame.
43
+
44
+ Integrations using `SlideRendering#render(update)` have the same requirement: `update` must mutate the supplied view synchronously. Presently runs the new slide's scripts immediately after that update, before yielding to asynchronous syntax highlighting or code-focus preparation.
45
+
32
46
  ## The Slide API
33
47
 
34
48
  ### `slide.find(selector)`
@@ -11,7 +11,7 @@ require_relative "presentation_controller"
11
11
  require_relative "home_view"
12
12
  require_relative "display_view"
13
13
  require_relative "presenter_view"
14
- require_relative "recording_view"
14
+ require_relative "recorder_view"
15
15
  require_relative "recordings"
16
16
  require_relative "slide_assets"
17
17
  require_relative "playback"
@@ -44,7 +44,7 @@ module Presently
44
44
  # The view classes that this application allows.
45
45
  # @returns [Array(Class)] The allowed view classes.
46
46
  def allowed_views
47
- [HomeView, DisplayView, PresenterView, RecordingView]
47
+ [HomeView, DisplayView, PresenterView, RecorderView, RecordingControlsView]
48
48
  end
49
49
 
50
50
  # The shared state passed to all views via the resolver.
@@ -60,7 +60,7 @@ module Presently
60
60
  templates = Templates.for(@templates_roots)
61
61
  presentation = Presentation.load(@slides_root, templates)
62
62
 
63
- PresentationController.new(presentation, state: State.new)
63
+ PresentationController.new(presentation, state: State.new, recordings: @recordings)
64
64
  end
65
65
  end
66
66
 
@@ -89,7 +89,7 @@ module Presently
89
89
 
90
90
  router.get("/display"){make_page(resolver.root(DisplayView), interface: :display).call}
91
91
  router.get("/presenter"){make_page(resolver.root(PresenterView), interface: :presenter).call}
92
- router.get("/record"){make_page(resolver.root(RecordingView), interface: :recorder).call}
92
+ router.get("/recorder"){make_page(resolver.root(RecorderView), interface: :recorder).call}
93
93
 
94
94
  router.route("/recordings", methods: ["GET", "HEAD", "PUT", "PATCH"]) do |request|
95
95
  handle_recording(request, request_parameters(request))
@@ -20,7 +20,7 @@ module Presently
20
20
  description: "Control the presentation with speaker notes, timing, and slide previews.",
21
21
  },
22
22
  {
23
- href: "/record",
23
+ href: "/recorder",
24
24
  title: "Narration Recorder",
25
25
  description: "Record, review, and save narration for each slide.",
26
26
  },
@@ -29,6 +29,7 @@ module Presently
29
29
  ].freeze
30
30
  IMPORTS = {
31
31
  "live" => "/_components/@socketry/live/Live.js",
32
+ "@socketry/live" => "/_components/@socketry/live/Live.js",
32
33
  "live-audio" => "/_components/@socketry/live-audio/Live/Audio.js",
33
34
  "morphdom" => "/_components/morphdom/morphdom-esm.js",
34
35
  "@socketry/presently" => "/_components/@socketry/presently/Presently.js",
@@ -18,8 +18,10 @@ module Presently
18
18
  # Initialize a new controller for the given presentation.
19
19
  # @parameter presentation [Presentation] The presentation to control.
20
20
  # @parameter state [State | Nil] The state persistence object. If provided, state is saved on changes and restored on initialization.
21
- def initialize(presentation, state: nil)
21
+ # @parameter recordings [Recordings | Nil] The narration recording store, when recording is enabled.
22
+ def initialize(presentation, state: nil, recordings: nil)
22
23
  @presentation = presentation
24
+ @recordings = recordings
23
25
  @current_index = 0
24
26
  @clock = Clock.new
25
27
  @listeners = []
@@ -55,6 +57,13 @@ module Presently
55
57
  @presentation.slides[@current_index]
56
58
  end
57
59
 
60
+ # Whether the given slide has a narration recording.
61
+ # @parameter slide [Slide] The slide to check.
62
+ # @returns [Boolean]
63
+ def recording_available?(slide)
64
+ @recordings&.exist?(slide) || false
65
+ end
66
+
58
67
  # The slide following the current one.
59
68
  # @returns [Slide | Nil] The next slide, or `nil` if on the last slide.
60
69
  def next_slide
@@ -4,6 +4,7 @@
4
4
  # Copyright, 2026, by Samuel Williams.
5
5
 
6
6
  require_relative "editor"
7
+ require_relative "recording_controls_view"
7
8
  require_relative "slide_view"
8
9
  require_relative "slide_renderer"
9
10
 
@@ -13,7 +14,7 @@ module Presently
13
14
  # Recording is intentionally separate from {PresenterView}: presenting is a
14
15
  # live performance interface, while recording is an authoring workflow with
15
16
  # retakes, playback, and explicit saving.
16
- class RecordingView < SlideView
17
+ class RecorderView < SlideView
17
18
  # Initialize a recording view.
18
19
  # @parameter id [String] The unique element identifier.
19
20
  # @parameter data [Hash] The element data attributes.
@@ -22,6 +23,7 @@ module Presently
22
23
  super(id, data)
23
24
  @controller = controller
24
25
  @slide_renderer = SlideRenderer.new(css_class: "slide recording-slide", templates: controller.templates)
26
+ @recording_controls = RecordingControlsView.mount(self, "recording-controls", controller: controller)
25
27
  end
26
28
 
27
29
  # Bind this view to a page and register for slide changes.
@@ -73,9 +75,6 @@ module Presently
73
75
  slide = @controller.current_slide
74
76
  return unless slide
75
77
 
76
- index = @controller.current_index
77
- recording_url = "/recordings?index=#{index}"
78
-
79
78
  builder.tag(:div, class: "recorder") do
80
79
  render_navigation(builder, slide)
81
80
 
@@ -88,29 +87,7 @@ module Presently
88
87
  builder.tag(:section, class: "recording-panel") do
89
88
  builder.tag(:h2){builder.text("Narration")}
90
89
  builder.tag(:p){builder.text("Record, review, and save one audio track for this slide.")}
91
-
92
- builder.tag("presently-recorder",
93
- id: "presently-recorder-#{index}",
94
- "data-recording-url": recording_url,
95
- "data-slide-duration": slide.duration
96
- ) do
97
- builder.tag(:div, class: "recording-actions") do
98
- builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
99
- builder.tag(:button, class: "recording-save", type: "button", disabled: true){builder.text("Save")}
100
- builder.tag(:span, class: "recording-indicator", "aria-hidden": "true"){}
101
- builder.tag(:span, class: "recording-time"){builder.text("0:00")}
102
- end
103
-
104
- builder.tag(:audio, class: "recording-playback", controls: true, preload: "metadata", hidden: true){}
105
- builder.tag(:button, class: "recording-apply-duration", type: "button", hidden: true) do
106
- builder.text("Update Slide Duration")
107
- end
108
- builder.tag(:label, class: "recording-duration-option") do
109
- builder.tag(:input, class: "recording-update-duration", type: "checkbox")
110
- builder.tag(:span){builder.text("Update slide duration to match recording")}
111
- end
112
- builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
113
- end
90
+ builder << @recording_controls.to_html
114
91
  end
115
92
 
116
93
  builder.tag(:section, class: "recording-notes") do
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Released under the MIT License.
4
+ # Copyright, 2026, by Samuel Williams.
5
+
6
+ require "live"
7
+
8
+ module Presently
9
+ # The persistent narration controls nested within {RecorderView}.
10
+ class RecordingControlsView < Live::View
11
+ # Initialize the recording controls view.
12
+ # @parameter id [String] The stable element identifier.
13
+ # @parameter data [Hash] The element data attributes.
14
+ # @parameter controller [PresentationController] The shared presentation controller.
15
+ def initialize(id, data, controller:)
16
+ super(id, data)
17
+ @controller = controller
18
+ end
19
+
20
+ # Use the client-side recorder as this live view's custom element.
21
+ def tag_name
22
+ "presently-recording-controls"
23
+ end
24
+
25
+ # Render the recorder host. Its shadow DOM owns the controls themselves.
26
+ # @parameter builder [XRB::Builder] The HTML builder.
27
+ def build_markup(builder)
28
+ slide = @controller.current_slide
29
+ return unless slide
30
+
31
+ index = @controller.current_index
32
+ recording_present = @controller.recording_available?(slide)
33
+
34
+ builder.inline_tag(tag_name,
35
+ id: @id,
36
+ data: @data,
37
+ "data-slide-index": index,
38
+ "data-recording-url": "/recordings?index=#{index}",
39
+ "data-slide-duration": slide.duration,
40
+ "data-recording-state": recording_present ? "present" : "missing"
41
+ )
42
+ end
43
+ end
44
+ end
@@ -45,7 +45,9 @@ module Presently
45
45
  scope = TemplateScope.new(slide)
46
46
  html = template.to_string(scope)
47
47
 
48
- classes = [@css_class, extra_class].compact.join(" ")
48
+ classes = [@css_class, extra_class]
49
+ classes << "slide-scripted" unless slide.scripts.empty?
50
+ classes = classes.compact.join(" ")
49
51
  path = Stylesheet.encode_path(slide.path)
50
52
 
51
53
  builder.tag(:div, class: "slide-surface", data: {template: slide.template}) do
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Presently
8
- VERSION = "0.23.0"
8
+ VERSION = "0.24.0"
9
9
  end
data/lib/presently.rb CHANGED
@@ -8,7 +8,7 @@ require_relative "presently/version"
8
8
  require_relative "presently/recordings"
9
9
  require_relative "presently/stylesheet"
10
10
  require_relative "presently/recordings/normalizer"
11
- require_relative "presently/recording_view"
11
+ require_relative "presently/recorder_view"
12
12
  require_relative "presently/playback"
13
13
  require_relative "presently/application"
14
14
  require_relative "presently/environment/application"
@@ -25,6 +25,9 @@ export function runScript(slideEl, {animated = true} = {}) {
25
25
  }
26
26
  } catch (error) {
27
27
  console.error('Slide script error:', error);
28
+ } finally {
29
+ // Scripted slides remain hidden until their initial state has been applied.
30
+ slideEl.dataset.slideReady = '';
28
31
  }
29
32
 
30
33
  return slide;
@@ -27,19 +27,21 @@ export class SlideRendering {
27
27
  async initialize() {
28
28
  if (this.#disposed) return false;
29
29
 
30
- await prepareSlides();
31
- if (this.#disposed) return false;
32
-
30
+ // Establish script-controlled visibility before yielding to asynchronous
31
+ // highlighting, so partially initialized slide content is never painted.
33
32
  this.#view.querySelectorAll('.slide').forEach(slideElement => {
34
33
  const slide = runScript(slideElement);
35
34
  if (slide) this.#slides.push(slide);
36
35
  });
37
36
 
38
- return true;
37
+ await prepareSlides();
38
+ return !this.#disposed;
39
39
  }
40
40
 
41
41
  // Update the view, initialize its slides, and emit the change event.
42
- // @parameter update [Function] Updates the view's DOM.
42
+ // The update must mutate the view synchronously so slide scripts can establish
43
+ // their initial state before the browser can paint the updated DOM.
44
+ // @parameter update [Function] Updates the view's DOM synchronously.
43
45
  // @returns [Promise(Boolean)] Whether the rendering was completed.
44
46
  async render(update) {
45
47
  if (this.#disposed) return false;
@@ -48,7 +50,7 @@ export class SlideRendering {
48
50
  const render = async () => {
49
51
  if (this.#disposed) return;
50
52
 
51
- await update(this.#view);
53
+ update(this.#view);
52
54
  initialized = await this.initialize();
53
55
  };
54
56
 
@@ -55,69 +55,81 @@
55
55
  opacity: 0.65;
56
56
  }
57
57
 
58
- presently-recorder {
58
+ presently-recording-controls {
59
59
  display: grid;
60
60
  gap: 1rem;
61
+ /* Preserve the completed control layout while the client component upgrades. */
62
+ min-block-size: 12.5rem;
61
63
  }
62
64
 
63
- .recording-actions {
65
+ presently-recording-controls::part(actions) {
64
66
  display: flex;
65
67
  align-items: center;
66
68
  gap: 0.6rem;
67
69
  }
68
70
 
69
- .recording-actions button,
70
- .recording-apply-duration {
71
+ presently-recording-controls::part(toggle),
72
+ presently-recording-controls::part(save),
73
+ presently-recording-controls::part(apply-duration) {
74
+ box-sizing: border-box;
71
75
  padding: 0.55rem 0.9rem;
72
76
  background: var(--surface-light);
73
- border: 1px solid rgba(255,255,255,0.12);
77
+ border: 1px solid rgba(255, 255, 255, 0.12);
74
78
  border-radius: 6px;
75
79
  color: var(--slide-text);
76
80
  cursor: pointer;
77
81
  font: inherit;
82
+ transition:
83
+ background-color 120ms ease-out,
84
+ border-color 120ms ease-out,
85
+ color 120ms ease-out,
86
+ opacity 120ms ease-out;
78
87
  }
79
88
 
80
- .recording-apply-duration {
89
+ presently-recording-controls::part(apply-duration) {
81
90
  justify-self: start;
82
91
  }
83
92
 
84
- .recording-actions button:hover:not(:disabled),
85
- .recording-apply-duration:hover:not(:disabled) {
93
+ presently-recording-controls::part(toggle):hover:not(:disabled),
94
+ presently-recording-controls::part(save):hover:not(:disabled),
95
+ presently-recording-controls::part(apply-duration):hover:not(:disabled) {
86
96
  background: var(--accent);
87
97
  }
88
98
 
89
- .recording-actions button:disabled,
90
- .recording-apply-duration:disabled {
99
+ presently-recording-controls::part(toggle):disabled,
100
+ presently-recording-controls::part(save):disabled,
101
+ presently-recording-controls::part(apply-duration):disabled {
91
102
  cursor: default;
92
103
  opacity: 0.35;
93
104
  }
94
105
 
95
- .recording-toggle:not(:disabled) {
106
+ presently-recording-controls::part(toggle):not(:disabled) {
96
107
  color: var(--accent-light);
97
108
  }
98
109
 
99
- presently-recorder[data-state="recording"] .recording-toggle {
110
+ presently-recording-controls[data-recording-state="recording"]::part(toggle) {
100
111
  color: #ef5350;
101
112
  }
102
113
 
103
- .recording-indicator {
114
+ presently-recording-controls::part(indicator) {
115
+ box-sizing: border-box;
104
116
  width: 0.65rem;
105
117
  height: 0.65rem;
106
118
  margin-left: auto;
107
- background: rgba(255,255,255,0.2);
119
+ background: rgba(255, 255, 255, 0.2);
108
120
  border-radius: 50%;
109
121
  }
110
122
 
111
- presently-recorder[data-state="preparing"] .recording-indicator,
112
- presently-recorder[data-state="starting"] .recording-indicator {
123
+ presently-recording-controls[data-recording-state="preparing"]::part(indicator),
124
+ presently-recording-controls[data-recording-state="starting"]::part(indicator) {
113
125
  background: #f5b942;
114
- box-shadow: 0 0 0 0.25rem rgba(245,185,66,0.15);
126
+ box-shadow: 0 0 0 0.25rem rgba(245, 185, 66, 0.15);
115
127
  animation: recording-indicator-pulse 600ms ease-in-out infinite alternate;
116
128
  }
117
129
 
118
- presently-recorder[data-state="recording"] .recording-indicator {
130
+ presently-recording-controls[data-recording-state="recording"]::part(indicator) {
119
131
  background: #ef5350;
120
- box-shadow: 0 0 0 0.25rem rgba(239,83,80,0.15);
132
+ box-shadow: 0 0 0 0.25rem rgba(239, 83, 80, 0.15);
121
133
  }
122
134
 
123
135
  @keyframes recording-indicator-pulse {
@@ -127,32 +139,29 @@ presently-recorder[data-state="recording"] .recording-indicator {
127
139
  }
128
140
  }
129
141
 
130
- .recording-time {
142
+ presently-recording-controls::part(time) {
131
143
  font-family: var(--font-mono);
132
144
  font-variant-numeric: tabular-nums;
133
145
  }
134
146
 
135
- .recording-playback {
147
+ presently-recording-controls::part(playback) {
148
+ display: block;
136
149
  width: 100%;
137
150
  }
138
151
 
139
- .recording-duration-option {
152
+ presently-recording-controls::part(duration-option) {
140
153
  display: flex;
141
154
  align-items: center;
142
155
  gap: 0.5rem;
143
156
  cursor: pointer;
144
157
  }
145
158
 
146
- .recording-status {
147
- min-height: 1.4em;
148
- margin: 0;
149
- font-size: 0.9rem;
150
- opacity: 0.7;
151
- }
152
-
153
- .recording-status.error {
154
- color: #ff4757;
155
- opacity: 1;
159
+ @media (prefers-reduced-motion: reduce) {
160
+ presently-recording-controls::part(toggle),
161
+ presently-recording-controls::part(save),
162
+ presently-recording-controls::part(apply-duration) {
163
+ transition: none;
164
+ }
156
165
  }
157
166
 
158
167
  .recording-notes {
@@ -60,6 +60,11 @@
60
60
  container-type: size;
61
61
  }
62
62
 
63
+ /* Never expose a scripted slide before its script establishes the initial state. */
64
+ .slide-scripted:not([data-slide-ready]) {
65
+ visibility: hidden;
66
+ }
67
+
63
68
  @container (min-aspect-ratio: 16 / 9) {
64
69
  .slide {
65
70
  width: auto;
@@ -1,7 +1,7 @@
1
- import { Live } from 'live';
2
- import {SlideRendering} from '@socketry/presently';
1
+ import './recording_controls.js';
3
2
 
4
- import './recorder.js';
3
+ import {Live} from '@socketry/live';
4
+ import {SlideRendering} from '@socketry/presently';
5
5
 
6
6
  const SLIDE_RENDER_EVENT = 'presently:slide:render';
7
7
 
@@ -52,7 +52,7 @@ document.addEventListener('change', (event) => {
52
52
 
53
53
  // Keyboard navigation
54
54
  document.addEventListener('keydown', (event) => {
55
- if (event.target.closest('button, input, textarea, select, audio, presently-recorder')) return;
55
+ if (event.target.closest('button, input, textarea, select, audio, presently-recording-controls')) return;
56
56
 
57
57
  const liveView = document.querySelector('live-view');
58
58
  if (!liveView) return;