create-bearminion 1.5.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 (30) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +5 -3
  3. package/package.json +2 -2
  4. package/template/README.md +2 -2
  5. package/template/package.json +1 -1
  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/lib/content-model.ts +4 -1
  24. package/template/src/lib/migrations.ts +27 -0
  25. package/template/src/styles/global.css +161 -0
  26. package/template/src/components/sections/video/Video.astro +0 -78
  27. package/template/src/components/sections/video/Video.stories.ts +0 -17
  28. package/template/src/components/sections/video/VideoFrame.astro +0 -60
  29. package/template/src/components/sections/video/definition.ts +0 -58
  30. package/template/src/components/sections/video/wireframe.ts +0 -9
@@ -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,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 } }
@@ -1,60 +0,0 @@
1
- ---
2
- import type { Image } from 'bearminion'
3
- import { imageUrl } from '../../../lib/image-url'
4
- import { cn } from '../../../lib/cn'
5
-
6
- interface Props {
7
- /** A video file URL, or `placeholder` for an empty frame. */
8
- src: string
9
- poster?: Image
10
- label?: string
11
- loop: boolean
12
- roundedClass?: string
13
- class?: string
14
- }
15
-
16
- const { src, poster, label, loop, roundedClass = 'rounded-card-lg', class: className } = Astro.props
17
- const placeholder = src === 'placeholder'
18
-
19
- const posterSrc = await imageUrl(poster)
20
- // A looping video starts muted and without controls once the page script sees it on screen. Until then, and without
21
- // JavaScript or with reduced motion, it keeps its controls so it can still be played.
22
- const playback = loop ? { muted: true, loop: true, controls: true, 'data-video-loop': '' } : { controls: true }
23
- ---
24
-
25
- <div class={cn('relative overflow-hidden bg-surface', placeholder && 'placeholder-stripes aspect-video', roundedClass, className)}>
26
- {
27
- placeholder ? (
28
- <div class="absolute inset-0 flex flex-col items-center justify-center gap-3 p-6 text-center">
29
- <span class="flex size-14 items-center justify-center rounded-full bg-fg text-bg" aria-hidden="true">
30
- <svg viewBox="0 0 16 16" class="ms-0.5 size-5" fill="currentColor"><path d="M4 2.5v11l9-5.5z" /></svg>
31
- </span>
32
- <span class="font-mono text-[0.6875rem] tracking-[0.04em] text-muted">Add a video link in the section settings</span>
33
- </div>
34
- ) : (
35
- <video class="block h-auto w-full" src={src} poster={posterSrc} preload="metadata" playsinline aria-label={label} {...playback} />
36
- )
37
- }
38
- </div>
39
-
40
- <script>
41
- /** Looping videos play while at least a quarter of them is on screen, and not at all with reduced motion. */
42
- function bindLoopingVideos() {
43
- const reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches
44
- for (const video of document.querySelectorAll<HTMLVideoElement>('video[data-video-loop]')) {
45
- if (video.dataset.bound === 'true') continue
46
- video.dataset.bound = 'true'
47
- if (reduced) continue
48
- video.controls = false
49
- new IntersectionObserver(
50
- ([entry]) => {
51
- if (entry?.isIntersecting) void video.play().catch(() => (video.controls = true))
52
- else video.pause()
53
- },
54
- { threshold: 0.25 },
55
- ).observe(video)
56
- }
57
- }
58
- bindLoopingVideos()
59
- document.addEventListener('bearminion:preview', bindLoopingVideos)
60
- </script>
@@ -1,58 +0,0 @@
1
- import { z } from 'zod'
2
- import { defineSection, imageSchema, richTextSchema, textSchema, withEmphasis } from 'bearminion'
3
- import { VIDEO_FILE } from '../media/source'
4
- import { accent, eyebrow, heading } from '../shared'
5
- import { videoWireframe } from './wireframe'
6
-
7
- export const videoVariants = ['framed', 'wide', 'split'] as const
8
-
9
- /** `controls`: the visitor presses play. `loop`: plays silently and on repeat while it is on screen, like a moving image. */
10
- export const videoPlayback = ['controls', 'loop'] as const
11
-
12
- /**
13
- * A video file the browser can play: a path in the site's `public/` folder (`/videos/demo.webm`) or an `https://` link
14
- * to the file. `placeholder` shows an empty frame. A page link such as YouTube does not play in a video element.
15
- */
16
- export const videoSourceSchema = z
17
- .string()
18
- .min(1)
19
- .refine((value) => value === 'placeholder' || VIDEO_FILE.test(value), {
20
- message:
21
- 'Use a link to a video file ending in .mp4 or .webm, or a path in public/ such as /videos/demo.webm. YouTube and similar page links do not play here.',
22
- })
23
-
24
- export const videoSchema = withEmphasis({
25
- eyebrow,
26
- heading,
27
- intro: richTextSchema.optional(),
28
- video: videoSourceSchema,
29
- /** What the video shows, read out by screen readers. */
30
- label: textSchema.optional(),
31
- /** Shown before the video plays and while it loads. */
32
- poster: imageSchema.optional(),
33
- caption: textSchema.optional(),
34
- playback: z.enum(videoPlayback).default('controls'),
35
- })
36
-
37
- export type VideoProps = z.infer<typeof videoSchema>
38
-
39
- export const video = defineSection({
40
- type: 'video',
41
- label: 'Video',
42
- description: 'A video with a heading, a poster image and a caption.',
43
- schema: videoSchema,
44
- defaults: {
45
- eyebrow: 'Watch',
46
- heading: 'See it in action.',
47
- marks: accent('See it in action.', 'in action.'),
48
- intro: 'A short video says more than a page of text. Link an .mp4 or .webm file and add a poster image to show before it plays.',
49
- video: 'placeholder',
50
- label: 'Product walkthrough',
51
- playback: 'controls',
52
- },
53
- variants: videoVariants,
54
- defaultVariant: 'framed',
55
- options: [{ name: 'playback', label: 'Playback', type: 'enum', choices: videoPlayback, default: 'controls' }],
56
- optional: { props: ['eyebrow', 'heading', 'intro', 'label', 'poster', 'caption'] },
57
- wireframe: videoWireframe,
58
- })
@@ -1,9 +0,0 @@
1
- import { defineWireframe } from 'bearminion'
2
-
3
- /** Add section picker thumbnail: [left %, top %, width %, height %, kind]. */
4
- export const videoWireframe = defineWireframe([
5
- [30, 10, 40, 8, 'title'],
6
- [24, 22, 52, 4, 'text'],
7
- [12, 32, 76, 58, 'dark'],
8
- [45, 54, 10, 14, 'light'],
9
- ])