create-bearminion 1.0.1 → 1.2.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.
- package/CHANGELOG.md +28 -0
- package/README.md +1 -1
- package/package.json +7 -8
- package/template/README.md +5 -5
- package/template/content/pages/index.json +4 -0
- package/template/package.json +1 -1
- package/template/src/components/atoms/typography.ts +3 -2
- package/template/src/components/molecules/CommandLine.astro +109 -0
- package/template/src/components/molecules/SectionHeader.astro +1 -1
- package/template/src/components/sections/SectionList.astro +9 -0
- package/template/src/components/sections/cta/Cta.astro +36 -11
- package/template/src/components/sections/cta/definition.ts +16 -2
- package/template/src/components/sections/definitions.ts +6 -0
- package/template/src/components/sections/faq/Faq.astro +9 -4
- package/template/src/components/sections/faq/definition.ts +6 -3
- package/template/src/components/sections/features/Features.astro +40 -15
- package/template/src/components/sections/features/definition.ts +2 -12
- package/template/src/components/sections/hero/Hero.astro +60 -30
- package/template/src/components/sections/hero/HeroBadge.astro +26 -0
- package/template/src/components/sections/hero/definition.ts +41 -3
- package/template/src/components/sections/media/Media.astro +115 -0
- package/template/src/components/sections/media/Media.stories.ts +31 -0
- package/template/src/components/sections/media/MediaPlayer.astro +133 -0
- package/template/src/components/sections/media/definition.ts +72 -0
- package/template/src/components/sections/media/source.ts +33 -0
- package/template/src/components/sections/media/wireframe.ts +9 -0
- package/template/src/components/sections/registry.ts +6 -0
- package/template/src/components/sections/steps/Steps.astro +139 -0
- package/template/src/components/sections/steps/Steps.stories.ts +27 -0
- package/template/src/components/sections/steps/definition.ts +62 -0
- package/template/src/components/sections/steps/icons.ts +27 -0
- package/template/src/components/sections/steps/wireframe.ts +16 -0
- package/template/src/components/sections/text-image/TextImage.astro +2 -4
- package/template/src/components/sections/text-image/definition.ts +3 -2
- package/template/src/components/sections/video/Video.astro +78 -0
- package/template/src/components/sections/video/Video.stories.ts +17 -0
- package/template/src/components/sections/video/VideoFrame.astro +60 -0
- package/template/src/components/sections/video/definition.ts +58 -0
- package/template/src/components/sections/video/wireframe.ts +9 -0
- package/template/src/layouts/Base.astro +1 -1
- package/template/src/lib/image-url.ts +21 -0
- 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
|
|
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
|
-
{
|
|
43
|
-
<Button {...buttonLinkProps(
|
|
44
|
-
{
|
|
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
|
-
{
|
|
48
|
-
<Button {...buttonLinkProps(
|
|
49
|
-
{
|
|
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
|
|
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
|
-
{
|
|
78
|
-
<Button {...buttonLinkProps(
|
|
79
|
-
{
|
|
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
|
-
{
|
|
83
|
-
<Button {...buttonLinkProps(
|
|
84
|
-
{
|
|
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-
|
|
127
|
-
{
|
|
128
|
-
<
|
|
129
|
-
{
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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
|
|
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
|
-
|
|
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,115 @@
|
|
|
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
|
+
}
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
{rendered && (<SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
|
|
36
|
+
{
|
|
37
|
+
section.variant === 'centered' && (
|
|
38
|
+
<div
|
|
39
|
+
class:list={[
|
|
40
|
+
'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',
|
|
41
|
+
intro && 'pt-[clamp(4rem,2rem+6cqi,8rem)]',
|
|
42
|
+
]}
|
|
43
|
+
>
|
|
44
|
+
{intro && (
|
|
45
|
+
<SectionHeader
|
|
46
|
+
eyebrow={props.eyebrow}
|
|
47
|
+
heading={props.heading}
|
|
48
|
+
marks={props.marks}
|
|
49
|
+
intro={props.body}
|
|
50
|
+
introField="body"
|
|
51
|
+
align="center"
|
|
52
|
+
size="h2-md"
|
|
53
|
+
field={field}
|
|
54
|
+
headingClass="max-w-[20ch]"
|
|
55
|
+
class="gap-5"
|
|
56
|
+
/>
|
|
57
|
+
)}
|
|
58
|
+
{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]']}>
|
|
59
|
+
<MediaPlayer {...player} />
|
|
60
|
+
{props.caption && (
|
|
61
|
+
<Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
|
|
62
|
+
{props.caption}
|
|
63
|
+
</Typography>
|
|
64
|
+
)}
|
|
65
|
+
</figure>)}
|
|
66
|
+
</div>
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
{
|
|
70
|
+
section.variant === 'wide' && (
|
|
71
|
+
<div class="section-container flex flex-col gap-[clamp(2rem,4cqi,3.5rem)]">
|
|
72
|
+
{intro && (
|
|
73
|
+
<div class="flex flex-wrap items-end justify-between gap-x-12 gap-y-5">
|
|
74
|
+
<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" />
|
|
75
|
+
{props.body && <Typography as="p" size="lead" color="muted" data-field={field('body')} class="flex-[0_1_26rem]" markdown={props.body} />}
|
|
76
|
+
</div>
|
|
77
|
+
)}
|
|
78
|
+
{showFrame && (<figure class="flex flex-col gap-3">
|
|
79
|
+
<MediaPlayer {...player} />
|
|
80
|
+
{props.caption && (
|
|
81
|
+
<Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
|
|
82
|
+
{props.caption}
|
|
83
|
+
</Typography>
|
|
84
|
+
)}
|
|
85
|
+
</figure>)}
|
|
86
|
+
</div>
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
{
|
|
90
|
+
section.variant === 'split' && (
|
|
91
|
+
<div class="section-container grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] items-center gap-[clamp(2.5rem,5cqi,5rem)]">
|
|
92
|
+
{intro && (
|
|
93
|
+
<SectionHeader
|
|
94
|
+
eyebrow={props.eyebrow}
|
|
95
|
+
heading={props.heading}
|
|
96
|
+
marks={props.marks}
|
|
97
|
+
intro={props.body}
|
|
98
|
+
introField="body"
|
|
99
|
+
size="h2-md"
|
|
100
|
+
field={field}
|
|
101
|
+
class="gap-5"
|
|
102
|
+
/>
|
|
103
|
+
)}
|
|
104
|
+
{showFrame && (<figure class:list={['flex flex-col gap-3', !intro && 'col-span-full']}>
|
|
105
|
+
<MediaPlayer {...player} />
|
|
106
|
+
{props.caption && (
|
|
107
|
+
<Typography as="figcaption" size="small" color="muted" data-field={field('caption')}>
|
|
108
|
+
{props.caption}
|
|
109
|
+
</Typography>
|
|
110
|
+
)}
|
|
111
|
+
</figure>)}
|
|
112
|
+
</div>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
</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,133 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Image } from 'bearminion'
|
|
3
|
+
import { 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
|
+
class?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const { kind, aspect, video, poster, image, label, imageField, class: className } = Astro.props
|
|
22
|
+
const ASPECT = { '16:9': 'aspect-video', '4:3': 'aspect-[4/3]', '21:9': 'aspect-[21/9]', '1:1': 'aspect-square' } as const
|
|
23
|
+
const frame = cn(
|
|
24
|
+
'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)]',
|
|
25
|
+
ASPECT[aspect],
|
|
26
|
+
className,
|
|
27
|
+
)
|
|
28
|
+
const source = kind === 'video' && video ? parseMediaSource(video) : null
|
|
29
|
+
const hasImage = kind === 'image' && Boolean(image?.src) && image?.src !== 'placeholder' && !image?.hidden
|
|
30
|
+
const empty = kind === 'video' ? !source : !hasImage
|
|
31
|
+
const cover = source ? ((await imageUrl(poster)) ?? (source.kind === 'youtube' ? source.thumbnail : undefined)) : undefined
|
|
32
|
+
const name = label || 'Video'
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
{
|
|
36
|
+
empty ? (
|
|
37
|
+
// The empty frame is an editor hint, so a built site leaves it out.
|
|
38
|
+
import.meta.env.DEV && (
|
|
39
|
+
<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}>
|
|
40
|
+
{kind === 'video' && (
|
|
41
|
+
<span class="flex size-14 items-center justify-center rounded-full bg-fg text-bg" aria-hidden="true">
|
|
42
|
+
<svg viewBox="0 0 24 24" class="ms-0.5 size-[18px]" fill="currentColor"><path d="M8 5.5v13l11-6.5z" /></svg>
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
<span class="font-mono text-[0.75rem] tracking-[0.02em] text-muted">
|
|
46
|
+
{kind === 'video' ? 'Add a video link in the section settings' : 'Add an image in the section settings'}
|
|
47
|
+
</span>
|
|
48
|
+
</div>
|
|
49
|
+
)
|
|
50
|
+
) : kind === 'image' && image ? (
|
|
51
|
+
<MediaFrame
|
|
52
|
+
src={image.src}
|
|
53
|
+
alt={image.alt}
|
|
54
|
+
fit={image.fit === 'auto' ? 'cover' : image.fit}
|
|
55
|
+
position={image.position}
|
|
56
|
+
aspectClass={cn(ASPECT[aspect], 'min-h-48 w-full p-0')}
|
|
57
|
+
roundedClass="rounded-[clamp(1rem,2cqi,1.5rem)]"
|
|
58
|
+
class="shadow-[inset_0_0_0_1px_var(--line),0_40px_80px_-48px_oklch(0.2_0.02_255/0.35)]"
|
|
59
|
+
data-field={imageField}
|
|
60
|
+
/>
|
|
61
|
+
) : (
|
|
62
|
+
source && (
|
|
63
|
+
<div
|
|
64
|
+
class={frame}
|
|
65
|
+
data-media-player
|
|
66
|
+
data-kind={source.kind}
|
|
67
|
+
data-src={source.kind === 'file' ? source.src : source.embed}
|
|
68
|
+
data-label={name}
|
|
69
|
+
data-poster={source.kind === 'file' ? cover : undefined}
|
|
70
|
+
>
|
|
71
|
+
{cover && <img src={cover} alt="" class="absolute inset-0 size-full object-cover" loading="lazy" decoding="async" />}
|
|
72
|
+
<button
|
|
73
|
+
type="button"
|
|
74
|
+
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"
|
|
75
|
+
aria-label={`Play video: ${name}`}
|
|
76
|
+
data-media-play
|
|
77
|
+
>
|
|
78
|
+
<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">
|
|
79
|
+
<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>
|
|
80
|
+
</span>
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
)
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
<script>
|
|
88
|
+
/**
|
|
89
|
+
* Nothing is loaded from YouTube, Vimeo or the video file until the visitor presses play. Then the button and cover
|
|
90
|
+
* are swapped for the player, which starts at once.
|
|
91
|
+
*/
|
|
92
|
+
function play(frame: HTMLElement) {
|
|
93
|
+
const src = frame.dataset.src
|
|
94
|
+
if (!src) return
|
|
95
|
+
const label = frame.dataset.label ?? 'Video'
|
|
96
|
+
let player: HTMLElement
|
|
97
|
+
if (frame.dataset.kind === 'file') {
|
|
98
|
+
const video = document.createElement('video')
|
|
99
|
+
video.src = src
|
|
100
|
+
if (frame.dataset.poster) video.poster = frame.dataset.poster
|
|
101
|
+
video.controls = true
|
|
102
|
+
video.autoplay = true
|
|
103
|
+
video.playsInline = true
|
|
104
|
+
video.setAttribute('aria-label', label)
|
|
105
|
+
video.className = 'absolute inset-0 size-full bg-black object-contain'
|
|
106
|
+
player = video
|
|
107
|
+
} else {
|
|
108
|
+
const iframe = document.createElement('iframe')
|
|
109
|
+
iframe.src = src
|
|
110
|
+
iframe.title = label
|
|
111
|
+
iframe.allow = 'autoplay; fullscreen; picture-in-picture; encrypted-media'
|
|
112
|
+
iframe.allowFullscreen = true
|
|
113
|
+
iframe.referrerPolicy = 'strict-origin-when-cross-origin'
|
|
114
|
+
iframe.className = 'absolute inset-0 size-full border-0'
|
|
115
|
+
player = iframe
|
|
116
|
+
}
|
|
117
|
+
frame.replaceChildren(player)
|
|
118
|
+
player.focus()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function bindMediaPlayers() {
|
|
122
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-media-play]')) {
|
|
123
|
+
if (button.dataset.bound === 'true') continue
|
|
124
|
+
button.dataset.bound = 'true'
|
|
125
|
+
button.addEventListener('click', () => {
|
|
126
|
+
const frame = button.closest<HTMLElement>('[data-media-player]')
|
|
127
|
+
if (frame) play(frame)
|
|
128
|
+
})
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
bindMediaPlayers()
|
|
132
|
+
document.addEventListener('bearminion:preview', bindMediaPlayers)
|
|
133
|
+
</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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineWireframe } from 'bearminion'
|
|
2
|
+
|
|
3
|
+
/** Add section picker thumbnail: [left %, top %, width %, height %, kind]. */
|
|
4
|
+
export const mediaWireframe = defineWireframe([
|
|
5
|
+
[32, 8, 36, 7, 'title'],
|
|
6
|
+
[26, 19, 48, 4, 'text'],
|
|
7
|
+
[14, 30, 72, 60, 'figure'],
|
|
8
|
+
[44, 52, 12, 16, 'dark'],
|
|
9
|
+
])
|