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