create-bearminion 1.4.0 → 1.6.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +5 -3
  3. package/package.json +3 -3
  4. package/template/README.md +3 -3
  5. package/template/package.json +4 -4
  6. package/template/src/components/atoms/Typography.astro +5 -2
  7. package/template/src/components/sections/SectionList.astro +6 -3
  8. package/template/src/components/sections/definitions.ts +4 -2
  9. package/template/src/components/sections/gallery/Gallery.astro +128 -0
  10. package/template/src/components/sections/gallery/Gallery.stories.ts +24 -0
  11. package/template/src/components/sections/gallery/definition.ts +59 -0
  12. package/template/src/components/sections/gallery/lightbox.ts +159 -0
  13. package/template/src/components/sections/gallery/wireframe.ts +12 -0
  14. package/template/src/components/sections/media/Media.astro +1 -0
  15. package/template/src/components/sections/media/MediaPlayer.astro +29 -1
  16. package/template/src/components/sections/media/definition.ts +6 -1
  17. package/template/src/components/sections/media/source.ts +3 -33
  18. package/template/src/components/sections/registry.ts +4 -2
  19. package/template/src/components/sections/text/Text.astro +43 -0
  20. package/template/src/components/sections/text/Text.stories.ts +17 -0
  21. package/template/src/components/sections/text/definition.ts +36 -0
  22. package/template/src/components/sections/text/wireframe.ts +12 -0
  23. package/template/src/components/seo/Seo.astro +11 -9
  24. package/template/src/lib/content-model.ts +4 -1
  25. package/template/src/lib/migrations.ts +27 -0
  26. package/template/src/styles/global.css +161 -0
  27. package/template/src/components/sections/video/Video.astro +0 -78
  28. package/template/src/components/sections/video/Video.stories.ts +0 -17
  29. package/template/src/components/sections/video/VideoFrame.astro +0 -60
  30. package/template/src/components/sections/video/definition.ts +0 -58
  31. package/template/src/components/sections/video/wireframe.ts +0 -9
@@ -13,6 +13,7 @@ interface Props {
13
13
  poster?: Image
14
14
  image?: Image
15
15
  label?: string
16
+ playback?: MediaProps['playback']
16
17
  /** `data-field` path for the image, so a click on the frame opens the image picker. */
17
18
  imageField: string
18
19
  /** Near the top of the page: the cover loads at once instead of lazily, since it is likely the largest thing on screen. */
@@ -20,7 +21,7 @@ interface Props {
20
21
  class?: string
21
22
  }
22
23
 
23
- const { kind, aspect, video, poster, image, label, imageField, eager = false, class: className } = Astro.props
24
+ const { kind, aspect, video, poster, image, label, playback = 'controls', imageField, eager = false, class: className } = Astro.props
24
25
  const ASPECT = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '21:9': 'aspect-[21/9]', '1:1': 'aspect-square' } as const
