presently 0.22.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: bceb99c19c4563f60e51fe3e17cce836c7176ebd9b695919af5737ccc87fd4ca
4
- data.tar.gz: 102b2bbab9019d17c5aadb85c3f40044c1e94bdb2e587482c246d0c184f5fa36
3
+ metadata.gz: ff7fb5b5f79f21fe8d6ebae384ee3e8454fe85012c209568d244b40840898774
4
+ data.tar.gz: ba3b8d4703af77058b4abfc2343032cc778ce61487f52b70ef6cb7643547001a
5
5
  SHA512:
6
- metadata.gz: 8edf0ac3857670ce3afe65522bdf9d560b2f909fb68a85a43161200047f6a8fe588be5bef3ee685ca007a927d47999b5afc7c0db65ae43896c78b018ec347ffc
7
- data.tar.gz: b8cca4183e4494e9fdd2923e429bc3bdca75310f127eba5474f410214c90f2cd5a0b2d89bb03df1e887673d30b81ebb45320b3af5853b73f137e6fbe6c2554f7
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,9 +89,9 @@ 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
- router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
94
+ router.route("/recordings", methods: ["GET", "HEAD", "PUT", "PATCH"]) do |request|
95
95
  handle_recording(request, request_parameters(request))
96
96
  end
97
97
 
@@ -159,10 +159,38 @@ module Presently
159
159
  when "GET", "HEAD"
160
160
  serve_recording(request, slide, @recordings)
161
161
  when "PUT"
162
- store_recording(request, slide)
162
+ duration = recording_duration(parameters)
163
+ if parameters.key?("duration") && !duration
164
+ return invalid_recording_duration
165
+ end
166
+
167
+ store_recording(request, slide, duration: duration)
168
+ when "PATCH"
169
+ duration = recording_duration(parameters)
170
+ return invalid_recording_duration unless duration
171
+ return Protocol::HTTP::Response[404, [], ["Recording not found."]] unless @recordings.exist?(slide)
172
+
173
+ slide.update_duration!(duration)
174
+ Protocol::HTTP::Response[200, [["content-type", "application/json"]], ["{\"updated\":true,\"duration\":#{duration}}"]]
175
+ end
176
+ end
177
+
178
+ # Parse an optional positive recording duration.
179
+ # @parameter parameters [Hash] The decoded query parameters.
180
+ # @returns [Integer | Nil] The duration in seconds, or `nil` when absent or invalid.
181
+ def recording_duration(parameters)
182
+ if value = parameters["duration"]
183
+ duration = Integer(value, exception: false)
184
+ return duration if duration&.positive?
163
185
  end
164
186
  end
165
187
 
188
+ # Build the response for an invalid recording duration.
189
+ # @returns [Protocol::HTTP::Response] A bad request response.
190
+ def invalid_recording_duration
191
+ Protocol::HTTP::Response[400, [], ["Duration must be a positive number of seconds."]]
192
+ end
193
+
166
194
  # Serve normalized narration for playback, falling back to the source take.
167
195
  def handle_playback_recording(request, parameters)
168
196
  index = recording_index(parameters)
@@ -206,7 +234,7 @@ module Presently
206
234
  # @parameter request [Protocol::HTTP::Request] The incoming request.
207
235
  # @parameter slide [Slide] The slide being recorded.
208
236
  # @returns [Protocol::HTTP::Response]
209
- def store_recording(request, slide)
237
+ def store_recording(request, slide, duration: nil)
210
238
  content_type = request.headers["content-type"]&.split(";", 2)&.first
211
239
  unless content_type == Recordings::CONTENT_TYPE
212
240
  return Protocol::HTTP::Response[415, [], ["Expected #{Recordings::CONTENT_TYPE}."]]
@@ -217,7 +245,10 @@ module Presently
217
245
  end
218
246
 
219
247
  @recordings.write(slide, request.body)
