presently 0.21.0 → 0.23.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: 9d60b3fb59bbf07f0fdd4f3e1756998485049e2b55dc56225160e160af02346e
4
- data.tar.gz: 4837aa9c8574f3b759a3199db53ad263d323df5cd6239df7292e8535ad83702c
3
+ metadata.gz: 38ca14929dc4ebe84fefad98f34e153f3c6423b9cbb147d73d08a84c2c64e4c2
4
+ data.tar.gz: b3efe32e93f81c0857d2109231e149d67d510a1d46c3117d0ef862b52f3a6b04
5
5
  SHA512:
6
- metadata.gz: b597972eabe2fec25758be28d3630db39756e93e7e2c24c43cfcdbaca3385741c1306748ada054cc7ea9d8bdbe9ba91f05217453cd5bfca3a1be5c2cc885b8d9
7
- data.tar.gz: 122236fe265570f0be41163f662376b55df718d28ee52eada7e796c415ea52edd0f8f225649e1b9438d457cbb9281c2d06674274a80a33d6ec7b12ef304bed80
6
+ metadata.gz: 1104e8b67d9ff964735a644cbbedb1f9616522af673cf0903bef63cced829e61312cea822cab9cc77a3b84a812e653fb19970582f23726cb846d5f92aa594c51
7
+ data.tar.gz: d4ae5680354d4b51b1f3d6451a0cf224e03f36dd05255b8d2c9f630b63276dd420c5573b7bed72633e5377af479f1eca18856347c999c364809cae42f505cce5
checksums.yaml.gz.sig CHANGED
Binary file
@@ -192,7 +192,7 @@ Use semantic classes and CSS custom properties so that meaning remains separate
192
192
  .diagram-node {
193
193
  --node-color: var(--accent);
194
194
  border: 0.08em solid var(--node-color);
195
- background: color-mix(in srgb, var(--node-color) 10%, var(--slide-bg));
195
+ background: color-mix(in srgb, var(--node-color) 10%, var(--slide-background));
196
196
  }
197
197
 
198
198
  .diagram-node[data-tone="storage"] {
@@ -72,10 +72,11 @@ module Presently
72
72
 
73
73
  # Create a Presently page with the presentation-specific stylesheets.
74
74
  # @parameter view [Live::View] The root view for the page.
75
+ # @parameter interface [Symbol | Nil] The interface-specific stylesheet to load.
75
76
  # @returns [Page] The presentation page.
76
- def make_page(view)
77
+ def make_page(view, interface: nil)
77
78
  stylesheets = controller.presentation.stylesheets.map(&:url)
78
- Page.new(title: title, body: view, stylesheets: stylesheets)
79
+ Page.new(title: title, body: view, interface: interface, stylesheets: stylesheets)
79
80
  end
80
81
 
81
82
  # Add Presently's application routes.
@@ -83,14 +84,14 @@ module Presently
83
84
  def configure_routes(router)
84
85
  router.get("/") do
85
86
  body = resolver.root(HomeView)
86
- Page.new(title: title, body: body).call
87
+ Page.new(title: title, body: body, interface: :home).call
87
88
  end
88
89
 
89
- router.get("/display"){make_page(resolver.root(DisplayView)).call}
90
- router.get("/presenter"){make_page(resolver.root(PresenterView)).call}
91
- router.get("/record"){make_page(resolver.root(RecordingView)).call}
90
+ router.get("/display"){make_page(resolver.root(DisplayView), interface: :display).call}
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
93
 
93
- router.route("/recordings", methods: ["GET", "HEAD", "PUT"]) do |request|
94
+ router.route("/recordings", methods: ["GET", "HEAD", "PUT", "PATCH"]) do |request|
94
95
  handle_recording(request, request_parameters(request))
95
96
  end
96
97
 
@@ -158,10 +159,38 @@ module Presently
158
159
  when "GET", "HEAD"
159
160
  serve_recording(request, slide, @recordings)
160
161
  when "PUT"
161
- 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?
162
185
  end
163
186
  end
164
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
+
165
194
  # Serve normalized narration for playback, falling back to the source take.
166
195
  def handle_playback_recording(request, parameters)
167
196
  index = recording_index(parameters)
@@ -205,7 +234,7 @@ module Presently
205
234
  # @parameter request [Protocol::HTTP::Request] The incoming request.
206
235
  # @parameter slide [Slide] The slide being recorded.
207
236
  # @returns [Protocol::HTTP::Response]
208
- def store_recording(request, slide)
237
+ def store_recording(request, slide, duration: nil)
209
238
  content_type = request.headers["content-type"]&.split(";", 2)&.first
210
239
  unless content_type == Recordings::CONTENT_TYPE
211
240
  return Protocol::HTTP::Response[415, [], ["Expected #{Recordings::CONTENT_TYPE}."]]
@@ -216,7 +245,10 @@ module Presently
216
245
  end
217
246
 
218
247
  @recordings.write(slide, request.body)
219
- 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]]
220
252
  rescue Recordings::TooLarge => error