25
26
  const frame = cn(
26
27
  'relative w-full min-h-48 overflow-hidden rounded-[clamp(1rem,2cqi,1.5rem)] bg-surface shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]',
@@ -35,6 +36,9 @@ const coverSrcset = source ? await imageSrcset(poster) : undefined
35
36
  // The frame is at most 68rem wide and the full width of a phone.
36
37
  const coverSizes = '(min-width: 72rem) 68rem, 100vw'
37
38
  const name = label || 'Video'
39
+ // A video file on loop starts muted and without controls once the page script sees it on screen. Until then, and without
40
+ // JavaScript or with reduced motion, it keeps its controls so it can still be played.
41
+ const looping = source?.kind === 'file' && playback === 'loop'
38
42
  ---
39
43
 
40
44
  {
@@ -63,6 +67,10 @@ const name = label || 'Video'
63
67
  class="shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]"
64
68
  data-field={imageField}
65
69
  />
70
+ ) : looping && source?.kind === 'file' ? (
71
+ <div class={frame}>
72
+ <video class="absolute inset-0 size-full object-cover" src={source.src} poster={cover} preload="metadata" playsinline muted loop controls aria-label={name} data-video-loop />
73
+ </div>
66
74
  ) : (
67
75
  source && (
68
76
  <div
@@ -145,4 +153,24 @@ const name = label || 'Video'
145
153
  }
146
154
  bindMediaPlayers()
147
155
  document.addEventListener('bearminion:preview', bindMediaPlayers)
156
+
157
+ /** Looping videos play while at least a quarter of them is on screen, and not at all with reduced motion. */
158
+ function bindLoopingVideos() {
159
+ const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
160
+ for (const video of document.querySelectorAll<HTMLVideoElement>('video[data-video-loop]')) {
161
+ if (video.dataset.bound === 'true') continue
162
+ video.dataset.bound = 'true'
163
+ if (reduced) continue
164
+ video.controls = false
165
+ new IntersectionObserver(
166
+ ([entry]) => {
167
+ if (entry?.isIntersecting) void video.play().catch(() => (video.controls = true))
168
+ else video.pause()
169
+ },
170
+ { threshold: 0.25 },
171
+ ).observe(video)
172
+ }
173
+ }
174
+ bindLoopingVideos()
175
+ document.addEventListener('bearminion:preview', bindLoopingVideos)
148
176
  </script>
@@ -6,6 +6,8 @@ import { mediaWireframe } from './wireframe'
6
6
 
7
7
  export const mediaVariants = ['centered', 'wide', 'split'] as const
8
8
  export const mediaKinds = ['video', 'image'] as const
9
+ /** `controls`: the visitor presses play. `loop`: a video file plays silently on repeat while it is on screen, like a moving image. */
10
+ export const mediaPlayback = ['controls', 'loop'] as const
9
11
  export const mediaAspects = ['16:9', '4:3', '21:9', '1:1'] as const
10
12
 
11
13
  /** A YouTube or Vimeo link, a path in `public/` such as `/videos/demo.webm`, or an `https://` link to a video file. */
@@ -32,6 +34,7 @@ export const mediaSchema = withEmphasis({
32
34
  /** What the video shows, read out by screen readers. An image uses its own alt text. */
33
35
  label: textSchema.optional(),
34
36
  caption: textSchema.optional(),
37
+ playback: z.enum(mediaPlayback).default('controls'),
35
38
  })
36
39
 
37
40
  export type MediaProps = z.infer<typeof mediaSchema>
@@ -39,7 +42,7 @@ export type MediaProps = z.infer<typeof mediaSchema>
39
42
  export const media = defineSection({
40
43
  type: 'media',
41
44
  label: 'Media',
42
- description: 'One video or image under a heading. YouTube, Vimeo or a video file.',
45
+ description: 'One video or image under a heading. YouTube, Vimeo or a video file, with a poster and a caption.',
43
46
  schema: mediaSchema,
44
47
  defaults: {
45
48
  eyebrow: 'Demo',
@@ -50,12 +53,14 @@ export const media = defineSection({
50
53
  aspect: '16:9',
51
54
  intro: true,
52
55
  label: 'Product walkthrough',
56
+ playback: 'controls',
53
57
  },
54
58
  variants: mediaVariants,
55
59
  defaultVariant: 'centered',
56
60
  options: [
57
61
  { name: 'kind', label: 'Type', type: 'enum', choices: mediaKinds, default: 'video' },
58
62
  { name: 'aspect', label: 'Shape', type: 'enum', choices: mediaAspects, default: '16:9' },
63
+ { name: 'playback', label: 'Video playback', type: 'enum', choices: mediaPlayback, default: 'controls' },
59
64
  { name: 'intro', label: 'Show heading', type: 'boolean', default: true },
60
65
  ],
61
66
  optional: { props: ['eyebrow', 'heading', 'body', 'video', 'poster', 'image', 'label', 'caption'] },
@@ -1,33 +1,3 @@
1
- /**
2
- * What a Media section's video link points at. YouTube and Vimeo links become privacy-friendly embeds
3
- * (youtube-nocookie.com, Vimeo with Do Not Track) that load only after the visitor presses play. A link to a video file
4
- * plays in a `<video>` element.
5
- */
6
- export type MediaSource =
7
- | { kind: 'youtube'; id: string; embed: string; thumbnail: string }
8
- | { kind: 'vimeo'; id: string; embed: string }
9
- | { kind: 'file'; src: string }
10
-
11
- const YOUTUBE =
12
- /^https?:\/\/(?:(?:www|m)\.)?(?:youtube\.com\/(?:watch\?(?:[^#\s]*&)?v=|embed\/|shorts\/|live\/)|youtube-nocookie\.com\/embed\/|youtu\.be\/)([\w-]{6,})(?:[?&#][^\s]*)?$/i
13
- const VIMEO = /^https?:\/\/(?:(?:www|player)\.)?vimeo\.com\/(?:video\/)?(\d+)(?:[/?#][^\s]*)?$/i
14
- /** A path in `public/` (`/videos/demo.webm`) or an `https://` link to a video file. */
15
- export const VIDEO_FILE = /^(?:\/(?!\/)|https?:\/\/)\S+\.(?:mp4|webm|m4v|mov|ogv)(?:[?#]\S*)?$/i
16
-
17
- export function parseMediaSource(value: string): MediaSource | null {
18
- const src = value.trim()
19
- const youtube = YOUTUBE.exec(src)
20
- if (youtube?.[1]) {
21
- const id = youtube[1]
22
- return {
23
- kind: 'youtube',
24
- id,
25
- embed: `https://www.youtube-nocookie.com/embed/${id}?autoplay=1&rel=0`,
26
- thumbnail: `https://i.ytimg.com/vi/${id}/hqdefault.jpg`,
27
- }
28
- }
29
- const vimeo = VIMEO.exec(src)
30
- if (vimeo?.[1]) return { kind: 'vimeo', id: vimeo[1], embed: `https://player.vimeo.com/video/${vimeo[1]}?autoplay=1&dnt=1` }
31
- if (VIDEO_FILE.test(src)) return { kind: 'file', src }
32
- return null
33
- }
1
+ /** The parser lives in the library, where the editor's video box uses it too. Re-exported so sections keep one import. */
2
+ export { VIDEO_FILE, parseMediaSource } from 'bearminion'
3
+ export type { MediaSource } from 'bearminion'
@@ -3,6 +3,7 @@ import Contact from './contact/Contact.astro'
3
3
  import Cta from './cta/Cta.astro'
4
4
  import Faq from './faq/Faq.astro'
5
5
  import Features from './features/Features.astro'
6
+ import Gallery from './gallery/Gallery.astro'
6
7
  import Hero from './hero/Hero.astro'
7
8
  import LogoStrip from './logo-strip/LogoStrip.astro'
8
9
  import Media from './media/Media.astro'
@@ -10,20 +11,21 @@ import Pricing from './pricing/Pricing.astro'
10
11
  import Stats from './stats/Stats.astro'
11
12
  import Steps from './steps/Steps.astro'
12
13
  import Testimonials from './testimonials/Testimonials.astro'
14
+ import Text from './text/Text.astro'
13
15
  import TextImage from './text-image/TextImage.astro'
14
- import Video from './video/Video.astro'
15
16
 
16
17
  /** Maps a content section `type` to the component that renders it. Definitions live in `definitions.ts`. */
17
18
  export const sectionComponents = {
18
19
  hero: Hero,
19
20
  logoStrip: LogoStrip,
20
21
  steps: Steps,
22
+ gallery: Gallery,
21
23
  features: Features,
24
+ text: Text,
22
25
  textImage: TextImage,
23
26
  stats: Stats,
24
27
  testimonials: Testimonials,
25
28
  pricing: Pricing,
26
- video: Video,
27
29
  media: Media,
28
30
  faq: Faq,
29
31
  cta: Cta,
@@ -0,0 +1,43 @@
1
+ ---
2
+ import type { Section } from '../definitions'
3
+ import Typography from '../../atoms/Typography.astro'
4
+ import SectionHeader from '../../molecules/SectionHeader.astro'
5
+ import SectionShell from '../../molecules/SectionShell.astro'
6
+ import { cn } from '../../../lib/cn'
7
+
8
+ interface Props {
9
+ section: Extract<Section, { type: 'text' }>
10
+ index: number
11
+ motion?: boolean
12
+ }
13
+
14
+ const { section, index, motion = true } = Astro.props
15
+ const props = section.props
16
+ const field = (path: string) => `sections.${index}.props.${path}`
17
+ const reveal = section.motion ?? motion
18
+ const center = section.variant === 'center'
19
+ ---
20
+
21
+ <SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
22
+ <div class={cn('section-container flex flex-col gap-[clamp(1.75rem,3.5cqi,2.75rem)]', center && 'items-center')}>
23
+ <div class={cn('flex w-full flex-col', props.narrow && 'max-w-[42rem]', center && 'items-center text-center')}>
24
+ <SectionHeader
25
+ eyebrow={props.eyebrow}
26
+ heading={props.heading}
27
+ marks={props.marks}
28
+ size="h2-md"
29
+ align={center ? 'center' : 'left'}
30
+ field={field}
31
+ class="gap-5"
32
+ />
33
+ </div>
34
+ <Typography
35
+ as="div"
36
+ size="lead"
37
+ class={cn('w-full', props.narrow && 'max-w-[42rem]', center && 'text-left')}
38
+ data-field={field('body')}
39
+ markdown={props.body}
40
+ richHeadings
41
+ />
42
+ </div>
43
+ </SectionShell>
@@ -0,0 +1,17 @@
1
+ import { sectionArgTypes } from '../../../stories/options'
2
+ import { text } from './definition'
3
+ import Text from './Text.astro'
4
+
5
+ /** The sample pages hold no Text section, so the stories start from its defaults. */
6
+ const section = { id: 'text', type: 'text', variant: 'left', tone: 'base', props: text.defaults } as const
7
+
8
+ export default {
9
+ title: 'Sections/Text',
10
+ component: Text,
11
+ parameters: { layout: 'fullscreen' },
12
+ argTypes: sectionArgTypes('text'),
13
+ }
14
+
15
+ export const Left = { args: { section, index: 0 } }
16
+ export const Center = { args: { section: { ...section, variant: 'center' }, index: 0 } }
17
+ export const FullWidth = { args: { section: { ...section, props: { ...text.defaults, narrow: false } }, index: 0 } }
@@ -0,0 +1,36 @@
1
+ import { z } from 'zod'
2
+ import { defineSection, richTextSchema, withEmphasis } from 'bearminion'
3
+ import { accent, eyebrow, heading } from '../shared'
4
+ import { textWireframe } from './wireframe'
5
+
6
+ export const textVariants = ['left', 'center'] as const
7
+
8
+ export const textSchema = withEmphasis({
9
+ eyebrow,
10
+ heading,
11
+ /** Long-form text. Besides bold, italic, links, code and lists it may hold `##` and `###` headings. */
12
+ body: richTextSchema,
13
+ /** On: a readable column about 65 characters wide. Off: the full width of the page. */
14
+ narrow: z.boolean().default(true),
15
+ })
16
+
17
+ export type TextProps = z.infer<typeof textSchema>
18
+
19
+ export const text = defineSection({
20
+ type: 'text',
21
+ label: 'Text',
22
+ description: 'Plain long-form text with headings, lists and links. For an About, a policy or any simple page.',
23
+ schema: textSchema,
24
+ defaults: {
25
+ eyebrow: 'About',
26
+ heading: 'A page of plain words.',
27
+ marks: accent('A page of plain words.', 'plain words.'),
28
+ body: 'Write as much as you need. Select a few words to make them **bold**, *italic* or a link.\n\n## A heading\n\nHeadings split a long page into parts a reader can scan.\n\n- Lists work too\n- With as many items as you need',
29
+ narrow: true,
30
+ },
31
+ variants: textVariants,
32
+ defaultVariant: 'left',
33
+ options: [{ name: 'narrow', label: 'Narrow column (easier to read)', type: 'boolean', default: true }],
34
+ optional: { props: ['eyebrow', 'heading'] },
35
+ wireframe: textWireframe,
36
+ })
@@ -0,0 +1,12 @@
1
+ import { defineWireframe } from 'bearminion'
2
+
3
+ /** Add section picker thumbnail: [left %, top %, width %, height %, kind]. */
4
+ export const textWireframe = defineWireframe([
5
+ [20, 12, 40, 8, 'title'],
6
+ [20, 30, 60, 3, 'text'],
7
+ [20, 38, 60, 3, 'text'],
8
+ [20, 46, 52, 3, 'text'],
9
+ [20, 58, 30, 5, 'title'],
10
+ [20, 70, 60, 3, 'text'],
11
+ [20, 78, 44, 3, 'text'],
12
+ ])
@@ -1,5 +1,5 @@
1
1
  ---
2
- import type { Page } from 'bearminion'
2
+ import { hasJsonLdId, mergeJsonLd, siteJsonLdIds, type Page } from 'bearminion'
3
3
  import type { Site } from '../organisms/site-definition'
4
4
  import { isRemote, resolveLocalAsset } from '../../lib/assets'
5
5
 
@@ -31,19 +31,21 @@ const robots = [page.seo.noindex ? 'noindex' : 'index', page.seo.nofollow ? 'nof
31
31
  const ogImageSrc = page.seo.ogImage ?? site.seo.ogImage
32
32
  const ogImage = ogImageSrc ? absoluteImage(ogImageSrc) : undefined
33
33
 
34
- const jsonLd = {
34
+ // Blocks from site.seo.jsonLd go on every page. When the site describes itself, the generated WebPage points at those blocks.
35
+ const siteBlocks = site.seo.jsonLd ?? []
36
+ const ids = siteJsonLdIds(site.url)
37
+ const webPage = {
35
38
  '@context': 'https://schema.org',
36
39
  '@type': 'WebPage',
37
40
  name: title,
38
41
  description,
39
42
  url: canonical,
40
43
  ...(ogImage ? { image: ogImage } : {}),
41
- isPartOf: {
42
- '@type': 'WebSite',
43
- name: site.name,
44
- url: site.url,
45
- },
44
+ isPartOf: hasJsonLdId(siteBlocks, ids.website) ? { '@id': ids.website } : { '@type': 'WebSite', name: site.name, url: site.url },
45
+ ...(hasJsonLdId(siteBlocks, ids.organization) ? { publisher: { '@id': ids.organization } } : {}),
46
46
  }
47
+ // A page's own JSON-LD replaces only the generated WebPage. Blocks with the same @id are emitted once, the page's first.
48
+ const blocks = mergeJsonLd(siteBlocks, page.seo.jsonLd ?? [webPage])
47
49
  ---
48
50
 
49
51
  <title>{title}</title>
@@ -60,9 +62,9 @@ const jsonLd = {
60
62
  <meta name="twitter:title" content={title} />
61
63
  <meta name="twitter:description" content={description} />
62
64
  {ogImage && <meta name="twitter:image" content={ogImage} />}
63
- {/* Custom JSON-LD replaces the generated WebPage block. `<` is escaped so a value cannot close the script tag. */}
65
+ {/* `<` is escaped so a value cannot close the script tag. */}
64
66
  {
65
- (page.seo.jsonLd ?? [jsonLd]).map((block) => (
67
+ blocks.map((block) => (
66
68
  <script type="application/ld+json" set:html={JSON.stringify(block).replace(/</g, '\\u003c')} />
67
69
  ))
68
70
  }
@@ -1,6 +1,7 @@
1
1
  import { defineTheme } from 'bearminion'
2
2
  import { siteDefinition } from '../components/organisms/site-definition'
3
3
  import { sectionDefinitions } from '../components/sections/definitions'
4
+ import { migrateVideoToMedia } from './migrations'
4
5
 
5
6
  /**
6
7
  * Everything the editor and the loaders need to know about this site's content: its section types
@@ -20,6 +21,8 @@ export const contentModel = {
20
21
  /**
21
22
  * Raise this when a schema change would reject files written before it, and add `migrations[n]`
22
23
  * to upgrade version n to n + 1. Files without a `schemaVersion` count as version 1.
24
+ * Version 2 folded the Video section into Media.
23
25
  */
24
- schemaVersion: 1,
26
+ schemaVersion: 2,
27
+ migrations: { 1: migrateVideoToMedia },
25
28
  }
@@ -0,0 +1,27 @@
1
+ import type { ContentMigration } from 'bearminion'
2
+
3
+ type Record_ = Record<string, unknown>
4
+ const isRecord = (value: unknown): value is Record_ => typeof value === 'object' && value !== null && !Array.isArray(value)
5
+
6
+ /** The old Video section's layouts, as the Media section names them. */
7
+ const VARIANT: Record<string, string> = { framed: 'centered', wide: 'wide', split: 'split' }
8
+
9
+ /** A Video section is a Media section of kind `video`: its `intro` is the Media `body`, and an empty placeholder frame is no link. */
10
+ export function videoToMedia(section: Record_): Record_ {
11
+ const { intro, video, playback, ...rest } = isRecord(section.props) ? section.props : ({} as Record_)
12
+ const props: Record_ = { ...rest, kind: 'video' }
13
+ if (intro !== undefined) props.body = intro
14
+ if (typeof video === 'string' && video !== 'placeholder') props.video = video
15
+ if (playback === 'loop') props.playback = 'loop'
16
+ const variant = typeof section.variant === 'string' ? (VARIANT[section.variant] ?? 'centered') : 'centered'
17
+ return { ...section, type: 'media', variant, props }
18
+ }
19
+
20
+ /** Version 1 to 2: the Video section is folded into Media. */
21
+ export const migrateVideoToMedia: ContentMigration = (document, kind) => {
22
+ if (kind !== 'page' || !Array.isArray(document.sections)) return document
23
+ return {
24
+ ...document,
25
+ sections: document.sections.map((section) => (isRecord(section) && section.type === 'video' ? videoToMedia(section) : section)),
26
+ }
27
+ }
@@ -229,6 +229,31 @@
229
229
  color: var(--fg);
230
230
  white-space: nowrap;
231
231
  }
232
+ /* Headings in long-form text (the Text section). Sized for reading, below the section heading. */
233
+ [data-rich-headings="true"] h2,
234
+ [data-rich-headings="true"] h3 {
235
+ color: var(--fg);
236
+ font-weight: 600;
237
+ text-wrap: balance;
238
+ }
239
+ [data-rich-headings="true"] h2 {
240
+ margin: 1.75em 0 0.5em;
241
+ font-size: 1.625em;
242
+ line-height: 1.15;
243
+ letter-spacing: -0.03em;
244
+ }
245
+ [data-rich-headings="true"] h3 {
246
+ margin: 1.5em 0 0.4em;
247
+ font-size: 1.25em;
248
+ line-height: 1.25;
249
+ letter-spacing: -0.02em;
250
+ }
251
+ [data-rich-headings="true"] > :first-child {
252
+ margin-top: 0;
253
+ }
254
+ [data-rich-headings="true"] :is(h2, h3) + p {
255
+ margin-top: 0;
256
+ }
232
257
  blockquote[data-field-kind="rich"]::before {
233
258
  content: "“";
234
259
  }
@@ -320,6 +345,142 @@
320
345
  outline: none;
321
346
  border-bottom-color: var(--fg);
322
347
  }
348
+ /* The gallery lightbox: a native <dialog> over the whole screen, one picture at a time. */
349
+ .bm-lightbox {
350
+ /* 100%, not 100vw: vw counts the page scrollbar, which pushed the close button off the right edge. */
351
+ width: 100%;
352
+ max-width: none;
353
+ height: 100%;
354
+ max-height: none;
355
+ overscroll-behavior: contain;
356
+ margin: 0;
357
+ padding: 0;
358
+ border: 0;
359
+ background: rgb(0 0 0 / 0.94);
360
+ color: #fff;
361
+ overflow: hidden;
362
+ }
363
+ .bm-lightbox::backdrop {
364
+ background: rgb(0 0 0 / 0.94);
365
+ }
366
+ .bm-lightbox[open] {
367
+ display: block;
368
+ }
369
+ /* The page behind does not scroll while a picture is open. */
370
+ html:has(.bm-lightbox[open]) {
371
+ overflow: hidden;
372
+ }
373
+ .bm-lightbox-stage {
374
+ position: relative;
375
+ display: grid;
376
+ place-items: center;
377
+ width: 100%;
378
+ height: 100%;
379
+ padding: clamp(3.5rem, 8vh, 5rem) clamp(1rem, 9vw, 6.5rem);
380
+ touch-action: pan-y;
381
+ }
382
+ /* The picture takes whatever height the caption leaves, so a tall picture and a long caption both stay whole. */
383
+ .bm-lightbox-figure {
384
+ display: grid;
385
+ grid-template-rows: minmax(0, 1fr) auto;
386
+ justify-items: center;
387
+ gap: 0.875rem;
388
+ width: 100%;
389
+ height: 100%;
390
+ min-height: 0;
391
+ margin: 0;
392
+ }
393
+ .bm-lightbox-image {
394
+ display: block;
395
+ align-self: center;
396
+ max-width: 100%;
397
+ max-height: 100%;
398
+ min-height: 0;
399
+ width: auto;
400
+ height: auto;
401
+ object-fit: contain;
402
+ border-radius: 8px;
403
+ transition: opacity 0.2s ease;
404
+ }
405
+ .bm-lightbox[data-loading] .bm-lightbox-image {
406
+ opacity: 0;
407
+ }
408
+ .bm-lightbox-caption {
409
+ width: min(100%, 46rem);
410
+ font-size: 0.9375rem;
411
+ line-height: 1.5;
412
+ text-align: center;
413
+ color: rgb(255 255 255 / 0.82);
414
+ text-wrap: pretty;
415
+ max-height: 7.5rem;
416
+ overflow-y: auto;
417
+ }
418
+ .bm-lightbox-counter {
419
+ position: absolute;
420
+ top: 1.375rem;
421
+ left: 1.25rem;
422
+ font-family: var(--font-mono);
423
+ font-size: 0.75rem;
424
+ letter-spacing: 0.06em;
425
+ color: rgb(255 255 255 / 0.7);
426
+ }
427
+ .bm-lightbox-close,
428
+ .bm-lightbox-nav {
429
+ position: absolute;
430
+ display: grid;
431
+ place-items: center;
432
+ width: 2.75rem;
433
+ height: 2.75rem;
434
+ border-radius: 9999px;
435
+ background: rgb(255 255 255 / 0.12);
436
+ color: #fff;
437
+ cursor: pointer;
438
+ transition: background-color 0.15s ease;
439
+ }
440
+ .bm-lightbox-icon {
441
+ display: block;
442
+ width: 1.25rem;
443
+ height: 1.25rem;
444
+ fill: none;
445
+ stroke: currentColor;
446
+ stroke-width: 2;
447
+ stroke-linecap: round;
448
+ stroke-linejoin: round;
449
+ }
450
+ .bm-lightbox-close:hover,
451
+ .bm-lightbox-nav:hover {
452
+ background: rgb(255 255 255 / 0.24);
453
+ }
454
+ .bm-lightbox-close:focus-visible,
455
+ .bm-lightbox-nav:focus-visible {
456
+ outline: 2px solid #fff;
457
+ outline-offset: 2px;
458
+ }
459
+ .bm-lightbox-close {
460
+ top: 0.875rem;
461
+ right: 0.875rem;
462
+ }
463
+ .bm-lightbox-nav {
464
+ top: 50%;
465
+ translate: 0 -50%;
466
+ }
467
+ .bm-lightbox-prev {
468
+ left: 0.875rem;
469
+ }
470
+ .bm-lightbox-next {
471
+ right: 0.875rem;
472
+ }
473
+ .bm-lightbox[data-single] .bm-lightbox-nav,
474
+ .bm-lightbox[data-single] .bm-lightbox-counter {
475
+ display: none;
476
+ }
477
+ @media (prefers-reduced-motion: reduce) {
478
+ .bm-lightbox-image,
479
+ .bm-lightbox-close,
480
+ .bm-lightbox-nav {
481
+ transition: none;
482
+ }
483
+ }
323
484
  .bm-marquee-mask {
324
485
  mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
325
486
  }
@@ -1,78 +0,0 @@
1
- ---
2
- import type { Section } from '../definitions'
3
- import Typography from '../../atoms/Typography.astro'
4
- import SectionHeader from '../../molecules/SectionHeader.astro'
5
- import SectionShell from '../../molecules/SectionShell.astro'
6
- import VideoFrame from './VideoFrame.astro'
7
-
8
- interface Props {
9
- section: Extract<Section, { type: 'video' }>
10
- index: number
11
- motion?: boolean
12
- }
13
-
14
- const { section, index, motion = true } = Astro.props
15
- const props = section.props
16
- const field = (path: string) => `sections.${index}.props.${path}`
17
- const reveal = section.motion ?? motion
18
- const loop = props.playback === 'loop'
19
- const hasHeader = Boolean(props.eyebrow || props.heading || props.intro)
20
- // The empty frame is an editor hint. A built site leaves it out rather than show visitors an instruction.
21
- const showFrame = props.video !== 'placeholder' || import.meta.env.DEV
22
- ---
23
-
24
- <SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
25
- {
26
- section.variant === 'framed' && (
27
- <div class="section-container flex flex-col items-center gap-10">
28
- {hasHeader && (
29
- <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} intro={props.intro} align="center" field={field} />
30
- )}
31
- {showFrame && (<figure class="w-full max-w-5xl">
32
- <VideoFrame src={props.video} poster={props.poster} label={props.label} loop={loop} class="shadow-[0_30px_80px_-40px_rgb(0_0_0/0.45)]" />
33
- {props.caption && (
34
- <Typography as="p" size="sm" color="muted" class="mt-3 text-center" data-field={field('caption')}>
35
- {props.caption}
36
- </Typography>
37
- )}
38
- </figure>)}
39
- </div>
40
- )
41
- }
42
- {
43
- section.variant === 'wide' && (
44
- <div class="flex flex-col gap-10">
45
- {hasHeader && (
46
- <div class="section-container">
47
- <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} intro={props.intro} field={field} />
48
- </div>
49
- )}
50
- {showFrame && (<figure>
51
- <VideoFrame src={props.video} poster={props.poster} label={props.label} loop={loop} roundedClass="rounded-none" />
52
- {props.caption && (
53
- <div class="section-container">
54
- <Typography as="p" size="sm" color="muted" class="mt-3" data-field={field('caption')}>
55
- {props.caption}
56
- </Typography>
57
- </div>
58
- )}
59
- </figure>)}
60
- </div>
61
- )
62
- }
63
- {
64
- section.variant === 'split' && (
65
- <div class="section-container grid items-center gap-12 @min-[860px]:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
66
- {hasHeader && <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} intro={props.intro} field={field} />}
67
- {showFrame && (<figure class:list={[!hasHeader && '@min-[860px]:col-span-2']}>
68
- <VideoFrame src={props.video} poster={props.poster} label={props.label} loop={loop} />
69
- {props.caption && (
70
- <Typography as="p" size="sm" color="muted" class="mt-3" data-field={field('caption')}>
71
- {props.caption}
72
- </Typography>
73
- )}
74
- </figure>)}
75
- </div>
76
- )
77
- }
78
- </SectionShell>
@@ -1,17 +0,0 @@
1
- import { sectionArgTypes } from '../../../stories/options'
2
- import { video } from './definition'
3
- import Video from './Video.astro'
4
-
5
- /** The sample pages hold no video, so the stories use the section's defaults: an empty frame until a file is linked. */
6
- const section = { id: 'video', type: 'video', variant: 'framed', tone: 'base', props: video.defaults } as const
7
-
8
- export default {
9
- title: 'Sections/Video',
10
- component: Video,
11
- parameters: { layout: 'fullscreen' },
12
- argTypes: sectionArgTypes('video'),
13
- }
14
-
15
- export const Framed = { args: { section, index: 0 } }
16
- export const Wide = { args: { section: { ...section, variant: 'wide' }, index: 0 } }
17
- export const Split = { args: { section: { ...section, variant: 'split' }, index: 0 } }