220
- Protocol::HTTP::Response[201, [["content-type", "application/json"]], ["{\"saved\":true}"]]
248
+ slide.update_duration!(duration) if duration
249
+
250
+ result = duration ? "{\"saved\":true,\"duration\":#{duration}}" : "{\"saved\":true}"
251
+ Protocol::HTTP::Response[201, [["content-type", "application/json"]], [result]]
221
252
  rescue Recordings::TooLarge => error
222
253
  Protocol::HTTP::Response[413, [], [error.message]]
223
254
  end
@@ -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",
@@ -19,6 +19,7 @@
19
19
  <script type="importmap">
20
20
  {
21
21
  "imports": {
22
+ "morphdom": "/_components/morphdom/morphdom-esm.js",
22
23
  "@socketry/presently": "/_components/@socketry/presently/Presently.js",
23
24
  "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js",
24
25
  "animejs": "/_components/animejs/dist/bundles/anime.esm.min.js"
@@ -31,12 +32,17 @@
31
32
 
32
33
  <body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
33
34
  <main class="playback-stage">
35
+ <div id="playback-frame" class="playback-frame slide-viewport"></div>
36
+ </main>
37
+
38
+ <!-- Keep inactive slides inert while retaining their rendered HTML for local navigation. -->
39
+ <div class="playback-slides" hidden>
34
40
  <?r self.slides.each_with_index do |slide, index| ?>
35
- <div class="playback-frame slide-viewport" data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}"<?r if index != 0 ?> hidden<?r end ?>>
41
+ <template data-index="#{index}" data-transition="#{slide.transition}" data-duration="#{slide.duration}">
36
42
  #{self.render_slide(slide)}
37
- </div>
43
+ </template>
38
44
  <?r end ?>
39
- </main>
45
+ </div>
40
46
 
41
47
  <div class="playback-audio" hidden>
42
48
  <?r self.slides.each_with_index do |slide, index| ?>
@@ -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,21 +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
- ) do
96
- builder.tag(:div, class: "recording-actions") do
97
- builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
98
- builder.tag(:button, class: "recording-save", type: "button", disabled: true){builder.text("Save")}
99
- builder.tag(:span, class: "recording-indicator", "aria-hidden": "true"){}
100
- builder.tag(:span, class: "recording-time"){builder.text("0:00")}
101
- end
102
-
103
- builder.tag(:audio, class: "recording-playback", controls: true, preload: "metadata", hidden: true){}
104
- builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
105
- end
90
+ builder << @recording_controls.to_html
106
91
  end
107
92
 
108
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
@@ -6,6 +6,7 @@
6
6
  require "yaml"
7
7
  require "markly"
8
8
  require "protocol/url"
9
+ require "tempfile"
9
10
 
10
11
  require "markly/renderer/html"
11
12
 
@@ -353,6 +354,47 @@ module Presently
353
354
  @front_matter&.fetch("duration", 60) || 60
354
355
  end
355
356
 
