create-bearminion 1.1.0 → 1.2.1

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 (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/package.json +2 -2
  4. package/template/README.md +6 -6
  5. package/template/content/pages/index.json +4 -0
  6. package/template/package.json +1 -1
  7. package/template/src/components/atoms/typography.ts +3 -2
  8. package/template/src/components/molecules/CommandLine.astro +109 -0
  9. package/template/src/components/molecules/SectionHeader.astro +1 -1
  10. package/template/src/components/sections/SectionList.astro +9 -0
  11. package/template/src/components/sections/cta/Cta.astro +36 -11
  12. package/template/src/components/sections/cta/definition.ts +16 -2
  13. package/template/src/components/sections/definitions.ts +6 -0
  14. package/template/src/components/sections/faq/Faq.astro +9 -4
  15. package/template/src/components/sections/faq/definition.ts +6 -3
  16. package/template/src/components/sections/features/Features.astro +40 -15
  17. package/template/src/components/sections/features/definition.ts +2 -12
  18. package/template/src/components/sections/hero/Hero.astro +60 -30
  19. package/template/src/components/sections/hero/HeroBadge.astro +26 -0
  20. package/template/src/components/sections/hero/definition.ts +41 -3
  21. package/template/src/components/sections/media/Media.astro +117 -0
  22. package/template/src/components/sections/media/Media.stories.ts +31 -0
  23. package/template/src/components/sections/media/MediaPlayer.astro +148 -0
  24. package/template/src/components/sections/media/definition.ts +72 -0
  25. package/template/src/components/sections/media/source.ts +33 -0
  26. package/template/src/components/sections/media/wireframe.ts +9 -0
  27. package/template/src/components/sections/registry.ts +6 -0
  28. package/template/src/components/sections/steps/Steps.astro +139 -0
  29. package/template/src/components/sections/steps/Steps.stories.ts +27 -0
  30. package/template/src/components/sections/steps/definition.ts +62 -0
  31. package/template/src/components/sections/steps/icons.ts +27 -0
  32. package/template/src/components/sections/steps/wireframe.ts +16 -0
  33. package/template/src/components/sections/text-image/TextImage.astro +2 -4
  34. package/template/src/components/sections/text-image/definition.ts +3 -2
  35. package/template/src/components/sections/video/Video.astro +78 -0
  36. package/template/src/components/sections/video/Video.stories.ts +17 -0
  37. package/template/src/components/sections/video/VideoFrame.astro +60 -0
  38. package/template/src/components/sections/video/definition.ts +58 -0
  39. package/template/src/components/sections/video/wireframe.ts +9 -0
  40. package/template/src/layouts/Base.astro +1 -1
  41. package/template/src/lib/image-url.ts +38 -0
  42. package/template/src/styles/global.css +18 -4
@@ -4,6 +4,8 @@ import Button from '../../atoms/Button.astro'
4
4
  import { buttonLinkProps, buttonVariant } from '../../../lib/link-attrs'
5
5
  import Emphasis from '../../atoms/Emphasis.astro'
6
6
  import Typography from '../../atoms/Typography.astro'
7
+ import CommandLine from '../../molecules/CommandLine.astro'
8
+ import HeroBadge from './HeroBadge.astro'
7
9
  import MediaFrame from '../../molecules/MediaFrame.astro'
8
10
  import Pill from '../../molecules/Pill.astro'
9
11
  import SectionShell from '../../molecules/SectionShell.astro'
@@ -18,16 +20,34 @@ const { section, index, motion = true } = Astro.props
18
20
  const props = section.props
19
21
  const field = (path: string) => `sections.${index}.props.${path}`
20
22
  const reveal = section.motion ?? motion
21
- const hasActions = Boolean(props.primary || props.secondary)
23
+ const primary = props.button ? props.primary : undefined
24
+ const command = props.code !== 'off' ? props.command : undefined
25
+ // `action` puts the command in the button row, in the secondary button's place.
26
+ const action = props.code === 'action' ? command : undefined
27
+ const secondary = action ? undefined : props.secondary
28
+ const below = props.code === 'chip' || props.code === 'terminal' ? command : undefined
29
+ const belowVariant = props.code === 'terminal' ? 'terminal' : 'chip'
30
+ const hasActions = Boolean(primary || secondary || action)
22
31
  const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor beside live page preview' }
23
32
  ---
24
33
 
25
34
  <SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
35
+ {
36
+ // Centred on the heading and faded out well before the edges, so it never meets the section above in a seam.
37
+ props.glow && (
38
+ <div
39
+ class="pointer-events-none absolute inset-0"
40
+ style="background: radial-gradient(ellipse 70% 60% at 50% 36%, color-mix(in oklch, var(--accent) var(--hero-glow, 20%), transparent), transparent 72%)"
41
+ aria-hidden="true"
42
+ data-hero-glow
43
+ />
44
+ )
45
+ }
26
46
  {
27
47
  section.variant === 'split' && (
28
- <div class="mx-auto grid max-w-[80rem] grid-cols-[repeat(auto-fit,minmax(min(100%,26rem),1fr))] items-center gap-[clamp(2.5rem,5cqi,5rem)] px-[clamp(1.25rem,4cqi,3rem)] py-[clamp(3.5rem,2rem+5cqi,7rem)]">
48
+ <div class="relative mx-auto grid max-w-[80rem] grid-cols-[repeat(auto-fit,minmax(min(100%,26rem),1fr))] items-center gap-[clamp(2.5rem,5cqi,5rem)] px-[clamp(1.25rem,4cqi,3rem)] py-[clamp(3.5rem,2rem+5cqi,7rem)]">
29
49
  <div class="flex flex-col items-start gap-6">
30
- {props.eyebrow && <Pill data-field={field('eyebrow')}>{props.eyebrow}</Pill>}
50
+ {props.badge ? <HeroBadge badge={props.badge} field={field} /> : props.eyebrow && <Pill data-field={field('eyebrow')}>{props.eyebrow}</Pill>}
31
51
  {
32
52
  props.heading && (
33
53
  <Typography as="h1" size="display">
@@ -39,19 +59,21 @@ const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor
39
59
  {
40
60
  hasActions && (
41
61
  <div class="mt-2 flex flex-wrap gap-2.5">
42
- {props.primary && (
43
- <Button {...buttonLinkProps(props.primary)} variant={buttonVariant(props.primary, 'primary')} arrow data-field={field('primary.label')}>
44
- {props.primary.label}
62
+ {primary && (
63
+ <Button {...buttonLinkProps(primary)} variant={buttonVariant(primary, 'primary')} arrow data-field={field('primary.label')}>
64
+ {primary.label}
45
65
  </Button>
46
66
  )}
47
- {props.secondary && (
48
- <Button {...buttonLinkProps(props.secondary)} variant={buttonVariant(props.secondary, 'secondary')} data-field={field('secondary.label')}>
49
- {props.secondary.label}
67
+ {secondary && (
68
+ <Button {...buttonLinkProps(secondary)} variant={buttonVariant(secondary, 'secondary')} data-field={field('secondary.label')}>
69
+ {secondary.label}
50
70
  </Button>
51
71
  )}
72
+ {action && <CommandLine command={action} variant="action" data-field={field('command')} />}
52
73
  </div>
53
74
  )
54
75
  }
76
+ {below && <CommandLine command={below} variant={belowVariant} data-field={field('command')} />}
55
77
  </div>
56
78
  {!props.image?.hidden && (
57
79
  <MediaFrame src={media.src} alt={media.alt} aspectClass="aspect-[4/3.4]" fit={media?.fit} position={media?.position} data-field={field('image.alt')} priority />
@@ -61,8 +83,8 @@ const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor
61
83
  }
62
84
  {
63
85
  section.variant === 'centered' && (
64
- <div class="mx-auto flex max-w-[80rem] flex-col items-center gap-6 px-[clamp(1.25rem,4cqi,3rem)] pt-[clamp(4rem,2rem+6cqi,8rem)] pb-[clamp(3rem,2rem+4cqi,6rem)] text-center">
65
- {props.eyebrow && <Pill data-field={field('eyebrow')}>{props.eyebrow}</Pill>}
86
+ <div class="relative mx-auto flex max-w-[80rem] flex-col items-center gap-6 px-[clamp(1.25rem,4cqi,3rem)] pt-[clamp(4rem,2rem+6cqi,8rem)] pb-[clamp(5rem,3rem+7cqi,10rem)] text-center">
87
+ {props.badge ? <HeroBadge badge={props.badge} field={field} /> : props.eyebrow && <Pill data-field={field('eyebrow')}>{props.eyebrow}</Pill>}
66
88
  {
67
89
  props.heading && (
68
90
  <Typography as="h1" size="display-center">
@@ -74,19 +96,21 @@ const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor
74
96
  {
75
97
  hasActions && (
76
98
  <div class="mt-2 flex flex-wrap justify-center gap-2.5">
77
- {props.primary && (
78
- <Button {...buttonLinkProps(props.primary)} variant={buttonVariant(props.primary, 'primary')} arrow data-field={field('primary.label')}>
79
- {props.primary.label}
99
+ {primary && (
100
+ <Button {...buttonLinkProps(primary)} variant={buttonVariant(primary, 'primary')} arrow data-field={field('primary.label')}>
101
+ {primary.label}
80
102
  </Button>
81
103
  )}
82
- {props.secondary && (
83
- <Button {...buttonLinkProps(props.secondary)} variant={buttonVariant(props.secondary, 'secondary')} data-field={field('secondary.label')}>
84
- {props.secondary.label}
104
+ {secondary && (
105
+ <Button {...buttonLinkProps(secondary)} variant={buttonVariant(secondary, 'secondary')} data-field={field('secondary.label')}>
106
+ {secondary.label}
85
107
  </Button>
86
108
  )}
109
+ {action && <CommandLine command={action} variant="action" data-field={field('command')} />}
87
110
  </div>
88
111
  )
89
112
  }
113
+ {below && <CommandLine command={below} variant={belowVariant} data-field={field('command')} class="mt-1" />}
90
114
  {!props.image?.hidden && (
91
115
  <MediaFrame
92
116
  src={media.src}
@@ -103,7 +127,7 @@ const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor
103
127
  }
104
128
  {
105
129
  section.variant === 'editorial' && (
106
- <div class="mx-auto max-w-[80rem] px-[clamp(1.25rem,4cqi,3rem)] pt-[clamp(3.5rem,2rem+5cqi,7rem)] pb-[clamp(2.5rem,2rem+3cqi,5rem)]">
130
+ <div class="relative mx-auto max-w-[80rem] px-[clamp(1.25rem,4cqi,3rem)] pt-[clamp(3.5rem,2rem+5cqi,7rem)] pb-[clamp(2.5rem,2rem+3cqi,5rem)]">
107
131
  {
108
132
  props.eyebrow && (
109
133
  <Typography as="p" size="eyebrow" color="muted" data-field={field('eyebrow')}>
@@ -119,21 +143,27 @@ const media = props.image ?? { src: 'placeholder', alt: 'hero visual — editor
119
143
  )
120
144
  }
121
145
  {
122
- (props.sub || hasActions) && (
146
+ (props.sub || hasActions || below) && (
123
147
  <div class="mt-[clamp(2rem,4cqi,3.5rem)] flex flex-wrap items-end justify-between gap-x-12 gap-y-6 border-t border-[var(--line)] pt-6">
124
148
  {props.sub && <Typography as="p" size="lead" color="muted" data-field={field('sub')} class="max-w-xl flex-[1_1_22rem]" markdown={props.sub} />}
125
- {hasActions && (
126
- <div class="flex flex-wrap gap-2.5">
127
- {props.secondary && (
128
- <Button {...buttonLinkProps(props.secondary)} variant={buttonVariant(props.secondary, 'secondary')} data-field={field('secondary.label')}>
129
- {props.secondary.label}
130
- </Button>
131
- )}
132
- {props.primary && (
133
- <Button {...buttonLinkProps(props.primary)} variant={buttonVariant(props.primary, 'primary')} arrow data-field={field('primary.label')}>
134
- {props.primary.label}
135
- </Button>
149
+ {(hasActions || below) && (
150
+ <div class="flex flex-col items-start gap-4">
151
+ {hasActions && (
152
+ <div class="flex flex-wrap items-center gap-2.5">
153
+ {action && <CommandLine command={action} variant="action" data-field={field('command')} />}
154
+ {secondary && (
155
+ <Button {...buttonLinkProps(secondary)} variant={buttonVariant(secondary, 'secondary')} data-field={field('secondary.label')}>
156
+ {secondary.label}
157
+ </Button>
158
+ )}
159
+ {primary && (
160
+ <Button {...buttonLinkProps(primary)} variant={buttonVariant(primary, 'primary')} arrow data-field={field('primary.label')}>
161
+ {primary.label}
162
+ </Button>
163
+ )}
164
+ </div>
136
165
  )}
166
+ {below && <CommandLine command={below} variant={belowVariant} data-field={field('command')} />}
137
167
  </div>
138
168
  )}
139
169
  </div>
@@ -0,0 +1,26 @@
1
+ ---
2
+ import { anchorAttrs } from '../../../lib/link-attrs'
3
+ import type { HeroProps } from './definition'
4
+
5
+ interface Props {
6
+ badge: NonNullable<HeroProps['badge']>
7
+ field: (path: string) => string
8
+ }
9
+
10
+ const { badge, field } = Astro.props
11
+ ---
12
+
13
+ <a
14
+ {...anchorAttrs({ href: badge.href })}
15
+ class="inline-flex h-9 max-w-full items-center gap-2.5 rounded-full bg-surface ps-1.5 pe-3.5 text-[0.875rem] text-fg ring-line transition-shadow hover:shadow-[inset_0_0_0_1px_var(--fg)]"
16
+ >
17
+ {
18
+ badge.tag && (
19
+ <span class="inline-flex h-6 shrink-0 items-center rounded-full bg-accent px-2.5 text-[0.8125rem] font-semibold tracking-[-0.01em] text-on" data-field={field('badge.tag')}>
20
+ {badge.tag}
21
+ </span>
22
+ )
23
+ }
24
+ <span class="truncate font-medium" data-field={field('badge.text')}>{badge.text}</span>
25
+ <span class="text-muted" aria-hidden="true">→</span>
26
+ </a>
@@ -1,16 +1,39 @@
1
- import type { z } from 'zod'
2
- import { defineSection, imageSchema, linkSchema, richTextSchema, withEmphasis } from 'bearminion'
1
+ import { z } from 'zod'
2
+ import { defineSection, imageSchema, linkSchema, richTextSchema, textSchema, withEmphasis } from 'bearminion'
3
3
  import { accent, eyebrow, heading } from '../shared'
4
4
  import { heroWireframe } from './wireframe'
5
5
 
6
6
  export const heroVariants = ['split', 'centered', 'editorial'] as const
7
7
 
8
+ /**
9
+ * How the install command shows. `off` hides it. `action` is a pill in the button row that takes the secondary
10
+ * button's place, `chip` a smaller pill under the buttons, `terminal` a dark block under the buttons.
11
+ */
12
+ export const heroCodeStyles = ['off', 'action', 'chip', 'terminal'] as const
13
+
14
+ /** A linked pill with a short tag, such as "1.2 · Available now". It takes the eyebrow's place in split and centered. */
15
+ export const heroBadgeSchema = z
16
+ .object({
17
+ tag: textSchema.optional(),
18
+ text: textSchema,
19
+ href: z.string().min(1),
20
+ })
21
+ .strict()
22
+
8
23
  export const heroSchema = withEmphasis({
9
24
  eyebrow,
10
25
  heading,
11
26
  sub: richTextSchema.optional(),
27
+ badge: heroBadgeSchema.optional(),
12
28
  primary: linkSchema.optional(),
13
29
  secondary: linkSchema.optional(),
30
+ /** Off hides the primary button and keeps it in the file, for a page whose only action is the command. */
31
+ button: z.boolean().default(true),
32
+ /** The shell command the Copy button copies, such as `npm create bearminion@latest`. */
33
+ command: textSchema.optional(),
34
+ code: z.enum(heroCodeStyles).default('off'),
35
+ /** A soft light in the accent color behind the heading. It follows the tone and the site palette. */
36
+ glow: z.boolean().default(false),
14
37
  image: imageSchema.optional(),
15
38
  })
16
39
 
@@ -28,9 +51,24 @@ export const hero = defineSection({
28
51
  sub: 'Use this paragraph to say who it is for and what they get. Keep it to two sentences.',
29
52
  primary: { label: 'Primary action', href: '#' },
30
53
  secondary: { label: 'Secondary action', href: '#' },
54
+ button: true,
55
+ command: 'npm create astro@latest',
56
+ code: 'off',
57
+ glow: false,
31
58
  },
32
59
  variants: heroVariants,
33
60
  defaultVariant: 'split',
34
- optional: { props: ['eyebrow', 'heading', 'sub', 'primary', 'secondary', 'image'] },
61
+ options: [
62
+ { name: 'button', label: 'Primary button', type: 'boolean', default: true },
63
+ { name: 'code', label: 'Install command', type: 'enum', choices: heroCodeStyles, default: 'off' },
64
+ { name: 'glow', label: 'Background glow', type: 'boolean', default: false },
65
+ ],
66
+ optional: { props: ['eyebrow', 'heading', 'sub', 'badge', 'primary', 'secondary', 'command', 'image'] },
67
+ showWhen: {
68
+ badge: { variant: ['split', 'centered'] },
69
+ primary: { button: true },
70
+ secondary: { code: ['off', 'chip', 'terminal'] },
71
+ command: { code: ['action', 'chip', 'terminal'] },
72
+ },
35
73
  wireframe: heroWireframe,
36
74
  })
@@ -0,0 +1,117 @@
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 MediaPlayer from './MediaPlayer.astro'
7
+ import { parseMediaSource } from './source'
8
+
9
+ interface Props {
10
+ section: Extract<Section, { type: 'media' }>
11
+ index: number
12
+ motion?: boolean
13
+ }
14
+
15
+ const { section, index, motion = true } = Astro.props
16
+ const props = section.props
17
+ const field = (path: string) => `sections.${index}.props.${path}`
18
+ const reveal = section.motion ?? motion
19
+ const intro = props.intro && Boolean(props.eyebrow || props.heading || props.body)
20
+ const filled = props.kind === 'video' ? Boolean(props.video && parseMediaSource(props.video)) : Boolean(props.image?.src && props.image.src !== 'placeholder' && !props.image.hidden)
21
+ // A built site leaves out an empty frame, and with no heading either, the whole section.
22
+ const showFrame = filled || import.meta.env.DEV
23
+ const rendered = showFrame || intro
24
+ const player = {
25
+ kind: props.kind,
26
+ aspect: props.aspect,
27
+ video: props.video,
28
+ poster: props.poster,
29
+ image: props.image,
30
+ label: props.label,
31
+ imageField: field('image.alt'),
32
+ // The first two sections are usually on screen at load, where a lazy cover would hold back the largest paint.
33
+ eager: index <= 1,
34
+ }
35
+ ---
36
+
37
+ {rendered && (<SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
38
+ {
39
+ section.variant === 'centered' && (
40
+ <div
41
+ class:list={[
42
+ 'mx-auto flex max-w-[80rem] flex-col items-center gap-5 px-[clamp(1.25rem,4cqi,3rem)] pb-[clamp(4rem,2rem+6cqi,8rem)] text-center',
43
+ intro && 'pt-[clamp(4rem,2rem+6cqi,8rem)]',
44
+ ]}
45
+ >
46
+ {intro && (
47
+ <SectionHeader
48
+ eyebrow={props.eyebrow}
49
+ heading={props.heading}
50
+ marks={props.marks}
51
+ intro={props.body}
52
+ introField="body"
53
+ align="center"
54
+ size="h2-md"
55
+ field={field}
56
+ headingClass="max-w-[20ch]"
57
+ class="gap-5"
58
+ />
59
+ )}
60
+ {showFrame && (<figure class:list={['flex w-full flex-col gap-3', intro ? 'mt-[clamp(1.5rem,3cqi,2.5rem)] max-w-[56rem]' : 'max-w-[68rem]']}>
61
+ <MediaPlayer {...player} />
62
+ {props.caption && (
63
+ <Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
64
+ {props.caption}
65
+ </Typography>
66
+ )}
67
+ </figure>)}
68
+ </div>
69
+ )
70
+ }
71
+ {
72
+ section.variant === 'wide' && (
73
+ <div class="section-container flex flex-col gap-[clamp(2rem,4cqi,3.5rem)]">
74
+ {intro && (
75
+ <div class="flex flex-wrap items-end justify-between gap-x-12 gap-y-5">
76
+ <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} size="h2-md" field={field} headingClass="max-w-[16ch]" class="flex-[1_1_24rem] gap-5" />
77
+ {props.body && <Typography as="p" size="lead" color="muted" data-field={field('body')} class="flex-[0_1_26rem]" markdown={props.body} />}
78
+ </div>
79
+ )}
80
+ {showFrame && (<figure class="flex flex-col gap-3">
81
+ <MediaPlayer {...player} />
82
+ {props.caption && (
83
+ <Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
84
+ {props.caption}
85
+ </Typography>
86
+ )}
87
+ </figure>)}
88
+ </div>
89
+ )
90
+ }
91
+ {
92
+ section.variant === 'split' && (
93
+ <div class="section-container grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] items-center gap-[clamp(2.5rem,5cqi,5rem)]">
94
+ {intro && (
95
+ <SectionHeader
96
+ eyebrow={props.eyebrow}
97
+ heading={props.heading}
98
+ marks={props.marks}
99
+ intro={props.body}
100
+ introField="body"
101
+ size="h2-md"
102
+ field={field}
103
+ class="gap-5"
104
+ />
105
+ )}
106
+ {showFrame && (<figure class:list={['flex flex-col gap-3', !intro && 'col-span-full']}>
107
+ <MediaPlayer {...player} />
108
+ {props.caption && (
109
+ <Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
110
+ {props.caption}
111
+ </Typography>
112
+ )}
113
+ </figure>)}
114
+ </div>
115
+ )
116
+ }
117
+ </SectionShell>)}
@@ -0,0 +1,31 @@
1
+ import { sectionArgTypes } from '../../../stories/options'
2
+ import { media } from './definition'
3
+ import Media from './Media.astro'
4
+
5
+ /** The sample pages hold no Media section, so the stories start from its defaults. */
6
+ const section = { id: 'media', type: 'media', variant: 'centered', tone: 'subtle', props: media.defaults } as const
7
+ // A file path rather than a YouTube link, so Storybook loads nothing from a third party.
8
+ const linked = { ...media.defaults, video: '/videos/demo.webm' }
9
+
10
+ export default {
11
+ title: 'Sections/Media',
12
+ component: Media,
13
+ parameters: { layout: 'fullscreen' },
14
+ argTypes: sectionArgTypes('media'),
15
+ }
16
+
17
+ export const Empty = { args: { section, index: 0 } }
18
+ export const Centered = { args: { section: { ...section, props: linked }, index: 0 } }
19
+ export const NoIntro = { args: { section: { ...section, tone: 'base', props: { ...linked, intro: false } }, index: 0 } }
20
+ export const Wide = { args: { section: { ...section, variant: 'wide', props: linked }, index: 0 } }
21
+ export const Split = { args: { section: { ...section, variant: 'split', props: { ...linked, aspect: '4:3' } }, index: 0 } }
22
+ export const Image = {
23
+ args: {
24
+ section: {
25
+ ...section,
26
+ variant: 'split',
27
+ props: { ...media.defaults, kind: 'image', image: { src: 'placeholder', alt: 'Screenshot' } },
28
+ },
29
+ index: 0,
30
+ },
31
+ }
@@ -0,0 +1,148 @@
1
+ ---
2
+ import type { Image } from 'bearminion'
3
+ import { imageSrcset, imageUrl } from '../../../lib/image-url'
4
+ import { cn } from '../../../lib/cn'
5
+ import MediaFrame from '../../molecules/MediaFrame.astro'
6
+ import type { MediaProps } from './definition'
7
+ import { parseMediaSource } from './source'
8
+
9
+ interface Props {
10
+ kind: MediaProps['kind']
11
+ aspect: MediaProps['aspect']
12
+ video?: string
13
+ poster?: Image
14
+ image?: Image
15
+ label?: string
16
+ /** `data-field` path for the image, so a click on the frame opens the image picker. */
17
+ imageField: string
18
+ /** Near the top of the page: the cover loads at once instead of lazily, since it is likely the largest thing on screen. */
19
+ eager?: boolean
20
+ class?: string
21
+ }
22
+
23
+ const { kind, aspect, video, poster, image, label, imageField, eager = false, class: className } = Astro.props
24
+ const ASPECT = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '21:9': 'aspect-[21/9]', '1:1': 'aspect-square' } as const
25
+ const frame = cn(
26
+ '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)]',
27
+ ASPECT[aspect],
28
+ className,
29
+ )
30
+ const source = kind === 'video' && video ? parseMediaSource(video) : null
31
+ const hasImage = kind === 'image' && Boolean(image?.src) && image?.src !== 'placeholder' && !image?.hidden
32
+ const empty = kind === 'video' ? !source : !hasImage
33
+ const cover = source ? ((await imageUrl(poster)) ?? (source.kind === 'youtube' ? source.thumbnail : undefined)) : undefined
34
+ const coverSrcset = source ? await imageSrcset(poster) : undefined
35
+ // The frame is at most 68rem wide and the full width of a phone.
36
+ const coverSizes = '(min-width: 72rem) 68rem, 100vw'
37
+ const name = label || 'Video'
38
+ ---
39
+
40
+ {
41
+ empty ? (
42
+ // The empty frame is an editor hint, so a built site leaves it out.
43
+ import.meta.env.DEV && (
44
+ <div class={cn(frame, 'placeholder-stripes flex flex-col items-center justify-center gap-4 p-6 text-center')} data-field={kind === 'image' ? imageField : undefined}>
45
+ {kind === 'video' && (
46
+ <span class="flex size-14 items-center justify-center rounded-full bg-fg text-bg" aria-hidden="true">
47
+ <svg viewBox="0 0 24 24" class="ms-0.5 size-[18px]" fill="currentColor"><path d="M8 5.5v13l11-6.5z" /></svg>
48
+ </span>
49
+ )}
50
+ <span class="font-mono text-[0.75rem] tracking-[0.02em] text-muted">
51
+ {kind === 'video' ? 'Add a video link in the section settings' : 'Add an image in the section settings'}
52
+ </span>
53
+ </div>
54
+ )
55
+ ) : kind === 'image' && image ? (
56
+ <MediaFrame
57
+ src={image.src}
58
+ alt={image.alt}
59
+ fit={image.fit === 'auto' ? 'cover' : image.fit}
60
+ position={image.position}
61
+ aspectClass={cn(ASPECT[aspect], 'min-h-48 w-full p-0')}
62
+ roundedClass="rounded-[clamp(1rem,2cqi,1.5rem)]"
63
+ class="shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]"
64
+ data-field={imageField}
65
+ />
66
+ ) : (
67
+ source && (
68
+ <div
69
+ class={frame}
70
+ data-media-player
71
+ data-kind={source.kind}
72
+ data-src={source.kind === 'file' ? source.src : source.embed}
73
+ data-label={name}
74
+ data-poster={source.kind === 'file' ? cover : undefined}
75
+ >
76
+ {cover && (
77
+ <img
78
+ src={cover}
79
+ srcset={coverSrcset}
80
+ sizes={coverSrcset ? coverSizes : undefined}
81
+ alt=""
82
+ class="absolute inset-0 size-full object-cover"
83
+ loading={eager ? 'eager' : 'lazy'}
84
+ decoding="async"
85
+ />
86
+ )}
87
+ <button
88
+ type="button"
89
+ class="group absolute inset-0 grid cursor-pointer place-items-center bg-[oklch(0.15_0.01_255/0.12)] transition-colors hover:bg-[oklch(0.15_0.01_255/0.22)] focus-visible:outline-2 focus-visible:outline-offset-[-4px] focus-visible:outline-accent"
90
+ aria-label={`Play video: ${name}`}
91
+ data-media-play
92
+ >
93
+ <span class="grid size-16 place-items-center rounded-full bg-fg text-bg shadow-[0_12px_32px_-12px_oklch(0.15_0.02_255/0.6)] transition-transform group-hover:scale-105">
94
+ <svg viewBox="0 0 24 24" class="ms-0.5 size-5" fill="currentColor" aria-hidden="true"><path d="M8 5.5v13l11-6.5z" /></svg>
95
+ </span>
96
+ </button>
97
+ </div>
98
+ )
99
+ )
100
+ }
101
+
102
+ <script>
103
+ /**
104
+ * Nothing is loaded from YouTube, Vimeo or the video file until the visitor presses play. Then the button and cover
105
+ * are swapped for the player, which starts at once.
106
+ */
107
+ function play(frame: HTMLElement) {
108
+ const src = frame.dataset.src
109
+ if (!src) return
110
+ const label = frame.dataset.label ?? 'Video'
111
+ let player: HTMLElement
112
+ if (frame.dataset.kind === 'file') {
113
+ const video = document.createElement('video')
114
+ video.src = src
115
+ if (frame.dataset.poster) video.poster = frame.dataset.poster
116
+ video.controls = true
117
+ video.autoplay = true
118
+ video.playsInline = true
119
+ video.setAttribute('aria-label', label)
120
+ video.className = 'absolute inset-0 size-full bg-black object-contain'
121
+ player = video
122
+ } else {
123
+ const iframe = document.createElement('iframe')
124
+ iframe.src = src
125
+ iframe.title = label
126
+ iframe.allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media'
127
+ iframe.allowFullscreen = true
128
+ iframe.referrerPolicy = 'strict-origin-when-cross-origin'
129
+ iframe.className = 'absolute inset-0 size-full border-0'
130
+ player = iframe
131
+ }
132
+ frame.replaceChildren(player)
133
+ player.focus()
134
+ }
135
+
136
+ function bindMediaPlayers() {
137
+ for (const button of document.querySelectorAll<HTMLButtonElement>('[data-media-play]')) {
138
+ if (button.dataset.bound === 'true') continue
139
+ button.dataset.bound = 'true'
140
+ button.addEventListener('click', () => {
141
+ const frame = button.closest<HTMLElement>('[data-media-player]')
142
+ if (frame) play(frame)
143
+ })
144
+ }
145
+ }
146
+ bindMediaPlayers()
147
+ document.addEventListener('bearminion:preview', bindMediaPlayers)
148
+ </script>
@@ -0,0 +1,72 @@
1
+ import { z } from 'zod'
2
+ import { defineSection, imageSchema, richTextSchema, textSchema, withEmphasis } from 'bearminion'
3
+ import { accent, eyebrow, heading } from '../shared'
4
+ import { parseMediaSource } from './source'
5
+ import { mediaWireframe } from './wireframe'
6
+
7
+ export const mediaVariants = ['centered', 'wide', 'split'] as const
8
+ export const mediaKinds = ['video', 'image'] as const
9
+ export const mediaAspects = ['16:9', '4:3', '21:9', '1:1'] as const
10
+
11
+ /** A YouTube or Vimeo link, a path in `public/` such as `/videos/demo.webm`, or an `https://` link to a video file. */
12
+ export const mediaVideoSchema = z
13
+ .string()
14
+ .min(1)
15
+ .refine((value) => parseMediaSource(value) !== null, {
16
+ message: 'Use a YouTube or Vimeo link, a link to a video file ending in .mp4 or .webm, or a path in public/ such as /videos/demo.webm.',
17
+ })
18
+
19
+ export const mediaSchema = withEmphasis({
20
+ eyebrow,
21
+ heading,
22
+ body: richTextSchema.optional(),
23
+ /** `video` plays the link in `video`; `image` shows `image`. */
24
+ kind: z.enum(mediaKinds).default('video'),
25
+ aspect: z.enum(mediaAspects).default('16:9'),
26
+ /** Off: no eyebrow, heading or body, and in the centered layout a wider frame with no top padding. */
27
+ intro: z.boolean().default(true),
28
+ video: mediaVideoSchema.optional(),
29
+ /** Shown before the video plays. Without one, a YouTube video shows its own thumbnail. */
30
+ poster: imageSchema.optional(),
31
+ image: imageSchema.optional(),
32
+ /** What the video shows, read out by screen readers. An image uses its own alt text. */
33
+ label: textSchema.optional(),
34
+ caption: textSchema.optional(),
35
+ })
36
+
37
+ export type MediaProps = z.infer<typeof mediaSchema>
38
+
39
+ export const media = defineSection({
40
+ type: 'media',
41
+ label: 'Media',
42
+ description: 'One video or image under a heading. YouTube, Vimeo or a video file.',
43
+ schema: mediaSchema,
44
+ defaults: {
45
+ eyebrow: 'Demo',
46
+ heading: 'See it in action.',
47
+ marks: accent('See it in action.', 'in action.'),
48
+ body: 'Paste a YouTube or Vimeo link, or a link to a video file. Nothing loads until the visitor presses play.',
49
+ kind: 'video',
50
+ aspect: '16:9',
51
+ intro: true,
52
+ label: 'Product walkthrough',
53
+ },
54
+ variants: mediaVariants,
55
+ defaultVariant: 'centered',
56
+ options: [
57
+ { name: 'kind', label: 'Type', type: 'enum', choices: mediaKinds, default: 'video' },
58
+ { name: 'aspect', label: 'Shape', type: 'enum', choices: mediaAspects, default: '16:9' },
59
+ { name: 'intro', label: 'Show heading', type: 'boolean', default: true },
60
+ ],
61
+ optional: { props: ['eyebrow', 'heading', 'body', 'video', 'poster', 'image', 'label', 'caption'] },
62
+ showWhen: {
63
+ eyebrow: { intro: true },
64
+ heading: { intro: true },
65
+ body: { intro: true },
66
+ video: { kind: 'video' },
67
+ poster: { kind: 'video' },
68
+ label: { kind: 'video' },
69
+ image: { kind: 'image' },
70
+ },
71
+ wireframe: mediaWireframe,
72
+ })
@@ -0,0 +1,33 @@
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
+ }