221
253
  Protocol::HTTP::Response[413, [], [error.message]]
222
254
  end
@@ -4,6 +4,8 @@
4
4
  <meta charset="UTF-8" />
5
5
 
6
6
  <link rel="stylesheet" href="/_static/index.css" type="text/css" />
7
+ <link rel="stylesheet" href="/_static/slides.css" type="text/css" />
8
+ <link rel="stylesheet" href="/_static/export.css" type="text/css" />
7
9
  <link rel="stylesheet" href="/_static/custom.css" type="text/css" />
8
10
  <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" />
9
11
  <?r self.stylesheets.each do |stylesheet| ?>
@@ -13,8 +13,17 @@ module Presently
13
13
  class Page < Lively::Page
14
14
  ICON = "/_static/icon.png"
15
15
  STYLESHEETS = [
16
- {href: "/_static/site.css", media: "screen"}.freeze,
17
16
  {href: "/_static/index.css", media: "screen"}.freeze,
17
+ ].freeze
18
+ SLIDES_STYLESHEET = {href: "/_static/slides.css", media: "screen"}.freeze
19
+ INTERFACE_STYLESHEETS = {
20
+ home: [{href: "/_static/home.css", media: "screen"}.freeze].freeze,
21
+ display: [SLIDES_STYLESHEET, {href: "/_static/display.css", media: "screen"}.freeze].freeze,
22
+ presenter: [SLIDES_STYLESHEET, {href: "/_static/presenter.css", media: "screen"}.freeze].freeze,
23
+ recorder: [SLIDES_STYLESHEET, {href: "/_static/recorder.css", media: "screen"}.freeze].freeze,
24
+ }.freeze
25
+ DEFAULT_INTERFACE_STYLESHEETS = INTERFACE_STYLESHEETS.values.flatten.uniq.freeze
26
+ TRAILING_STYLESHEETS = [
18
27
  {href: "/_static/custom.css", media: "screen"}.freeze,
19
28
  {href: "/_components/@socketry/syntax/themes/base/syntax.css", media: "screen"}.freeze,
20
29
  ].freeze
@@ -31,13 +40,16 @@ module Presently
31
40
  # Initialize a new page.
32
41
  # @parameter title [String] The page title.
33
42
  # @parameter body [Live::View | Nil] The Live view to embed in the page.
43
+ # @parameter interface [Symbol | Nil] The interface-specific stylesheets to load, or all interface stylesheets by default for compatibility.
34
44
  # @parameter stylesheets [Array(String | Hash)] Presentation-specific stylesheets.
35
- def initialize(title: "Presently", body: nil, stylesheets: [])
45
+ def initialize(title: "Presently", body: nil, interface: nil, stylesheets: [])
46
+ interface_stylesheets = interface ? INTERFACE_STYLESHEETS.fetch(interface) : DEFAULT_INTERFACE_STYLESHEETS
47
+
36
48
  super(
37
49
  title: title,
38
50
  body: body,
39
51
  icon: ICON,
40
- stylesheets: STYLESHEETS + stylesheets,
52
+ stylesheets: STYLESHEETS + interface_stylesheets + TRAILING_STYLESHEETS + stylesheets,
41
53
  imports: IMPORTS,
42
54
  modules: MODULES,
43
55
  )
@@ -7,8 +7,9 @@
7
7
  <meta name="viewport" content="width=device-width, initial-scale=1" />
8
8
 
9
9
  <link rel="icon" type="image/png" href="/_static/icon.png" />
10
- <link rel="stylesheet" href="/_static/site.css" type="text/css" media="screen" />
11
10
  <link rel="stylesheet" href="/_static/index.css" type="text/css" media="screen" />
11
+ <link rel="stylesheet" href="/_static/slides.css" type="text/css" media="screen" />
12
+ <link rel="stylesheet" href="/_static/playback.css" type="text/css" media="screen" />
12
13
  <link rel="stylesheet" href="/_static/custom.css" type="text/css" media="screen" />