357
+ # Update the expected duration in the slide's YAML front matter.
358
+ # Preserves the remainder of the source file rather than reserializing it.
359
+ # @parameter duration [Integer] The new positive duration in seconds.
360
+ # @returns [Integer] The persisted duration.
361
+ def update_duration!(duration)
362
+ duration = Integer(duration)
363
+ raise ArgumentError, "Duration must be positive!" unless duration.positive?
364
+
365
+ path = source_path
366
+ source = File.read(path)
367
+ newline = source.include?("\r\n") ? "\r\n" : "\n"
368
+
369
+ front_matter_pattern = /\A---[ \t]*(?<newline>\r?\n)(?<body>.*?)(?<closing>^---[ \t]*(?:\r?\n|\z))/m
370
+ if match = front_matter_pattern.match(source)
371
+ body = match[:body]
372
+
373
+ if line = /^duration:[^#\r\n]*(?<comment>[ \t]+#[^\r\n]*)?(?<newline>\r?\n|\z)/.match(body)
374
+ replacement = "duration: #{duration}#{line[:comment]}#{line[:newline]}"
375
+ body = body[0...line.begin(0)] + replacement + body[line.end(0)..]
376
+ else
377
+ body += match[:newline] unless body.empty? || body.end_with?("\n")
378
+ body += "duration: #{duration}#{match[:newline]}"
379
+ end
380
+
381
+ source = source[0...match.begin(:body)] + body + source[match.end(:body)..]
382
+ else
383
+ source = "---#{newline}duration: #{duration}#{newline}---#{newline}#{source}"
384
+ end
385
+
386
+ stat = File.stat(path)
387
+ Tempfile.create([".presently-slide", ".md"], File.dirname(path), binmode: true) do |file|
388
+ file.chmod(stat.mode & 0o7777)
389
+ file.write(source)
390
+ file.flush
391
+ File.rename(file.path, path)
392
+ end
393
+
394
+ (@front_matter ||= {})["duration"] = duration
395
+ return duration
396
+ end
397
+
356
398
  # The title of this slide.
357
399
  # @returns [String] The H1 text, front matter title, or filename without extension.
358
400
  def title
@@ -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.22.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;
@@ -12,15 +12,13 @@ async function prepareSlides() {
12
12
  // Owns the scripts and view transition associated with one rendering of a slide view.
13
13
  export class SlideRendering {
14
14
  #view;
15
- #html;
16
15
  #transitionName;
17
16
  #viewTransition = null;
18
17
  #slides = [];
19
18
  #disposed = false;
20
19
 
21
- constructor(view, {html = null, transition = null} = {}) {
20
+ constructor(view, {transition = null} = {}) {
22
21
  this.#view = view;
23
- this.#html = html;
24
22
  this.#transitionName = transition;
25
23
  }
26
24
 
@@ -29,39 +27,46 @@ export class SlideRendering {
29
27
  async initialize() {
30
28
  if (this.#disposed) return false;
31
29
 
32
- await prepareSlides();
33
- if (this.#disposed) return false;
34
-
30
+ // Establish script-controlled visibility before yielding to asynchronous
31
+ // highlighting, so partially initialized slide content is never painted.
35
32
  this.#view.querySelectorAll('.slide').forEach(slideElement => {
36
33
  const slide = runScript(slideElement);
37
34
  if (slide) this.#slides.push(slide);
38
35
  });
39
36
 
40
- return true;
37
+ await prepareSlides();
38
+ return !this.#disposed;
41
39
  }
42
40
 
43
41
  // Update the view, initialize its slides, and emit the change event.
44
- // @parameter live [Live] The connected Live client.
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.
45
45
  // @returns [Promise(Boolean)] Whether the rendering was completed.
46
- async render(live) {
46
+ async render(update) {
47
47
  if (this.#disposed) return false;
48
48
 
49
49
  let initialized = false;
50
- const update = async () => {
50
+ const render = async () => {
51
51
  if (this.#disposed) return;
52
52
 
53
- live.update(this.#view.id, this.#html);
53
+ update(this.#view);
54
54
  initialized = await this.initialize();
55
55
  };
56
56
 
57
- if (this.#transitionName && document.startViewTransition) {
57
+ if (this.#transitionName && document.startViewTransition && !document.hidden) {
58
58
  document.documentElement.dataset.transition = this.#transitionName;
59
59
  let viewTransition = null;
60
60
 
61
61
  try {
62
- viewTransition = document.startViewTransition(update);
62
+ viewTransition = document.startViewTransition(render);
63
63
  this.#viewTransition = viewTransition;
64
- await viewTransition.finished;
64
+
65
+ // A hidden document may abort the visual transition, but the DOM update should still complete.
66
+ viewTransition.ready.catch(() => {});
67
+ const finished = viewTransition.finished.catch(() => {});
68
+ await viewTransition.updateCallbackDone;
69
+ await finished;
65
70
  } finally {
66
71
  if (this.#viewTransition === viewTransition) {
67
72
  this.#viewTransition = null;
@@ -69,7 +74,7 @@ export class SlideRendering {
69
74
  }
70
75
  }
71
76
  } else {
72
- await update();
77
+ await render();
73
78
  }
74
79
 
75
80
  if (initialized && !this.#disposed) {
@@ -55,62 +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 {
71
+ presently-recording-controls::part(toggle),
72
+ presently-recording-controls::part(save),
73
+ presently-recording-controls::part(apply-duration) {
74
+ box-sizing: border-box;
70
75
  padding: 0.55rem 0.9rem;
71
76
  background: var(--surface-light);
72
- border: 1px solid rgba(255,255,255,0.12);
77
+ border: 1px solid rgba(255, 255, 255, 0.12);
73
78
  border-radius: 6px;
74
79
  color: var(--slide-text);
75
80
  cursor: pointer;
76
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;
77
87
  }
78
88
 
79
- .recording-actions button:hover:not(:disabled) {
89
+ presently-recording-controls::part(apply-duration) {
90
+ justify-self: start;
91
+ }
92
+
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) {
80
96
  background: var(--accent);
81
97
  }
82
98
 
83
- .recording-actions button:disabled {
99
+ presently-recording-controls::part(toggle):disabled,
100
+ presently-recording-controls::part(save):disabled,
101
+ presently-recording-controls::part(apply-duration):disabled {
84
102
  cursor: default;
85
103
  opacity: 0.35;
86
104
  }
87
105
 
88
- .recording-toggle:not(:disabled) {
106
+ presently-recording-controls::part(toggle):not(:disabled) {
89
107
  color: var(--accent-light);
90
108
  }
91
109
 
92
- presently-recorder[data-state="recording"] .recording-toggle {
110
+ presently-recording-controls[data-recording-state="recording"]::part(toggle) {
93
111
  color: #ef5350;
94
112
  }
95
113
 
96
- .recording-indicator {
114
+ presently-recording-controls::part(indicator) {
115
+ box-sizing: border-box;
97
116
  width: 0.65rem;
98
117
  height: 0.65rem;
99
118
  margin-left: auto;
100
- background: rgba(255,255,255,0.2);
119
+ background: rgba(255, 255, 255, 0.2);
101
120
  border-radius: 50%;
102
121
  }
103
122
 
104
- presently-recorder[data-state="preparing"] .recording-indicator,
105
- 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) {
106
125
  background: #f5b942;
107
- 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);
108
127
  animation: recording-indicator-pulse 600ms ease-in-out infinite alternate;
109
128
  }
110
129
 
111
- presently-recorder[data-state="recording"] .recording-indicator {
130
+ presently-recording-controls[data-recording-state="recording"]::part(indicator) {
112
131
  background: #ef5350;
113
- 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);
114
133
  }
115
134
 
116
135
  @keyframes recording-indicator-pulse {
@@ -120,25 +139,29 @@ presently-recorder[data-state="recording"] .recording-indicator {
120
139
  }
121
140
  }
122
141
 
123
- .recording-time {
142
+ presently-recording-controls::part(time) {
124
143
  font-family: var(--font-mono);
125
144
  font-variant-numeric: tabular-nums;
126
145
  }
127
146
 
128
- .recording-playback {
147
+ presently-recording-controls::part(playback) {
148
+ display: block;
129
149
  width: 100%;
130
150
  }
131
151
 
132
- .recording-status {
133
- min-height: 1.4em;
134
- margin: 0;
135
- font-size: 0.9rem;
136
- opacity: 0.7;
152
+ presently-recording-controls::part(duration-option) {
153
+ display: flex;
154
+ align-items: center;
155
+ gap: 0.5rem;
156
+ cursor: pointer;
137
157
  }
138
158
 
139
- .recording-status.error {
140
- color: #ff4757;
141
- 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
+ }
142
165
  }
143
166
 
144
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;