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
|
@@ -5,20 +5,26 @@ import Faq from './faq/Faq.astro'
|
|
|
5
5
|
import Features from './features/Features.astro'
|
|
6
6
|
import Hero from './hero/Hero.astro'
|
|
7
7
|
import LogoStrip from './logo-strip/LogoStrip.astro'
|
|
8
|
+
import Media from './media/Media.astro'
|
|
8
9
|
import Pricing from './pricing/Pricing.astro'
|
|
9
10
|
import Stats from './stats/Stats.astro'
|
|
11
|
+
import Steps from './steps/Steps.astro'
|
|
10
12
|
import Testimonials from './testimonials/Testimonials.astro'
|
|
11
13
|
import TextImage from './text-image/TextImage.astro'
|
|
14
|
+
import Video from './video/Video.astro'
|
|
12
15
|
|
|
13
16
|
/** Maps a content section `type` to the component that renders it. Definitions live in `definitions.ts`. */
|
|
14
17
|
export const sectionComponents = {
|
|
15
18
|
hero: Hero,
|
|
16
19
|
logoStrip: LogoStrip,
|
|
20
|
+
steps: Steps,
|
|
17
21
|
features: Features,
|
|
18
22
|
textImage: TextImage,
|
|
19
23
|
stats: Stats,
|
|
20
24
|
testimonials: Testimonials,
|
|
21
25
|
pricing: Pricing,
|
|
26
|
+
video: Video,
|
|
27
|
+
media: Media,
|
|
22
28
|
faq: Faq,
|
|
23
29
|
cta: Cta,
|
|
24
30
|
contact: Contact,
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
---
|
|
2
|
+
import type { Section } from '../definitions'
|
|
3
|
+
import Button from '../../atoms/Button.astro'
|
|
4
|
+
import Typography from '../../atoms/Typography.astro'
|
|
5
|
+
import SectionHeader from '../../molecules/SectionHeader.astro'
|
|
6
|
+
import SectionShell from '../../molecules/SectionShell.astro'
|
|
7
|
+
import { anchorAttrs, buttonLinkProps, buttonVariant } from '../../../lib/link-attrs'
|
|
8
|
+
import { stepColumns, stepIconPaths } from './icons'
|
|
9
|
+
|
|
10
|
+
interface Props {
|
|
11
|
+
section: Extract<Section, { type: 'steps' }>
|
|
12
|
+
index: number
|
|
13
|
+
motion?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const { section, index, motion = true } = Astro.props
|
|
17
|
+
const props = section.props
|
|
18
|
+
const field = (path: string) => `sections.${index}.props.${path}`
|
|
19
|
+
const reveal = section.motion ?? motion
|
|
20
|
+
const columns = stepColumns[props.steps.length] ?? ''
|
|
21
|
+
const number = (itemIndex: number) => String(itemIndex + 1).padStart(2, '0')
|
|
22
|
+
const textLink = 'inline-flex items-center gap-1.5 text-[0.9375rem] font-medium text-link underline-offset-4 hover:underline'
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
<SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
|
|
26
|
+
{
|
|
27
|
+
section.variant === 'columns' && (
|
|
28
|
+
<div class="section-container flex flex-col gap-[clamp(2.5rem,5cqi,4rem)]">
|
|
29
|
+
<div class="flex flex-wrap items-end justify-between gap-x-16 gap-y-5">
|
|
30
|
+
<SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} size="h2-md" field={field} headingClass="max-w-[16ch]" class="flex-[1_1_26rem] gap-5" />
|
|
31
|
+
{(props.body || props.link) && (
|
|
32
|
+
<div class="flex flex-[0_1_26rem] flex-col items-start gap-4">
|
|
33
|
+
{props.body && <Typography as="p" size="lead" color="muted" data-field={field('body')} markdown={props.body} />}
|
|
34
|
+
{props.link && (
|
|
35
|
+
<a {...anchorAttrs(props.link)} class={textLink}>
|
|
36
|
+
<span data-field={field('link.label')}>{props.link.label}</span>
|
|
37
|
+
<span aria-hidden="true">→</span>
|
|
38
|
+
</a>
|
|
39
|
+
)}
|
|
40
|
+
</div>
|
|
41
|
+
)}
|
|
42
|
+
</div>
|
|
43
|
+
<ol class:list={['grid grid-cols-1 gap-4', columns]}>
|
|
44
|
+
{props.steps.map((step, itemIndex) => (
|
|
45
|
+
<li class="flex flex-col gap-4 rounded-card bg-surface p-[clamp(1.5rem,2.5cqi,2rem)] ring-line [&_code]:bg-bg">
|
|
46
|
+
<div class="flex items-center justify-between">
|
|
47
|
+
<span class="grid size-12 place-items-center rounded-[0.875rem] bg-accent text-on" aria-hidden="true">
|
|
48
|
+
<svg viewBox="0 0 24 24" class="size-[22px]" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
49
|
+
<path d={stepIconPaths[step.icon]} />
|
|
50
|
+
</svg>
|
|
51
|
+
</span>
|
|
52
|
+
{props.numbered && <span class="font-mono text-[0.75rem] tracking-[0.06em] text-muted">{number(itemIndex)}</span>}
|
|
53
|
+
</div>
|
|
54
|
+
<Typography as="h3" size="h3" class="mt-2 text-[1.375rem] leading-[1.15] tracking-[-0.025em]" data-field={field(`steps.${itemIndex}.title`)}>
|
|
55
|
+
{step.title}
|
|
56
|
+
</Typography>
|
|
57
|
+
{step.description && <Typography as="p" size="body" color="muted" data-field={field(`steps.${itemIndex}.description`)} markdown={step.description} />}
|
|
58
|
+
</li>
|
|
59
|
+
))}
|
|
60
|
+
</ol>
|
|
61
|
+
</div>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
{
|
|
65
|
+
section.variant === 'rail' && (
|
|
66
|
+
<div class="section-container flex flex-col items-center gap-[clamp(3rem,6cqi,5rem)]">
|
|
67
|
+
<SectionHeader
|
|
68
|
+
eyebrow={props.eyebrow}
|
|
69
|
+
heading={props.heading}
|
|
70
|
+
marks={props.marks}
|
|
71
|
+
intro={props.body}
|
|
72
|
+
introField="body"
|
|
73
|
+
align="center"
|
|
74
|
+
size="h2-md"
|
|
75
|
+
field={field}
|
|
76
|
+
headingClass="max-w-[18ch]"
|
|
77
|
+
class="gap-5 [&>[data-field-kind=rich]]:max-w-[34rem]"
|
|
78
|
+
/>
|
|
79
|
+
<ol class:list={['grid w-full grid-cols-1 gap-x-8 gap-y-14', columns]}>
|
|
80
|
+
{props.steps.map((step, itemIndex) => (
|
|
81
|
+
<li class="relative flex flex-col gap-3.5 border-t border-line pt-9">
|
|
82
|
+
<span class="absolute -top-6 left-0 grid size-12 place-items-center rounded-full bg-bg text-fg ring-line" aria-hidden="true">
|
|
83
|
+
<svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
84
|
+
<path d={stepIconPaths[step.icon]} />
|
|
85
|
+
</svg>
|
|
86
|
+
</span>
|
|
87
|
+
{props.numbered && <span class="font-mono text-[0.75rem] tracking-[0.06em] text-link">Step {number(itemIndex)}</span>}
|
|
88
|
+
<Typography as="h3" size="h3" class="text-[1.375rem] leading-[1.15] tracking-[-0.025em]" data-field={field(`steps.${itemIndex}.title`)}>
|
|
89
|
+
{step.title}
|
|
90
|
+
</Typography>
|
|
91
|
+
{step.description && <Typography as="p" size="body" color="muted" data-field={field(`steps.${itemIndex}.description`)} markdown={step.description} />}
|
|
92
|
+
</li>
|
|
93
|
+
))}
|
|
94
|
+
</ol>
|
|
95
|
+
{props.link && (
|
|
96
|
+
<Button {...buttonLinkProps(props.link)} variant={buttonVariant(props.link, 'secondary')} arrow data-field={field('link.label')}>
|
|
97
|
+
{props.link.label}
|
|
98
|
+
</Button>
|
|
99
|
+
)}
|
|
100
|
+
</div>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
{
|
|
104
|
+
section.variant === 'list' && (
|
|
105
|
+
<div class="section-container grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] items-start gap-[clamp(2.5rem,6cqi,6rem)]">
|
|
106
|
+
<div class="sticky top-24 flex flex-col items-start gap-5">
|
|
107
|
+
<SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} size="h2-md" field={field} class="gap-5" />
|
|
108
|
+
{props.body && <Typography as="p" size="lead" color="muted" data-field={field('body')} class="max-w-[30rem]" markdown={props.body} />}
|
|
109
|
+
{props.link && (
|
|
110
|
+
<a {...anchorAttrs(props.link)} class={textLink}>
|
|
111
|
+
<span data-field={field('link.label')}>{props.link.label}</span>
|
|
112
|
+
<span aria-hidden="true">→</span>
|
|
113
|
+
</a>
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
<ol class="flex flex-col">
|
|
117
|
+
{props.steps.map((step, itemIndex) => (
|
|
118
|
+
<li class="grid grid-cols-[3rem_minmax(0,1fr)] gap-5 border-t border-line py-7">
|
|
119
|
+
<span class="grid size-12 place-items-center rounded-[0.875rem] bg-accent text-on" aria-hidden="true">
|
|
120
|
+
<svg viewBox="0 0 24 24" class="size-5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
|
121
|
+
<path d={stepIconPaths[step.icon]} />
|
|
122
|
+
</svg>
|
|
123
|
+
</span>
|
|
124
|
+
<div class="flex flex-col gap-2">
|
|
125
|
+
<div class="flex items-baseline gap-3">
|
|
126
|
+
{props.numbered && <span class="font-mono text-[0.75rem] text-muted">{number(itemIndex)}</span>}
|
|
127
|
+
<Typography as="h3" size="h3" class="text-[1.25rem] leading-[1.2]" data-field={field(`steps.${itemIndex}.title`)}>
|
|
128
|
+
{step.title}
|
|
129
|
+
</Typography>
|
|
130
|
+
</div>
|
|
131
|
+
{step.description && <Typography as="p" size="body" color="muted" data-field={field(`steps.${itemIndex}.description`)} markdown={step.description} />}
|
|
132
|
+
</div>
|
|
133
|
+
</li>
|
|
134
|
+
))}
|
|
135
|
+
</ol>
|
|
136
|
+
</div>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
139
|
+
</SectionShell>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { sectionArgTypes } from '../../../stories/options'
|
|
2
|
+
import { steps } from './definition'
|
|
3
|
+
import Steps from './Steps.astro'
|
|
4
|
+
|
|
5
|
+
/** The sample pages hold no Steps section, so the stories start from its defaults. */
|
|
6
|
+
const section = { id: 'steps', type: 'steps', variant: 'columns', tone: 'base', props: steps.defaults } as const
|
|
7
|
+
const four = {
|
|
8
|
+
...steps.defaults,
|
|
9
|
+
numbered: false,
|
|
10
|
+
steps: [
|
|
11
|
+
{ icon: 'bolt', title: 'Quick', description: 'One command sets up the site and the editor.' },
|
|
12
|
+
{ icon: 'cursor', title: 'Easy to use', description: 'Click the text on the page and type.' },
|
|
13
|
+
{ icon: 'sliders', title: 'Customizable', description: 'Sections are Astro components with a schema.' },
|
|
14
|
+
{ icon: 'terminal', title: 'Local', description: 'Run `npm run dev` and press `Ctrl+E`.' },
|
|
15
|
+
],
|
|
16
|
+
} as const
|
|
17
|
+
|
|
18
|
+
export default {
|
|
19
|
+
title: 'Sections/Steps',
|
|
20
|
+
component: Steps,
|
|
21
|
+
parameters: { layout: 'fullscreen' },
|
|
22
|
+
argTypes: sectionArgTypes('steps'),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Columns = { args: { section, index: 0 } }
|
|
26
|
+
export const Rail = { args: { section: { ...section, variant: 'rail', tone: 'subtle', props: four }, index: 0 } }
|
|
27
|
+
export const List = { args: { section: { ...section, variant: 'list' }, index: 0 } }
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { defineSection, linkSchema, richTextSchema, textSchema, withEmphasis, type CountRule } from 'bearminion'
|
|
3
|
+
import { accent, eyebrow, heading } from '../shared'
|
|
4
|
+
import { stepIconPaths } from './icons'
|
|
5
|
+
import { stepsWireframe } from './wireframe'
|
|
6
|
+
|
|
7
|
+
export const stepsVariants = ['columns', 'rail', 'list'] as const
|
|
8
|
+
|
|
9
|
+
/** 24px stroke icons; the paths are in `icons.ts`. */
|
|
10
|
+
export const stepIcons = ['terminal', 'edit', 'upload', 'moon', 'git', 'check', 'bolt', 'cursor', 'sliders', 'database'] as const
|
|
11
|
+
|
|
12
|
+
export const stepsCounts = {
|
|
13
|
+
steps: { recommended: '3 or 4', min: 2, max: 6 },
|
|
14
|
+
} as const satisfies Record<string, CountRule>
|
|
15
|
+
|
|
16
|
+
export const stepItemSchema = z
|
|
17
|
+
.object({
|
|
18
|
+
icon: z.enum(stepIcons),
|
|
19
|
+
title: textSchema,
|
|
20
|
+
/** One sentence. Text in backticks shows as code. */
|
|
21
|
+
description: richTextSchema.optional(),
|
|
22
|
+
})
|
|
23
|
+
.strict()
|
|
24
|
+
|
|
25
|
+
export const stepsSchema = withEmphasis({
|
|
26
|
+
eyebrow,
|
|
27
|
+
heading,
|
|
28
|
+
body: richTextSchema.optional(),
|
|
29
|
+
link: linkSchema.optional(),
|
|
30
|
+
/** Off: no "01" or "Step 01" over each item, for points that are not a sequence. */
|
|
31
|
+
numbered: z.boolean().default(true),
|
|
32
|
+
steps: z.array(stepItemSchema).min(stepsCounts.steps.min).max(stepsCounts.steps.max),
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
export type StepItem = z.infer<typeof stepItemSchema>
|
|
36
|
+
export type StepsProps = z.infer<typeof stepsSchema>
|
|
37
|
+
|
|
38
|
+
export const steps = defineSection({
|
|
39
|
+
type: 'steps',
|
|
40
|
+
label: 'Steps',
|
|
41
|
+
description: 'A few short points with an icon each: steps, or reasons.',
|
|
42
|
+
schema: stepsSchema,
|
|
43
|
+
defaults: {
|
|
44
|
+
eyebrow: 'How it works',
|
|
45
|
+
heading: 'Three steps to get going.',
|
|
46
|
+
marks: accent('Three steps to get going.', 'get going.'),
|
|
47
|
+
body: 'Say what the reader does first, then what happens next. Keep each step to one sentence.',
|
|
48
|
+
numbered: true,
|
|
49
|
+
steps: [
|
|
50
|
+
{ icon: 'cursor', title: 'Pick', description: 'Name the first thing the reader does.' },
|
|
51
|
+
{ icon: 'edit', title: 'Shape', description: 'Say what they change or decide next.' },
|
|
52
|
+
{ icon: 'check', title: 'Done', description: 'End with what they have when they finish.' },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
variants: stepsVariants,
|
|
56
|
+
defaultVariant: 'columns',
|
|
57
|
+
options: [{ name: 'numbered', label: 'Show numbers', type: 'boolean', default: true }],
|
|
58
|
+
optional: { props: ['eyebrow', 'heading', 'body', 'link'], items: { steps: ['description'] } },
|
|
59
|
+
counts: stepsCounts,
|
|
60
|
+
icons: { 'steps[].icon': stepIconPaths },
|
|
61
|
+
wireframe: stepsWireframe,
|
|
62
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { StepItem } from './definition'
|
|
2
|
+
|
|
3
|
+
/** 24px, stroke 1.8, round caps and joins. */
|
|
4
|
+
export const stepIconPaths: Record<StepItem['icon'], string> = {
|
|
5
|
+
terminal: 'M4 5h16v14H4zM8 10l2.5 2L8 14M12.5 14H16',
|
|
6
|
+
edit: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4',
|
|
7
|
+
upload: 'M12 16V4M7 9l5-5 5 5M5 20h14',
|
|
8
|
+
moon: 'M20 14.5A8 8 0 0 1 9.5 4 8 8 0 1 0 20 14.5z',
|
|
9
|
+
git: 'M6 3v12M6 15a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM18 9a3 3 0 1 0 0-6 3 3 0 0 0 0 6zM18 9c0 5-6 4-12 6',
|
|
10
|
+
check: 'M5 12.5l4.5 4.5L19 7',
|
|
11
|
+
bolt: 'M13 3L5 13.5h6L10 21l8-10.5h-6z',
|
|
12
|
+
cursor: 'M5 4l5.5 15 2.2-6.3L19 10.5zM13 13l5 5',
|
|
13
|
+
sliders: 'M4 7h9M17 7h3M4 17h3M11 17h9M15 5v4M9 15v4',
|
|
14
|
+
database: 'M4 6c0-1.7 3.6-3 8-3s8 1.3 8 3-3.6 3-8 3-8-1.3-8-3zM4 6v12c0 1.7 3.6 3 8 3s8-1.3 8-3V6M4 12c0 1.7 3.6 3 8 3s8-1.3 8-3',
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Columns for the step grid, by item count. A row never leaves an orphan: the count drops to the widest divisor that
|
|
19
|
+
* fits, with about 15.5rem per column (4 items: 4, then 2 × 2, then 1; 3 items: 3, then 1).
|
|
20
|
+
*/
|
|
21
|
+
export const stepColumns: Record<number, string> = {
|
|
22
|
+
2: '@min-[34rem]:grid-cols-2',
|
|
23
|
+
3: '@min-[49.5rem]:grid-cols-3',
|
|
24
|
+
4: '@min-[34rem]:grid-cols-2 @min-[65rem]:grid-cols-4',
|
|
25
|
+
5: '@min-[80.5rem]:grid-cols-5',
|
|
26
|
+
6: '@min-[34rem]:grid-cols-2 @min-[49.5rem]:grid-cols-3 @min-[96rem]:grid-cols-6',
|
|
27
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineWireframe } from 'bearminion'
|
|
2
|
+
|
|
3
|
+
/** Add section picker thumbnail: [left %, top %, width %, height %, kind]. */
|
|
4
|
+
export const stepsWireframe = defineWireframe([
|
|
5
|
+
[8, 12, 40, 8, 'title'],
|
|
6
|
+
[8, 25, 30, 4, 'text'],
|
|
7
|
+
[8, 40, 26, 46, 'card'],
|
|
8
|
+
[37, 40, 26, 46, 'card'],
|
|
9
|
+
[66, 40, 26, 46, 'card'],
|
|
10
|
+
[12, 46, 7, 9, 'highlight'],
|
|
11
|
+
[41, 46, 7, 9, 'highlight'],
|
|
12
|
+
[70, 46, 7, 9, 'highlight'],
|
|
13
|
+
[12, 62, 16, 4, 'text'],
|
|
14
|
+
[41, 62, 16, 4, 'text'],
|
|
15
|
+
[70, 62, 16, 4, 'text'],
|
|
16
|
+
])
|
|
@@ -37,7 +37,7 @@ const left = section.variant === 'left'
|
|
|
37
37
|
<span class="text-accent" aria-hidden="true">
|
|
38
38
|
✓
|
|
39
39
|
</span>
|
|
40
|
-
<
|
|
40
|
+
<Typography as="div" size="body" class="text-[0.9375rem]" data-field={field(`points.${pointIndex}`)} markdown={point} />
|
|
41
41
|
</li>
|
|
42
42
|
))}
|
|
43
43
|
</ul>
|
|
@@ -95,9 +95,7 @@ const left = section.variant === 'left'
|
|
|
95
95
|
{props.points.map((point, pointIndex) => (
|
|
96
96
|
<li class="border-t border-[var(--line)] py-6 @min-[700px]:border-t-0 @min-[700px]:border-l @min-[700px]:px-6 @min-[700px]:first:border-l-0 @min-[700px]:first:pl-0">
|
|
97
97
|
<span class="font-mono text-[0.75rem] tracking-[0.08em] text-muted">{String(pointIndex + 1).padStart(2, '0')}</span>
|
|
98
|
-
<
|
|
99
|
-
{point}
|
|
100
|
-
</p>
|
|
98
|
+
<Typography as="div" size="body" class="mt-3" data-field={field(`points.${pointIndex}`)} markdown={point} />
|
|
101
99
|
</li>
|
|
102
100
|
))}
|
|
103
101
|
</ol>
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from 'zod'
|
|
2
|
-
import { defineSection, imageSchema, linkSchema, richTextSchema,
|
|
2
|
+
import { defineSection, imageSchema, linkSchema, richTextSchema, withEmphasis } from 'bearminion'
|
|
3
3
|
import { accent, eyebrow, heading } from '../shared'
|
|
4
4
|
import { textImageWireframe } from './wireframe'
|
|
5
5
|
|
|
@@ -9,7 +9,8 @@ export const textImageSchema = withEmphasis({
|
|
|
9
9
|
eyebrow,
|
|
10
10
|
heading,
|
|
11
11
|
body: richTextSchema.optional(),
|
|
12
|
-
|
|
12
|
+
/** Rich text: bold, italic, links and `code` in backticks. Plain strings render as before. */
|
|
13
|
+
points: z.array(richTextSchema),
|
|
13
14
|
link: linkSchema.optional(),
|
|
14
15
|
image: imageSchema,
|
|
15
16
|
})
|
|
@@ -0,0 +1,78 @@
|
|
|
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>
|
|
@@ -0,0 +1,17 @@
|
|
|
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 } }
|
|
@@ -0,0 +1,60 @@
|
|
|
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>
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,9 @@
|
|
|
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
|
+
])
|
|
@@ -59,7 +59,7 @@ const themeColor = site.theme.mode === 'dark' ? '#1c1e24' : '#f7f8f8'
|
|
|
59
59
|
{ threshold: 0.1 },
|
|
60
60
|
)
|
|
61
61
|
function revealMotion() {
|
|
62
|
-
document.querySelectorAll('[data-motion="true"]:not([data-revealed])').forEach((node) => {
|
|
62
|
+
document.querySelectorAll('[data-section-id][data-motion="true"]:not([data-revealed])').forEach((node) => {
|
|
63
63
|
if (!observer) {
|
|
64
64
|
node.setAttribute('data-revealed', 'true')
|
|
65
65
|
return
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { getImage } from 'astro:assets'
|
|
2
|
+
import type { Image } from 'bearminion'
|
|
3
|
+
import { isRemote, resolveLocalAsset } from './assets'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* An image as a plain URL, for attributes that take no `<img>`, such as a video's `poster`. A file in `src/assets` is
|
|
7
|
+
* optimized to WebP; a file uploaded in this dev session is served by the editor's dev route; a remote URL is used as
|
|
8
|
+
* it is. A missing file in `src/assets` fails the build, like a missing `<Image>`.
|
|
9
|
+
*/
|
|
10
|
+
export async function imageUrl(image: Image | undefined, maxWidth = 1920): Promise<string | undefined> {
|
|
11
|
+
if (!image || image.hidden || !image.src || image.src === 'placeholder') return undefined
|
|
12
|
+
if (isRemote(image.src)) return image.src
|
|
13
|
+
const path = image.src.replace(/^\//, '')
|
|
14
|
+
const asset = resolveLocalAsset(path)
|
|
15
|
+
if (asset) return (await getImage({ src: asset, width: Math.min(asset.width, maxWidth), format: 'webp' })).src
|
|
16
|
+
if (path.startsWith('src/assets/')) {
|
|
17
|
+
if (import.meta.env.DEV) return `/__bearminion/assets?src=${encodeURIComponent(path)}`
|
|
18
|
+
throw new Error(`No file in src/assets for the image "${image.src}". Restore the file, or choose another image in the editor and save.`)
|
|
19
|
+
}
|
|
20
|
+
return image.src
|
|
21
|
+
}
|
|
@@ -216,6 +216,19 @@
|
|
|
216
216
|
[data-field-kind="rich"] li::marker {
|
|
217
217
|
color: var(--muted);
|
|
218
218
|
}
|
|
219
|
+
/* Inline code, written with backticks: a small mono chip that keeps its words together. */
|
|
220
|
+
[data-field-kind="rich"] code {
|
|
221
|
+
display: inline-block;
|
|
222
|
+
margin: 0.0625rem;
|
|
223
|
+
padding: 0.0625rem 0.4375rem;
|
|
224
|
+
border-radius: 6px;
|
|
225
|
+
background: var(--surface);
|
|
226
|
+
box-shadow: inset 0 0 0 1px var(--line);
|
|
227
|
+
font-family: var(--font-mono);
|
|
228
|
+
font-size: 0.84em;
|
|
229
|
+
color: var(--fg);
|
|
230
|
+
white-space: nowrap;
|
|
231
|
+
}
|
|
219
232
|
blockquote[data-field-kind="rich"]::before {
|
|
220
233
|
content: "“";
|
|
221
234
|
}
|
|
@@ -331,14 +344,15 @@
|
|
|
331
344
|
[data-open="true"] .menu-bar-2 {
|
|
332
345
|
transform: translateY(-2.75px) rotate(-45deg);
|
|
333
346
|
}
|
|
334
|
-
|
|
347
|
+
/* A section's own motion setting decides, not the page's: `<html>` carries data-motion too. */
|
|
348
|
+
[data-motion="true"] > [data-reveal] {
|
|
335
349
|
opacity: 0;
|
|
336
350
|
transform: translateY(28px);
|
|
337
351
|
}
|
|
338
|
-
[data-motion="true"] [data-reveal="16"] {
|
|
352
|
+
[data-motion="true"] > [data-reveal="16"] {
|
|
339
353
|
transform: translateY(16px);
|
|
340
354
|
}
|
|
341
|
-
[data-motion="true"][data-revealed] [data-reveal] {
|
|
355
|
+
[data-motion="true"][data-revealed] > [data-reveal] {
|
|
342
356
|
opacity: 1;
|
|
343
357
|
transform: none;
|
|
344
358
|
transition:
|
|
@@ -362,7 +376,7 @@
|
|
|
362
376
|
color: var(--bg);
|
|
363
377
|
}
|
|
364
378
|
@media (prefers-reduced-motion: reduce) {
|
|
365
|
-
[data-motion="true"] [data-reveal] {
|
|
379
|
+
[data-motion="true"] > [data-reveal] {
|
|
366
380
|
opacity: 1;
|
|
367
381
|
transform: none;
|
|
368
382
|
}
|