13
14
  <link rel="stylesheet" href="/_components/@socketry/syntax/themes/base/syntax.css" type="text/css" media="screen" />
14
15
  <?r self.stylesheets.each do |stylesheet| ?>
@@ -18,6 +19,7 @@
18
19
  <script type="importmap">
19
20
  {
20
21
  "imports": {
22
+ "morphdom": "/_components/morphdom/morphdom-esm.js",
21
23
  "@socketry/presently": "/_components/@socketry/presently/Presently.js",
22
24
  "@socketry/syntax": "/_components/@socketry/syntax/Syntax.js",
23
25
  "animejs": "/_components/animejs/dist/bundles/anime.esm.min.js"
@@ -30,12 +32,17 @@
30
32
 
31
33
  <body class="playback" data-autoplay="#{self.autoplay}" data-controls="#{self.controls}">
32
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>
33
40
  <?r self.slides.each_with_index do |slide, index| ?>
34
- <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}">
35
42
  #{self.render_slide(slide)}
36
- </div>
43
+ </template>
37
44
  <?r end ?>
38
- </main>
45
+ </div>
39
46
 
40
47
  <div class="playback-audio" hidden>
41
48
  <?r self.slides.each_with_index do |slide, index| ?>
@@ -91,7 +91,8 @@ module Presently
91
91
 
92
92
  builder.tag("presently-recorder",
93
93
  id: "presently-recorder-#{index}",
94
- "data-recording-url": recording_url
94
+ "data-recording-url": recording_url,
95
+ "data-slide-duration": slide.duration
95
96
  ) do
96
97
  builder.tag(:div, class: "recording-actions") do
97
98
  builder.tag(:button, class: "recording-toggle", type: "button"){builder.text("● Record")}
@@ -101,6 +102,13 @@ module Presently
101
102
  end
102
103
 
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
104
112
  builder.tag(:p, class: "recording-status", role: "status"){builder.text("Checking for an existing recording…")}
105
113
  end
106
114
  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
@@ -5,5 +5,5 @@
5
5
 
6
6
  # @namespace
7
7
  module Presently
8
- VERSION = "0.21.0"
8
+ VERSION = "0.23.0"
9
9
  end
@@ -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
 
@@ -41,27 +39,32 @@ export class SlideRendering {
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
+ // @parameter update [Function] Updates the view's DOM.
45
43
  // @returns [Promise(Boolean)] Whether the rendering was completed.
46
- async render(live) {
44
+ async render(update) {
47
45
  if (this.#disposed) return false;
48
46
 
49
47
  let initialized = false;
50
- const update = async () => {
48
+ const render = async () => {
51
49
  if (this.#disposed) return;
52
50
 
53
- live.update(this.#view.id, this.#html);
51
+ await update(this.#view);
54
52
  initialized = await this.initialize();
55
53
  };
56
54
 
57
- if (this.#transitionName && document.startViewTransition) {
55
+ if (this.#transitionName && document.startViewTransition && !document.hidden) {
58
56
  document.documentElement.dataset.transition = this.#transitionName;
59
57
  let viewTransition = null;
60
58
 
61
59
  try {
62
- viewTransition = document.startViewTransition(update);
60
+ viewTransition = document.startViewTransition(render);
63
61
  this.#viewTransition = viewTransition;
64
- await viewTransition.finished;
62
+
63
+ // A hidden document may abort the visual transition, but the DOM update should still complete.
64
+ viewTransition.ready.catch(() => {});
65
+ const finished = viewTransition.finished.catch(() => {});
66
+ await viewTransition.updateCallbackDone;
67
+ await finished;
65
68
  } finally {
66
69
  if (this.#viewTransition === viewTransition) {
67
70
  this.#viewTransition = null;
@@ -69,7 +72,7 @@ export class SlideRendering {
69
72
  }
70
73
  }
71
74
  } else {
72
- await update();
75
+ await render();
73
76
  }
74
77
 
75
78
  if (initialized && !this.#disposed) {
@@ -1,74 +1,7 @@
1
- /* ========================
2
- ARCHITECTURE DIAGRAM
3
- ======================== */
4
-
5
- .arch-diagram {
6
- position: relative;
7
- width: 100%;
8
- height: 80%;
9
- font-family: var(--font-mono);
10
- font-size: 0.85em;
11
- }
12
-
13
- .arch-box {
14
- position: absolute;
15
- border-radius: 8px;
16
- padding: 0.9rem 1rem;
17
- }
18
-
19
- .arch-box-title {
20
- font-weight: bold;
21
- margin-bottom: 0.6rem;
22
- }
23
-
24
- .arch-component {
25
- border-radius: 4px;
26
- padding: 0.35rem 0.5rem;
27
- text-align: center;
28
- margin-bottom: 0.35rem;
29
- }
30
-
31
- .arch-component:last-child {
32
- margin-bottom: 0;
33
- }
34
-
35
- .arch-server {
36
- border: 2px solid #f90;
37
- }
38
-
39
- .arch-server .arch-component {
40
- border: 1px solid #666;
41
- }
42
-
43
- .arch-display {
44
- border: 2px solid #4a9eff;
45
- }
46
-
47
- .arch-display .arch-component {
48
- border: 1px solid #666;
49
- }
50
-
51
- .arch-display .arch-ws {
52
- border-color: #4a9eff;
53
- color: #4a9eff;
54
- }
55
-
56
- .arch-presenter {
57
- border: 2px solid #a78bfa;
58
- }
59
-
60
- .arch-presenter .arch-component {
61
- border: 1px solid #666;
62
- }
63
-
64
- .arch-presenter .arch-ws {
65
- border-color: #a78bfa;
66
- color: #a78bfa;
67
- }
68
-
69
- .arch-arrow {
70
- position: absolute;
71
- text-align: center;
72
- color: #888;
73
- font-size: 0.8em;
74
- }
1
+ /*
2
+ * Add presentation-wide CSS overrides to this file.
3
+ * Prefer colocated slide- and section-specific stylesheets for focused changes:
4
+ * `slides/010-introduction.css` styles `slides/010-introduction.md`, while
5
+ * `slides/020-topic/style.css` styles every slide in `slides/020-topic/`.
6
+ * It intentionally contains no CSS rules by default.
7
+ */
@@ -0,0 +1,25 @@
1
+ /* ========================
2
+ DISPLAY VIEW (audience)
3
+ ======================== */
4
+
5
+ .display {
6
+ width: 100vw;
7
+ height: 100vh;
8
+ background: var(--slide-background);
9
+ position: relative;
10
+ outline: none;
11
+ }
12
+
13
+ .display .slide-container {
14
+ width: 100%;
15
+ height: 100%;
16
+ position: relative;
17
+ }
18
+
19
+ .display .slide-counter {
20
+ position: absolute;
21
+ bottom: 1rem;
22
+ right: 1.5rem;
23
+ font-size: 0.9rem;
24
+ opacity: 0.4;
25
+ }
@@ -0,0 +1,111 @@
1
+ /* ========================
2
+ EXPORT PAGE LAYOUT
3
+ ======================== */
4
+
5
+ /* Allow multi-page layout — overrides the default full-viewport body. */
6
+ body.export {
7
+ height: auto;
8
+ overflow: visible;
9
+ }
10
+
11
+ /*
12
+ * Each export-page div maps to one printed page.
13
+ * Dimensions are set via inline styles from Export::PageSize so they can
14
+ * be configured at export time. CSS just handles layout and overflow.
15
+ */
16
+ .export-page {
17
+ position: relative;
18
+ overflow: hidden;
19
+ break-after: page;
20
+ page-break-after: always;
21
+ }
22
+
23
+ .export-slide-area {
24
+ position: relative;
25
+ overflow: hidden;
26
+ }
27
+
28
+ /* Notes panel below the slide. */
29
+ .export-notes-area {
30
+ padding: 0.5rem 2rem 0.5rem;
31
+ background: #0d0d1f;
32
+ border-top: 2px solid var(--accent);
33
+ display: flex;
34
+ flex-direction: column;
35
+ gap: 0.4rem;
36
+ overflow: hidden;
37
+ }
38
+
39
+ /* Meta strip — mirrors the timing bar in the presenter view. */
40
+ .export-meta {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 1.5rem;
44
+ flex-shrink: 0;
45
+ font-size: 0.75rem;
46
+ opacity: 0.75;
47
+ font-family: var(--font-sans);
48
+ }
49
+
50
+ .export-slide-number {
51
+ font-weight: 600;
52
+ }
53
+
54
+ .export-filename code {
55
+ font-family: var(--font-mono);
56
+ background: rgba(255,255,255,0.08);
57
+ padding: 0.1em 0.35em;
58
+ border-radius: 3px;
59
+ font-size: 0.9em;
60
+ }
61
+
62
+ .export-elapsed,
63
+ .export-duration {
64
+ opacity: 0.85;
65
+ }
66
+
67
+ .export-speaker {
68
+ margin-left: auto;
69
+ font-size: 0.8rem;
70
+ opacity: 0.9;
71
+ }
72
+
73
+ /* Override the shared notes panel to fit the compact export area. */
74
+ .export-notes-area .notes {
75
+ min-height: unset;
76
+ max-height: unset;
77
+ padding: 0;
78
+ background: none;
79
+ border-radius: 0;
80
+ flex: 1;
81
+ overflow: hidden;
82
+ }
83
+
84
+ /* ========================
85
+ PRINT MEDIA
86
+ ======================== */
87
+
88
+ @media print {
89
+ /* Allow full-document layout. */
90
+ html, body {
91
+ height: auto !important;
92
+ overflow: visible !important;
93
+ }
94
+
95
+ /* Ensure backgrounds (dark theme) are included in the PDF. */
96
+ html {
97
+ -webkit-print-color-adjust: exact;
98
+ print-color-adjust: exact;
99
+ }
100
+
101
+ /* Each export-page is one printed page. */
102
+ .export-page {
103
+ break-after: page;
104
+ page-break-after: always;
105
+ }
106
+
107
+ /* Strip the Live element wrapper chrome from the presenter view if somehow printed. */
108
+ live-view {
109
+ display: contents;
110
+ }
111
+ }
@@ -0,0 +1,97 @@
1
+ /* ========================
2
+ HOME
3
+ ======================== */
4
+
5
+ .home {
6
+ width: 100vw;
7
+ height: 100vh;
8
+ overflow: auto;
9
+ display: flex;
10
+ flex-direction: column;
11
+ justify-content: center;
12
+ padding: clamp(2rem, 8vw, 8rem);
13
+ background:
14
+ radial-gradient(circle at top left, color-mix(in srgb, var(--accent) 20%, transparent), transparent 35rem),
15
+ var(--slide-background);
16
+ }
17
+
18
+ .home-heading,
19
+ .home-interfaces {
20
+ width: min(72rem, 100%);
21
+ margin-inline: auto;
22
+ }
23
+
24
+ .home-heading {
25
+ margin-bottom: 3rem;
26
+ }
27
+
28
+ .home-kicker {
29
+ margin: 0 0 0.75rem;
30
+ color: var(--accent-light);
31
+ font-size: 0.9rem;
32
+ font-weight: 700;
33
+ letter-spacing: 0.12em;
34
+ text-transform: uppercase;
35
+ }
36
+
37
+ .home-heading h1 {
38
+ margin: 0;
39
+ font-size: clamp(2.5rem, 6vw, 5rem);
40
+ line-height: 1;
41
+ }
42
+
43
+ .home-introduction {
44
+ max-width: 48rem;
45
+ margin: 1.25rem 0 0;
46
+ font-size: clamp(1.1rem, 2vw, 1.4rem);
47
+ line-height: 1.5;
48
+ opacity: 0.72;
49
+ }
50
+
51
+ .home-interfaces {
52
+ display: grid;
53
+ grid-template-columns: repeat(2, minmax(0, 1fr));
54
+ gap: 1rem;
55
+ }
56
+
57
+ .home-interface {
58
+ display: flex;
59
+ min-height: 10rem;
60
+ flex-direction: column;
61
+ justify-content: space-between;
62
+ gap: 1.5rem;
63
+ padding: 1.75rem;
64
+ border: 1px solid rgba(255, 255, 255, 0.12);
65
+ border-radius: 0.75rem;
66
+ background: color-mix(in srgb, var(--surface) 82%, transparent);
67
+ color: inherit;
68
+ text-decoration: none;
69
+ transition: border-color 150ms ease, transform 150ms ease, background 150ms ease;
70
+ }
71
+
72
+ .home-interface:hover,
73
+ .home-interface:focus-visible {
74
+ border-color: var(--accent-light);
75
+ background: color-mix(in srgb, var(--surface-light) 85%, transparent);
76
+ transform: translateY(-0.2rem);
77
+ outline: none;
78
+ }
79
+
80
+ .home-interface strong {
81
+ font-size: 1.4rem;
82
+ }
83
+
84
+ .home-interface span {
85
+ line-height: 1.5;
86
+ opacity: 0.7;
87
+ }
88
+
89
+ @media (max-width: 48rem) {
90
+ .home {
91
+ justify-content: flex-start;
92
+ }
93
+
94
+ .home-interfaces {
95
+ grid-template-columns: 1fr;
96
+ }
97
+ }