create-bearminion 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +4 -2
  3. package/package.json +2 -2
  4. package/template/README.md +6 -6
  5. package/template/content/pages/index.json +4 -0
  6. package/template/package.json +1 -1
  7. package/template/src/components/atoms/typography.ts +3 -2
  8. package/template/src/components/molecules/CommandLine.astro +109 -0
  9. package/template/src/components/molecules/SectionHeader.astro +1 -1
  10. package/template/src/components/sections/SectionList.astro +9 -0
  11. package/template/src/components/sections/cta/Cta.astro +36 -11
  12. package/template/src/components/sections/cta/definition.ts +16 -2
  13. package/template/src/components/sections/definitions.ts +6 -0
  14. package/template/src/components/sections/faq/Faq.astro +9 -4
  15. package/template/src/components/sections/faq/definition.ts +6 -3
  16. package/template/src/components/sections/features/Features.astro +40 -15
  17. package/template/src/components/sections/features/definition.ts +2 -12
  18. package/template/src/components/sections/hero/Hero.astro +60 -30
  19. package/template/src/components/sections/hero/HeroBadge.astro +26 -0
  20. package/template/src/components/sections/hero/definition.ts +41 -3
  21. package/template/src/components/sections/media/Media.astro +117 -0
  22. package/template/src/components/sections/media/Media.stories.ts +31 -0
  23. package/template/src/components/sections/media/MediaPlayer.astro +148 -0
  24. package/template/src/components/sections/media/definition.ts +72 -0
  25. package/template/src/components/sections/media/source.ts +33 -0
  26. package/template/src/components/sections/media/wireframe.ts +9 -0
  27. package/template/src/components/sections/registry.ts +6 -0
  28. package/template/src/components/sections/steps/Steps.astro +139 -0
  29. package/template/src/components/sections/steps/Steps.stories.ts +27 -0
  30. package/template/src/components/sections/steps/definition.ts +62 -0
  31. package/template/src/components/sections/steps/icons.ts +27 -0
  32. package/template/src/components/sections/steps/wireframe.ts +16 -0
  33. package/template/src/components/sections/text-image/TextImage.astro +2 -4
  34. package/template/src/components/sections/text-image/definition.ts +3 -2
  35. package/template/src/components/sections/video/Video.astro +78 -0
  36. package/template/src/components/sections/video/Video.stories.ts +17 -0
  37. package/template/src/components/sections/video/VideoFrame.astro +60 -0
  38. package/template/src/components/sections/video/definition.ts +58 -0
  39. package/template/src/components/sections/video/wireframe.ts +9 -0
  40. package/template/src/layouts/Base.astro +1 -1
  41. package/template/src/lib/image-url.ts +38 -0
  42. package/template/src/styles/global.css +18 -4
package/CHANGELOG.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # create-bearminion
2
2
 
3
+ ## 1.2.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 755bad0: The Media section's video cover is a responsive image (a WebP `srcset` from 480 to 1920 pixels wide), so a phone no longer downloads the full-size poster, and it loads at once instead of lazily when the section is one of the first two on the page, where it is often the largest thing on screen.
8
+ - 0a2301e: The package pages link to the new website and documentation: the npm homepage is https://bearminion.com, and the READMEs (including the template's) point to https://docs.bearminion.com instead of Markdown files on GitHub.
9
+
10
+ ## 1.2.0
11
+
12
+ ### Minor Changes
13
+
14
+ - 4c3f823: New sections and section options in the default template, from the bearminion.com design:
15
+
16
+ - **Media**: one video or image under a heading, in centered, wide and split layouts, with a shape (16:9, 4:3, 21:9, 1:1) and a switch for the heading. A YouTube or Vimeo link plays through youtube-nocookie.com or Vimeo with Do Not Track, and nothing loads until the visitor presses play.
17
+ - **Steps**: two to six short points with an icon each, as cards, a rail or a list, numbered or not. The grid never leaves one card alone on a row.
18
+ - **Hero**: an install command with a Copy button in three styles (in the button row, a chip, or a terminal), a switch that hides the primary button, and a linked badge in place of the eyebrow, and an optional soft background glow in the accent color.
19
+ - **Features**: the bento layout takes three to six cards instead of exactly four.
20
+ - **Text + Image**: points are rich text, so they can hold bold, links and `code`.
21
+ - **Call to action**: the terminal layout is a dark terminal in every tone, and each printed line can start with a prompt such as → or ✓.
22
+
23
+ Fixes: headings render semibold and eyebrows in the mono font, as the type scale defines (a default weight and font were overriding them); a section with motion switched off is no longer hidden on a long page; an empty Video or Media frame shows only in the editor and is left out of the built site.
24
+ - 9261888: The default template has a new **Video** section (framed, wide and split layouts) for an `.mp4` or `.webm` file, with a poster image, a caption, and a `loop` playback option that plays muted while the video is on screen and stays paused with reduced motion. YouTube and other page links are refused with a message saying why. The hero gains an optional `command`, shown with a copy button, and the terminal call to action's printed lines are now editable (`output`) instead of fixed text.
25
+
26
+ ### Patch Changes
27
+
28
+ - d584e76: The FAQ's split layout had a fixed "Contact us" link to `#contact` that could not be edited or removed, and pointed nowhere on a page without a contact section. It is now an optional `link`, editable in the editor like any other button.
29
+ - 19c1e5a: The default template uses the new field rules: Steps icons are picked from an icon grid, Media shows the Video, Poster and Label fields only for a video, the Image field only for an image, and the heading fields only while Show heading is on, and Hero hides the command while Install command is off, the primary button while it is switched off, the secondary button while the command takes its place, and the badge in the editorial layout.
30
+
3
31
  ## 1.1.0
4
32
 
5
33
  ### Minor Changes
package/README.md CHANGED
@@ -54,12 +54,14 @@ The folder must be new or empty. Inside an existing git repository, the site is
54
54
 
55
55
  ## What you get
56
56
 
57
- The [default BearMinion template](https://github.com/langehelp-software/bearminion/tree/main/templates/default): an Astro and Tailwind site with ten section types, the [`bearminion`](https://www.npmjs.com/package/bearminion) editor at the same version as this package, and content as JSON files you own and commit. The template ships inside this package, so it works offline and always matches its editor.
57
+ The [default BearMinion template](https://github.com/langehelp-software/bearminion/tree/main/templates/default): an Astro and Tailwind site with thirteen section types, the [`bearminion`](https://www.npmjs.com/package/bearminion) editor at the same version as this package, and content as JSON files you own and commit. The template ships inside this package, so it works offline and always matches its editor.
58
58
 
59
59
  ## Learn more
60
60
 
61
+ - [bearminion.com](https://bearminion.com), with a demo of the editor
62
+ - [Getting started](https://docs.bearminion.com/getting-started/): create, edit and put your site online
63
+ - [Documentation](https://docs.bearminion.com)
61
64
  - [BearMinion on GitHub](https://github.com/langehelp-software/bearminion)
62
- - [Documentation](https://github.com/langehelp-software/bearminion/blob/main/docs/README.md)
63
65
 
64
66
  ## License
65
67
 
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "create-bearminion",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "description": "Create a BearMinion site: an Astro website you edit by clicking on it.",
5
5
  "license": "MIT",
6
6
  "author": "Jakub Lange",
7
- "homepage": "https://github.com/langehelp-software/bearminion#readme",
7
+ "homepage": "https://bearminion.com",
8
8
  "repository": {
9
9
  "type": "git",
10
10
  "url": "git+https://github.com/langehelp-software/bearminion.git",
@@ -29,18 +29,18 @@ This template is yours to change: it is the theme. `bearminion` is the only runt
29
29
 
30
30
  Organisms are Header, Navigation, MobileMenu, and Footer. A modal, a dialog, and a cookie banner belong in this folder too. They are the chrome around every page (or a standalone widget), and they are fed by `content/site.json`.
31
31
 
32
- Sections are `hero`, `logoStrip`, `features`, `textImage`, `stats`, `testimonials`, `pricing`, `faq`, `cta`, and `contact`. Each page entry is `{ id, anchor?, type, variant, tone, motion?, props }`. `anchor` is an optional lowercase slug, unique on the page, and it becomes the section element's `id`. `data-section-id` stays the section id. `[data-section-id]` sets `scroll-margin-top: 7rem` so a sticky or floating header does not cover the target. `html` uses `scroll-behavior: smooth`, and `prefers-reduced-motion: reduce` forces `scroll-behavior: auto`. Section components are built from molecules and atoms. A section may use an organism when it needs one, such as a modal. The section root carries `data-section-id`, `data-section-type`, `data-variant`, `data-tone`, and `data-motion`. Atoms accept a `class` and forward `data-*` attributes, including `data-field`, so the editor can find the text.
32
+ Sections are `hero`, `logoStrip`, `steps`, `features`, `textImage`, `stats`, `testimonials`, `pricing`, `video`, `media`, `faq`, `cta`, and `contact`. Each page entry is `{ id, anchor?, type, variant, tone, motion?, props }`. `anchor` is an optional lowercase slug, unique on the page, and it becomes the section element's `id`. `data-section-id` stays the section id. `[data-section-id]` sets `scroll-margin-top: 7rem` so a sticky or floating header does not cover the target. `html` uses `scroll-behavior: smooth`, and `prefers-reduced-motion: reduce` forces `scroll-behavior: auto`. Section components are built from molecules and atoms. A section may use an organism when it needs one, such as a modal. The section root carries `data-section-id`, `data-section-type`, `data-variant`, `data-tone`, and `data-motion`. Atoms accept a `class` and forward `data-*` attributes, including `data-field`, so the editor can find the text.
33
33
 
34
- Paragraph fields (`sub`, `intro`, `body`, `description`, `quote`, `note`, `thanks`, and FAQ answers) are markdown. The template renders bold, italic, links, and paragraphs through `bearminion/rich-text`, escapes every other character, and adds `rel="noopener noreferrer"` on `http` and `https` links.
34
+ Paragraph fields (`sub`, `intro`, `body`, `description`, `quote`, `note`, `thanks`, FAQ answers and Text + Image `points`) are markdown. The template renders bold, italic, inline `code` (a mono chip), links, and paragraphs through `bearminion/rich-text`, escapes every other character, and adds `rel="noopener noreferrer"` on `http` and `https` links.
35
35
 
36
36
  ```text
37
37
  src/components/
38
38
  atoms/ Typography, Button, Image, LogoMark, Emphasis
39
- molecules/ SectionShell, SectionHeader, MediaFrame, Pill
39
+ molecules/ SectionShell, SectionHeader, MediaFrame, Pill, CommandLine
40
40
  organisms/ Header, Navigation, MobileMenu, Footer, plus site-definition.ts (header and footer layouts, limits)
41
41
  sections/ one directory per type, plus definitions.ts, registry.ts, shared.ts, SectionList.astro
42
42
  hero/ definition.ts (zod schema, defaults, options), wireframe.ts (picker thumbnail), Hero.astro, Hero.stories.ts
43
- logo-strip/ features/ text-image/ stats/ testimonials/ pricing/ faq/ cta/ contact/
43
+ logo-strip/ steps/ features/ text-image/ stats/ testimonials/ pricing/ video/ media/ faq/ cta/ contact/
44
44
  seo/ Seo
45
45
  ```
46
46
 
@@ -196,11 +196,11 @@ The style guide at `/style-guide` renders every typography size and button varia
196
196
 
197
197
  The page gets a shadow-root overlay. Click text with `data-field` to edit it, and hover a section (`data-section-id`) for add, duplicate, move, and delete. The corner **Editor** button or Ctrl+E / Cmd+E opens the sidebar. Ctrl+S / Cmd+S saves. Escape leaves a field, then closes the add picker or the sidebar. Unsaved edits turn the corner dot amber and trigger a beforeunload warning.
198
198
 
199
- A variant change or a new, duplicated, moved, or deleted section is re-rendered by `POST /__bearminion/api/preview`. The handler validates the draft, renders `SectionList` with Astro's container API inside the Vite SSR runner, and returns HTML. The overlay replaces `main#content`. Nothing is written until save. In dev, an image path under `src/assets/` that is missing from the eager glob is served from `/__bearminion/assets` so a new upload can use a plain `<img>` immediately. When the file is gone, that route returns a Missing image placeholder instead, so the page shows the gap. Production still resolves that path with `import.meta.glob` and Astro `<Image>`, and still throws if the file is missing; the editor's save validation keeps content from pointing at a missing file.
199
+ A variant change, a new, duplicated, moved, or deleted section, and a sidebar value with no text of its own on the page (such as a video link) are re-rendered by `POST /__bearminion/api/preview`. The handler validates the draft, renders `SectionList` with Astro's container API inside the Vite SSR runner, and returns HTML. The overlay replaces `main#content` and runs scripts the page had not loaded, so a section type added from the picker works at once. A section script binds on load and again on the `bearminion:preview` event, and marks what it bound so it never binds twice. Nothing is written until save. In dev, an image path under `src/assets/` that is missing from the eager glob is served from `/__bearminion/assets` so a new upload can use a plain `<img>` immediately. When the file is gone, that route returns a Missing image placeholder instead, so the page shows the gap. Production still resolves that path with `import.meta.glob` and Astro `<Image>`, and still throws if the file is missing; the editor's save validation keeps content from pointing at a missing file.
200
200
 
201
201
  Header links nest up to three levels (`MAX_NAV_DEPTH`). Desktop navigation uses hover and focus flyouts. The mobile menu uses an accordion. The link picker lists pages, `#anchor` on the current page, `/page#anchor` on other pages, and a custom URL.
202
202
 
203
- The API is `/__bearminion/api` on localhost. Saving `content/*.json` asks the editor to reload. If the draft is still dirty, the editor asks to save, discard, or cancel first. Unsaved drafts are kept in `localStorage` until save or discard. An upload does not reload by itself, so the draft can preview the new file before save. Request and response shapes, with curl examples, are in the [Dev API docs](https://github.com/langehelp-software/bearminion/blob/main/docs/dev-api.md), and every sidebar tab is described in [the editor docs](https://github.com/langehelp-software/bearminion/blob/main/docs/editor.md).
203
+ The API is `/__bearminion/api` on localhost. Saving `content/*.json` asks the editor to reload. If the draft is still dirty, the editor asks to save, discard, or cancel first. Unsaved drafts are kept in `localStorage` until save or discard. An upload does not reload by itself, so the draft can preview the new file before save. Request and response shapes, with curl examples, are in the [Dev API docs](https://docs.bearminion.com/dev-api/), and every sidebar tab is described in [the editor docs](https://docs.bearminion.com/editor/).
204
204
 
205
205
  ```bash
206
206
  pnpm dev
@@ -368,6 +368,10 @@
368
368
  "q": "How do I get help?"
369
369
  }
370
370
  ],
371
+ "link": {
372
+ "href": "#contact",
373
+ "label": "Contact us"
374
+ },
371
375
  "marks": [
372
376
  {
373
377
  "end": 28,
@@ -19,7 +19,7 @@
19
19
  "@fontsource/jetbrains-mono": "^5.3.0",
20
20
  "@tailwindcss/vite": "^4.1.12",
21
21
  "astro": "^7.3.8",
22
- "bearminion": "^1.1.0",
22
+ "bearminion": "^1.2.1",
23
23
  "class-variance-authority": "^0.7.1",
24
24
  "sharp": "^0.35.5",
25
25
  "tailwind-merge": "^3.7.0",
@@ -18,6 +18,7 @@ export const typography = cva('text-balance', {
18
18
  'mx-auto max-w-[16ch] font-sans text-[clamp(2.75rem,1.2rem+6.6cqi,7rem)] leading-[0.93] font-semibold tracking-[-0.05em]',
19
19
  'display-editorial': 'font-sans text-[clamp(3.25rem,0.5rem+11cqi,10.5rem)] leading-[0.86] font-semibold tracking-[-0.06em]',
20
20
  h2: 'font-sans text-[clamp(2rem,1.2rem+3.4cqi,3.75rem)] leading-none font-semibold tracking-[-0.04em]',
21
+ 'h2-md': 'font-sans text-[clamp(2.25rem,1.3rem+3.6cqi,4rem)] leading-[0.98] font-semibold tracking-[-0.045em]',
21
22
  'h2-lg': 'font-sans text-[clamp(2.25rem,1.2rem+4.4cqi,4.75rem)] leading-[0.95] font-semibold tracking-[-0.05em]',
22
23
  h3: 'font-sans text-[1.25rem] leading-[1.25] font-semibold tracking-[-0.02em]',
23
24
  lead: 'text-pretty font-sans text-[clamp(1.0625rem,1rem+0.35cqi,1.25rem)] leading-[1.55]',
@@ -52,11 +53,11 @@ export const typography = cva('text-balance', {
52
53
  right: 'text-right',
53
54
  },
54
55
  },
56
+ // No default weight or font: the size already sets them (headings are semibold, eyebrows mono), and a default here
57
+ // would be merged in after the size and override it.
55
58
  defaultVariants: {
56
59
  size: 'body',
57
60
  color: 'default',
58
- weight: 'regular',
59
- font: 'sans',
60
61
  },
61
62
  })
62
63
 
@@ -0,0 +1,109 @@
1
+ ---
2
+ import { cn } from '../../lib/cn'
3
+
4
+ interface Props {
5
+ /** The shell command, such as `npm create bearminion@latest`. */
6
+ command: string
7
+ /**
8
+ * `chip`: a small pill. `action`: a pill as tall as a button, for a button row. `terminal`: a dark block with a
9
+ * "Terminal" bar, the same in every tone.
10
+ */
11
+ variant?: 'chip' | 'action' | 'terminal'
12
+ class?: string
13
+ 'data-field'?: string
14
+ }
15
+
16
+ const { command, variant = 'chip', class: className, 'data-field': dataField } = Astro.props
17
+ const copyIcon = 'M8 8h11v11H8zM5 16V5h11'
18
+ ---
19
+
20
+ {
21
+ variant === 'terminal' ? (
22
+ <div
23
+ class={cn(
24
+ 'w-full max-w-[30rem] overflow-hidden rounded-2xl bg-[oklch(0.17_0.012_255)] text-left text-[oklch(0.93_0.004_250)] shadow-[0_0_0_1px_oklch(0.97_0.004_250/0.08),0_32px_64px_-40px_oklch(0.15_0.02_255/0.6)]',
25
+ className,
26
+ )}
27
+ data-command-line
28
+ >
29
+ <div class="flex items-center justify-between border-b border-[oklch(0.97_0.004_250/0.08)] py-2 ps-4 pe-2">
30
+ <span class="font-mono text-[0.6875rem] tracking-[0.08em] text-[oklch(0.93_0.004_250/0.55)] uppercase">Terminal</span>
31
+ <button
32
+ type="button"
33
+ class="h-[1.875rem] rounded-full bg-[oklch(0.97_0.004_250/0.08)] px-3 text-[0.75rem] font-medium transition-colors hover:bg-[oklch(0.97_0.004_250/0.14)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
34
+ aria-label="Copy command"
35
+ data-copy-command
36
+ >
37
+ <span data-copy-label>Copy</span>
38
+ </button>
39
+ </div>
40
+ <div class="flex gap-3 overflow-x-auto p-4 font-mono text-[0.8125rem] leading-[1.6] whitespace-pre">
41
+ <span class="text-[oklch(0.87_0.2_145)] select-none" aria-hidden="true">$</span>
42
+ <code data-command data-field={dataField}>{command}</code>
43
+ </div>
44
+ <span class="sr-only" role="status" data-copy-status />
45
+ </div>
46
+ ) : (
47
+ <div
48
+ class={cn(
49
+ 'inline-flex max-w-full items-center rounded-full bg-surface font-mono text-fg ring-line',
50
+ variant === 'action' ? 'h-12 gap-3.5 ps-[1.125rem] pe-1.5 text-[0.875rem]' : 'h-11 gap-3 ps-4 pe-1.5 text-[0.8125rem]',
51
+ className,
52
+ )}
53
+ data-command-line
54
+ >
55
+ <span class="text-muted select-none" aria-hidden="true">$</span>
56
+ <code class="min-w-0 truncate" data-command data-field={dataField}>{command}</code>
57
+ <button
58
+ type="button"
59
+ class={cn(
60
+ 'inline-flex shrink-0 items-center gap-1.5 rounded-full bg-fg font-sans font-medium text-bg transition-opacity hover:opacity-85 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent',
61
+ variant === 'action' ? 'h-9 px-3.5 text-[0.8125rem]' : 'h-8 px-3 text-[0.75rem]',
62
+ )}
63
+ aria-label="Copy command"
64
+ data-copy-command
65
+ >
66
+ {variant === 'action' && (
67
+ <svg viewBox="0 0 24 24" class="size-[13px]" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round" aria-hidden="true">
68
+ <path d={copyIcon} />
69
+ </svg>
70
+ )}
71
+ <span data-copy-label>Copy</span>
72
+ </button>
73
+ <span class="sr-only" role="status" data-copy-status />
74
+ </div>
75
+ )
76
+ }
77
+
78
+ <script>
79
+ /** Copies the command as it is on the page now, so an edit in the editor is what gets copied. */
80
+ function bindCommandLines() {
81
+ for (const button of document.querySelectorAll<HTMLButtonElement>('[data-copy-command]')) {
82
+ if (button.dataset.bound === 'true') continue
83
+ button.dataset.bound = 'true'
84
+ const line = button.closest('[data-command-line]')
85
+ const label = button.querySelector('[data-copy-label]')
86
+ const status = line?.querySelector('[data-copy-status]')
87
+ button.addEventListener('click', async () => {
88
+ const code = line?.querySelector('[data-command]')
89
+ const command = code?.textContent?.trim() ?? ''
90
+ if (!command) return
91
+ try {
92
+ await navigator.clipboard.writeText(command)
93
+ } catch {
94
+ // No clipboard access (an insecure origin, or the permission was refused): select the text instead.
95
+ if (code) window.getSelection()?.selectAllChildren(code)
96
+ return
97
+ }
98
+ if (label) label.textContent = 'Copied'
99
+ if (status) status.textContent = 'Command copied'
100
+ window.setTimeout(() => {
101
+ if (label) label.textContent = 'Copy'
102
+ if (status) status.textContent = ''
103
+ }, 1600)
104
+ })
105
+ }
106
+ }
107
+ bindCommandLines()
108
+ document.addEventListener('bearminion:preview', bindCommandLines)
109
+ </script>
@@ -11,7 +11,7 @@ interface Props {
11
11
  intro?: string
12
12
  align?: 'left' | 'center'
13
13
  headingAs?: 'h1' | 'h2'
14
- size?: 'h2' | 'h2-lg' | 'display' | 'display-center' | 'display-editorial'
14
+ size?: 'h2' | 'h2-md' | 'h2-lg' | 'display' | 'display-center' | 'display-editorial'
15
15
  field?: (path: string) => string
16
16
  /** Prop name used for the intro `data-field` path. */
17
17
  introField?: string
@@ -11,11 +11,14 @@ interface Props {
11
11
  const { sections, motion = true } = Astro.props
12
12
  const Hero = sectionComponents.hero
13
13
  const LogoStrip = sectionComponents.logoStrip
14
+ const Steps = sectionComponents.steps
14
15
  const Features = sectionComponents.features
15
16
  const TextImage = sectionComponents.textImage
16
17
  const Stats = sectionComponents.stats
17
18
  const Testimonials = sectionComponents.testimonials
18
19
  const Pricing = sectionComponents.pricing
20
+ const Video = sectionComponents.video
21
+ const Media = sectionComponents.media
19
22
  const Faq = sectionComponents.faq
20
23
  const Cta = sectionComponents.cta
21
24
  const Contact = sectionComponents.contact
@@ -28,6 +31,8 @@ const Contact = sectionComponents.contact
28
31
  return <Hero section={section} index={index} motion={motion} />
29
32
  case 'logoStrip':
30
33
  return <LogoStrip section={section} index={index} motion={motion} />
34
+ case 'steps':
35
+ return <Steps section={section} index={index} motion={motion} />
31
36
  case 'features':
32
37
  return <Features section={section} index={index} motion={motion} />
33
38
  case 'textImage':
@@ -38,6 +43,10 @@ const Contact = sectionComponents.contact
38
43
  return <Testimonials section={section} index={index} motion={motion} />
39
44
  case 'pricing':
40
45
  return <Pricing section={section} index={index} motion={motion} />
46
+ case 'video':
47
+ return <Video section={section} index={index} motion={motion} />
48
+ case 'media':
49
+ return <Media section={section} index={index} motion={motion} />
41
50
  case 'faq':
42
51
  return <Faq section={section} index={index} motion={motion} />
43
52
  case 'cta':
@@ -109,7 +109,7 @@ const bandSecondary = props.secondary ? buttonVariant(props.secondary, 'secondar
109
109
  }
110
110
  {
111
111
  section.variant === 'terminal' && (
112
- <div class="section-container grid items-center gap-10 @min-[880px]:grid-cols-[minmax(22rem,1fr)_minmax(0,1fr)]" data-cta>
112
+ <div class="section-container grid grid-cols-[repeat(auto-fit,minmax(min(100%,22rem),1fr))] items-center gap-[clamp(2.5rem,6cqi,5rem)]" data-cta>
113
113
  {
114
114
  (props.heading || props.body || hasActions) && (
115
115
  <div class="flex flex-col items-start gap-6">
@@ -136,20 +136,43 @@ const bandSecondary = props.secondary ? buttonVariant(props.secondary, 'secondar
136
136
  </div>
137
137
  )
138
138
  }
139
- <div class="rounded-panel bg-fg p-6 text-bg">
140
- <div class="mb-4 flex items-center justify-between">
141
- <span class="font-mono text-[0.6875rem] tracking-[0.08em] text-bg/60 uppercase">terminal</span>
139
+ {/* A dark terminal in every tone, like the one on your machine. Nothing to print, no terminal. */}
140
+ {(props.command || (props.output ?? []).length > 0) && (<div class="overflow-hidden rounded-[1.25rem] bg-[oklch(0.17_0.012_255)] text-[oklch(0.93_0.004_250)] shadow-[0_0_0_1px_oklch(0.97_0.004_250/0.08),0_40px_80px_-40px_oklch(0.15_0.02_255/0.6)]">
141
+ <div class="flex items-center justify-between border-b border-[oklch(0.97_0.004_250/0.08)] py-3 ps-4 pe-3">
142
+ <div class="flex gap-1.5" aria-hidden="true">
143
+ <span class="size-2.5 rounded-full bg-[oklch(0.97_0.004_250/0.18)]" />
144
+ <span class="size-2.5 rounded-full bg-[oklch(0.97_0.004_250/0.18)]" />
145
+ <span class="size-2.5 rounded-full bg-[oklch(0.97_0.004_250/0.18)]" />
146
+ </div>
142
147
  {props.command && (
143
- <button type="button" data-copy class="font-mono text-[0.75rem] text-accent" data-command={props.command}>
148
+ <button
149
+ type="button"
150
+ data-copy
151
+ class="h-[1.875rem] rounded-full bg-[oklch(0.97_0.004_250/0.08)] px-3 text-[0.75rem] font-medium transition-colors hover:bg-[oklch(0.97_0.004_250/0.14)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[oklch(0.87_0.2_145)]"
152
+ aria-label="Copy command"
153
+ data-command={props.command}
154
+ >
144
155
  <span data-copy-label>Copy</span>
145
156
  </button>
146
157
  )}
147
158
  </div>
148
- <pre class="font-mono text-[0.8125rem] leading-[1.7] whitespace-pre-wrap text-bg/90"><code>{`$ ${props.command ?? ''}
149
- # pages live in content/pages
150
- # site chrome lives in content/site.json
151
- # production is the static build`}</code></pre>
152
- </div>
159
+ <ol class="flex flex-col gap-2.5 overflow-x-auto px-5 pt-5 pb-6 font-mono text-[clamp(0.75rem,0.7rem+0.2cqi,0.875rem)] leading-[1.6]">
160
+ {props.command && (
161
+ <li class="flex gap-3 whitespace-pre">
162
+ <span class="text-[oklch(0.87_0.2_145)] select-none" aria-hidden="true">$</span>
163
+ <span data-terminal-command data-field={field('command')}>{props.command}</span>
164
+ </li>
165
+ )}
166
+ {(props.output ?? []).map((line, lineIndex) => (
167
+ <li class="flex gap-3 whitespace-pre">
168
+ <span class="min-w-[1ch] text-[oklch(0.87_0.2_145)] select-none" aria-hidden={line.prompt ? undefined : 'true'} data-field={line.prompt ? field(`output.${lineIndex}.prompt`) : undefined}>
169
+ {line.prompt ?? ' '}
170
+ </span>
171
+ <span class="text-[oklch(0.93_0.004_250/0.45)]" data-field={field(`output.${lineIndex}.text`)}>{line.text}</span>
172
+ </li>
173
+ ))}
174
+ </ol>
175
+ </div>)}
153
176
  </div>
154
177
  )
155
178
  }
@@ -163,7 +186,9 @@ const bandSecondary = props.secondary ? buttonVariant(props.secondary, 'secondar
163
186
  const label = button.querySelector('[data-copy-label]')
164
187
  const original = label?.textContent ?? 'Copy'
165
188
  button.addEventListener('click', async () => {
166
- const command = button.dataset.command ?? ''
189
+ // The command as it is on the page now, so an edit made in the editor is what gets copied.
190
+ const shown = button.closest('[data-cta]')?.querySelector('[data-terminal-command]')?.textContent?.trim()
191
+ const command = shown || button.dataset.command || ''
167
192
  try {
168
193
  await navigator.clipboard.writeText(command)
169
194
  } catch {
@@ -1,14 +1,23 @@
1
- import type { z } from 'zod'
1
+ import { z } from 'zod'
2
2
  import { defineSection, linkSchema, richTextSchema, textSchema, withEmphasis } from 'bearminion'
3
3
  import { accent, heading } from '../shared'
4
4
  import { ctaWireframe } from './wireframe'
5
5
 
6
6
  export const ctaVariants = ['band', 'centered', 'terminal'] as const
7
7
 
8
+ export const ctaLineSchema = z
9
+ .object({
10
+ prompt: textSchema.optional(),
11
+ text: textSchema,
12
+ })
13
+ .strict()
14
+
8
15
  export const ctaSchema = withEmphasis({
9
16
  heading,
10
17
  body: richTextSchema.optional(),
11
18
  command: textSchema.optional(),
19
+ /** Lines the terminal variant shows under the command, such as what it prints. `prompt` is a mark before the line, such as → or ✓. */
20
+ output: z.array(ctaLineSchema).max(8).optional(),
12
21
  primary: linkSchema.optional(),
13
22
  secondary: linkSchema.optional(),
14
23
  })
@@ -25,11 +34,16 @@ export const cta = defineSection({
25
34
  marks: accent('Ready to get started?', 'get started?'),
26
35
  body: 'One sentence that repeats the main benefit and tells the reader what happens next.',
27
36
  command: 'npm create astro@latest',
37
+ output: [
38
+ { text: '# pages live in content/pages' },
39
+ { text: '# site chrome lives in content/site.json' },
40
+ { text: '# production is the static build' },
41
+ ],
28
42
  primary: { label: 'Primary action', href: '#' },
29
43
  secondary: { label: 'Secondary action', href: '#' },
30
44
  },
31
45
  variants: ctaVariants,
32
46
  defaultVariant: 'band',
33
- optional: { props: ['heading', 'body', 'command', 'primary', 'secondary'] },
47
+ optional: { props: ['heading', 'body', 'command', 'output', 'primary', 'secondary'], items: { output: ['prompt'] } },
34
48
  wireframe: ctaWireframe,
35
49
  })
@@ -12,10 +12,13 @@ import { faq } from './faq/definition'
12
12
  import { features } from './features/definition'
13
13
  import { hero } from './hero/definition'
14
14
  import { logoStrip } from './logo-strip/definition'
15
+ import { media } from './media/definition'
15
16
  import { pricing } from './pricing/definition'
16
17
  import { stats } from './stats/definition'
18
+ import { steps } from './steps/definition'
17
19
  import { testimonials } from './testimonials/definition'
18
20
  import { textImage } from './text-image/definition'
21
+ import { video } from './video/definition'
19
22
 
20
23
  /**
21
24
  * Every section type of this site. Each one is defined in its own directory:
@@ -27,11 +30,14 @@ import { textImage } from './text-image/definition'
27
30
  export const sectionDefinitions = {
28
31
  hero,
29
32
  logoStrip,
33
+ steps,
30
34
  features,
31
35
  textImage,
32
36
  stats,
33
37
  testimonials,
34
38
  pricing,
39
+ video,
40
+ media,
35
41
  faq,
36
42
  cta,
37
43
  contact,
@@ -1,5 +1,5 @@
1
1
  ---
2
- import { DEFAULT_CONTACT_ANCHOR } from '../shared'
2
+ import { anchorAttrs } from '../../../lib/link-attrs'
3
3
  import type { Section } from '../definitions'
4
4
  import Typography from '../../atoms/Typography.astro'
5
5
  import SectionHeader from '../../molecules/SectionHeader.astro'
@@ -50,9 +50,14 @@ const reveal = section.motion ?? motion
50
50
  {props.more}
51
51
  </Typography>
52
52
  )}
53
- <a href={`#${DEFAULT_CONTACT_ANCHOR}`} class="text-[0.9375rem] font-medium text-link underline-offset-4 hover:underline">
54
- Contact us →
55
- </a>
53
+ {props.link && (
54
+ <a
55
+ {...anchorAttrs(props.link)}
56
+ class="text-[0.9375rem] font-medium text-link underline-offset-4 hover:underline"
57
+ >
58
+ <span data-field={field('link.label')}>{props.link.label}</span> <span aria-hidden="true">→</span>
59
+ </a>
60
+ )}
56
61
  </div>
57
62
  <div>
58
63
  {props.items.map((item, itemIndex) => (
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod'
2
- import { defineSection, richTextSchema, textSchema, withEmphasis, type CountRule } from 'bearminion'
3
- import { accent, eyebrow, heading } from '../shared'
2
+ import { defineSection, linkSchema, richTextSchema, textSchema, withEmphasis, type CountRule } from 'bearminion'
3
+ import { DEFAULT_CONTACT_ANCHOR, accent, eyebrow, heading } from '../shared'
4
4
  import { faqWireframe } from './wireframe'
5
5
 
6
6
  export const faqVariants = ['accordion', 'split', 'grid'] as const
@@ -21,6 +21,8 @@ export const faqSchema = withEmphasis({
21
21
  eyebrow,
22
22
  heading,
23
23
  more: textSchema.optional(),
24
+ /** The link under the heading in the split layout, such as one to the contact section. */
25
+ link: linkSchema.optional(),
24
26
  items: z.array(faqItemSchema).min(faqCounts.items.min).max(faqCounts.items.max),
25
27
  })
26
28
 
@@ -36,6 +38,7 @@ export const faq = defineSection({
36
38
  eyebrow: 'FAQ',
37
39
  heading: 'Questions, answered plainly.',
38
40
  marks: accent('Questions, answered plainly.', 'plainly.'),
41
+ link: { label: 'Contact us', href: `#${DEFAULT_CONTACT_ANCHOR}` },
39
42
  items: [
40
43
  { q: 'What is included?', a: 'Answer the question people ask most before they buy, in two or three sentences.' },
41
44
  { q: 'How long does it take to get started?', a: 'Give a realistic time frame and say what the reader needs to have ready.' },
@@ -45,7 +48,7 @@ export const faq = defineSection({
45
48
  },
46
49
  variants: faqVariants,
47
50
  defaultVariant: 'accordion',
48
- optional: { props: ['eyebrow', 'heading', 'more'] },
51
+ optional: { props: ['eyebrow', 'heading', 'more', 'link'] },
49
52
  counts: faqCounts,
50
53
  wireframe: faqWireframe,
51
54
  })
@@ -6,6 +6,7 @@ import Typography from '../../atoms/Typography.astro'
6
6
  import MediaFrame from '../../molecules/MediaFrame.astro'
7
7
  import SectionHeader from '../../molecules/SectionHeader.astro'
8
8
  import SectionShell from '../../molecules/SectionShell.astro'
9
+ import { cn } from '../../../lib/cn'
9
10
 
10
11
  interface Props {
11
12
  section: Extract<Section, { type: 'features' }>
@@ -17,6 +18,9 @@ const { section, index, motion = true } = Astro.props
17
18
  const props = section.props
18
19
  const field = (path: string) => `sections.${index}.props.${path}`
19
20
  const reveal = section.motion ?? motion
21
+ const wideFourth = props.items.length === 4
22
+ /** At two columns, an even count leaves the last card alone on its row, so it spans the row with its text beside the picture. */
23
+ const spanLast = (itemIndex: number) => !wideFourth && itemIndex === props.items.length - 1 && props.items.length % 2 === 0
20
24
  ---
21
25
 
22
26
  <SectionShell id={section.id} anchor={section.anchor} type={section.type} variant={section.variant} tone={section.tone} motion={reveal}>
@@ -88,29 +92,50 @@ const reveal = section.motion ?? motion
88
92
  section.variant === 'bento' && (
89
93
  <div class="section-container">
90
94
  <SectionHeader eyebrow={props.eyebrow} heading={props.heading} marks={props.marks} intro={props.intro} align="center" field={field} class="mx-auto max-w-3xl" />
91
- <ul class="mt-12 grid grid-cols-1 gap-4 @min-[620px]:grid-cols-2 @min-[960px]:grid-cols-3">
95
+ <ul class="mt-[clamp(2.5rem,4cqi,4rem)] grid grid-cols-1 gap-4 @min-[620px]:grid-cols-2 @min-[960px]:grid-cols-3">
92
96
  {props.items.map((item, itemIndex) => (
93
97
  <li
94
98
  class:list={[
95
- 'flex flex-col gap-4 rounded-card-lg bg-surface p-6 ring-line',
96
- itemIndex === 0 && '@min-[620px]:col-span-2 @min-[960px]:row-span-2',
97
- itemIndex === 3 && '@min-[620px]:col-span-2 @min-[960px]:col-span-3 @min-[960px]:grid @min-[960px]:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)] @min-[960px]:items-end @min-[960px]:gap-8',
99
+ 'flex min-h-72 flex-col gap-4 rounded-card-lg bg-surface p-2 ring-line',
100
+ // The first card is the big one: two columns, and two rows at three columns.
101
+ itemIndex === 0 && '@min-[620px]:col-span-2 @min-[620px]:min-h-[26rem] @min-[960px]:row-span-2',
102
+ // Four cards: the fourth spans the row, with its text beside the picture at three columns.
103
+ wideFourth &&
104
+ itemIndex === 3 &&
105
+ '@min-[620px]:col-span-2 @min-[960px]:col-span-3 @min-[960px]:flex-row-reverse @min-[960px]:items-center',
106
+ // Any other count at two columns: the last card spans the row when it would sit alone, picture beside text.
107
+ spanLast(itemIndex) && '@min-[620px]:@max-[959px]:col-span-2 @min-[620px]:@max-[959px]:flex-row-reverse @min-[620px]:@max-[959px]:items-center',
98
108
  ]}
99
109
  >
100
110
  {!item.image?.hidden && (
101
- <MediaFrame
102
- src={item.image?.src}
103
- alt={item.image?.alt ?? item.title}
104
- aspectClass={itemIndex === 0 ? 'aspect-[4/3] min-h-48 flex-1' : 'aspect-[4/3]'}
105
- fit={item.image?.fit} position={item.image?.position} data-field={field(`items.${itemIndex}.image.alt`)}
106
- />
107
- )}
108
- <div>
109
- <p class="font-mono text-[0.6875rem] tracking-[0.08em] text-muted">{String(itemIndex + 1).padStart(2, '0')}</p>
110
- <Typography as="h3" size="h3" class="mt-3" data-field={field(`items.${itemIndex}.title`)}>
111
+ <MediaFrame
112
+ src={item.image?.src}
113
+ alt={item.image?.alt ?? item.title}
114
+ aspectClass={cn(
115
+ 'min-h-36 flex-1',
116
+ wideFourth && itemIndex === 3 && '@min-[960px]:self-stretch',
117
+ spanLast(itemIndex) && '@min-[620px]:@max-[959px]:self-stretch',
118
+ )}
119
+ roundedClass="rounded-2xl"
120
+ class="p-3"
121
+ fit={item.image?.fit}
122
+ position={item.image?.position}
123
+ data-field={field(`items.${itemIndex}.image.alt`)}
124
+ />
125
+ )}
126
+ <div
127
+ class:list={[
128
+ 'flex flex-col gap-1.5 px-2 pt-1 pb-2',
129
+ wideFourth && itemIndex === 3 && '@min-[960px]:flex-1 @min-[960px]:px-6',
130
+ spanLast(itemIndex) && '@min-[620px]:@max-[959px]:flex-1 @min-[620px]:@max-[959px]:px-6',
131
+ ]}
132
+ >
133
+ <Typography as="h3" size="h3" data-field={field(`items.${itemIndex}.title`)}>
111
134
  {item.title}
112
135
  </Typography>
113
- {item.description && <Typography as="p" size="body" color="muted" class="mt-2" data-field={field(`items.${itemIndex}.description`)} markdown={item.description} />}
136
+ {item.description && (
137
+ <Typography as="p" size="body" color="muted" class="max-w-[30rem]" data-field={field(`items.${itemIndex}.description`)} markdown={item.description} />
138
+ )}
114
139
  </div>
115
140
  </li>
116
141
  ))}
@@ -5,9 +5,9 @@ import { featuresWireframe } from './wireframe'
5
5
 
6
6
  export const featuresVariants = ['cards', 'list', 'bento'] as const
7
7
 
8
- /** Three to six cards; the bento layout is a fixed four-tile grid. */
8
+ /** Three to six cards. In the bento grid, four keeps a wide fourth card; any other count fills rows of three. */
9
9
  export const featuresCounts = {
10
- items: { recommended: '4 for bento, 3–6 otherwise', min: 3, max: 6, exact: { variant: 'bento', count: 4 } },
10
+ items: { recommended: '3–6; 4 or 6 fill the bento grid best', min: 3, max: 6 },
11
11
  } as const satisfies Record<string, CountRule>
12
12
 
13
13
  export const featureItemSchema = z
@@ -51,14 +51,4 @@ export const features = defineSection({
51
51
  optional: { props: ['eyebrow', 'heading', 'intro', 'link'], items: { items: ['description', 'image'] } },
52
52
  counts: featuresCounts,
53
53
  wireframe: featuresWireframe,
54
- refine({ variant, props }, ctx) {
55
- const { variant: exactVariant, count } = featuresCounts.items.exact
56
- if (variant === exactVariant && props.items.length !== count) {
57
- ctx.addIssue({
58
- code: 'custom',
59
- message: `The ${exactVariant} variant needs exactly ${count} feature items`,
60
- path: ['props', 'items'],
61
- })
62
- }
63
- },
64
54
  })