bsmnt 0.13.0 → 0.14.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.
Files changed (90) hide show
  1. package/dist/modules/features/cms/sanity-pagebuilder/config.d.ts.map +1 -1
  2. package/dist/modules/features/cms/sanity-pagebuilder/config.js +1 -0
  3. package/dist/modules/features/cms/sanity-pagebuilder/config.js.map +1 -1
  4. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.d.ts.map +1 -1
  5. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js +1 -0
  6. package/dist/modules/features/cms/sanity-pagebuilder-cache/config.js.map +1 -1
  7. package/package.json +1 -1
  8. package/src/agent-skills/ashby-pagebuilder-block/SKILL.md +29 -11
  9. package/src/agent-skills/hubspot-pagebuilder-block/SKILL.md +31 -12
  10. package/src/agent-skills/page-builder-block/SKILL.md +26 -13
  11. package/src/modules/features/cms/sanity-pagebuilder/files/app/api/revalidate/route.ts +2 -2
  12. package/src/modules/features/cms/sanity-pagebuilder/files/components/sanity/rich-text.tsx +1 -1
  13. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  14. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  15. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  16. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  17. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  18. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/icons.ts +2 -2
  19. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/page-builder-config.ts +22 -50
  20. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/presentation.ts +1 -18
  21. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/queries.ts +56 -64
  22. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/sanity.config.ts +4 -8
  23. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  24. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  25. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/index.ts +1 -4
  26. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  27. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  28. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/shared/page.ts +11 -37
  29. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/singletons.ts +0 -16
  30. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/structure.ts +35 -33
  31. package/src/modules/features/cms/sanity-pagebuilder/files/lib/utils/studio-embed.ts +22 -0
  32. package/src/modules/features/cms/sanity-pagebuilder-cache/files/app/api/revalidate/route.ts +2 -2
  33. package/src/modules/features/cms/sanity-pagebuilder-cache/files/components/sanity/rich-text.tsx +1 -1
  34. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/block-actions-item.tsx +2 -2
  35. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-frame.tsx +124 -0
  36. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/component-preview-pane.tsx +25 -0
  37. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-input.tsx +3 -26
  38. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/page-builder-insert-menu.tsx +121 -93
  39. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/icons.ts +2 -2
  40. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/page-builder-config.ts +22 -50
  41. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/presentation.ts +1 -18
  42. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/queries.ts +56 -64
  43. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/sanity.config.ts +4 -8
  44. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/index.ts +0 -1
  45. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/reusable/blog-content.ts +2 -2
  46. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/index.ts +1 -4
  47. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/block.ts +4 -4
  48. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page-builder.ts +4 -18
  49. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/shared/page.ts +11 -37
  50. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/singletons.ts +0 -16
  51. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/structure.ts +35 -33
  52. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/utils/studio-embed.ts +22 -0
  53. package/src/modules/features/forms/hubspot/files/_sanity-adapter/form-block.ts +4 -10
  54. package/src/modules/features/forms/hubspot/files/lib/integrations/hubspot/README.md +10 -5
  55. package/src/modules/features/jobs/ashby/files/_sanity-adapter/job-board-block.ts +0 -7
  56. package/src/modules/features/jobs/ashby/files/lib/integrations/ashby/README.md +11 -5
  57. package/src/templates/next-default/lib/utils/README.md +1 -0
  58. package/src/templates/next-default/lib/utils/image-sizes.ts +64 -25
  59. package/src/templates/next-experiments/lib/utils/README.md +1 -0
  60. package/src/templates/next-experiments/lib/utils/image-sizes.ts +64 -25
  61. package/src/templates/next-pagebuilder/AGENTS.md +19 -7
  62. package/src/templates/next-pagebuilder/README.md +15 -3
  63. package/src/templates/next-pagebuilder/app/actions/unlock-gated-content.ts +5 -4
  64. package/src/templates/next-pagebuilder/app/components/page.tsx +81 -0
  65. package/src/templates/next-pagebuilder/components/page-builder/components/gated-content/index.tsx +8 -3
  66. package/src/templates/next-pagebuilder/components/page-builder/library.ts +183 -0
  67. package/src/templates/next-pagebuilder/components/page-builder/registry.tsx +4 -1
  68. package/src/templates/next-pagebuilder/components/page-builder/renderer.tsx +3 -1
  69. package/src/templates/next-pagebuilder/components/page-builder/stage.tsx +10 -0
  70. package/src/templates/next-pagebuilder/components/page-document/index.tsx +2 -1
  71. package/src/templates/next-pagebuilder/lib/integrations/sanity/sanity.types.ts +139 -271
  72. package/src/templates/next-pagebuilder/lib/utils/image-sizes.ts +64 -25
  73. package/src/templates/next-pagebuilder-cache/AGENTS.md +19 -7
  74. package/src/templates/next-pagebuilder-cache/README.md +15 -3
  75. package/src/templates/next-pagebuilder-cache/app/actions/unlock-gated-content.ts +5 -4
  76. package/src/templates/next-pagebuilder-cache/app/components/page.tsx +91 -0
  77. package/src/templates/next-pagebuilder-cache/components/page-builder/components/gated-content/index.tsx +8 -3
  78. package/src/templates/next-pagebuilder-cache/components/page-builder/library.ts +183 -0
  79. package/src/templates/next-pagebuilder-cache/components/page-builder/registry.tsx +4 -1
  80. package/src/templates/next-pagebuilder-cache/components/page-builder/renderer.tsx +3 -1
  81. package/src/templates/next-pagebuilder-cache/components/page-builder/stage.tsx +10 -0
  82. package/src/templates/next-pagebuilder-cache/components/page-document/index.tsx +2 -1
  83. package/src/templates/next-pagebuilder-cache/lib/integrations/sanity/sanity.types.ts +139 -271
  84. package/src/templates/next-pagebuilder-cache/lib/utils/image-sizes.ts +64 -25
  85. package/src/templates/next-webgl/lib/utils/README.md +1 -0
  86. package/src/templates/next-webgl/lib/utils/image-sizes.ts +64 -25
  87. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  88. package/src/modules/features/cms/sanity-pagebuilder/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
  89. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/components/use-studio-preview-urls.ts +0 -62
  90. package/src/modules/features/cms/sanity-pagebuilder-cache/files/lib/integrations/sanity/schemas/components/studio-preview.ts +0 -37
@@ -105,10 +105,15 @@ selectable block. Adding this integration installs the
105
105
  HubSpot form into the page builder" and it makes the edits and verifies the
106
106
  typecheck. To do it by hand:
107
107
 
108
- 1. `schemas/index.ts` — import `hubspotFormBlock`, add it to `schema.types`
108
+ 1. `schemas/components/reusable/hubspot-form.ts` — re-declare the adapter
109
+ through `defineBlock`, so the block is an inline object with Anchor ID and
110
+ Presentation Mode; export it from `schemas/components/index.ts` and add it to
111
+ `schema.types` in `schemas/index.ts`
109
112
  2. `page-builder-config.ts` — add `"hubspotFormBlock"` to
110
- `PageBuilderDocumentType` and a `hubspotFormBlockReference` member
111
- 3. `structure.ts` — add `S.documentTypeListItem("hubspotFormBlock")`
112
- 4. `components/page-builder/renderer.tsx` — add a `case "hubspotFormBlock"` that
113
- decodes `block.form` and renders `<HubSpotForm />`
113
+ `PageBuilderDocumentType` and a `pageBuilderBlockMembers` entry with
114
+ `pageTypes` and `category`
115
+ 3. `components/page-builder/registry.tsx` — one entry keyed `hubspotFormBlock`
116
+ that decodes `block.form` and renders `<HubSpotForm />`
117
+ 4. `components/page-builder/library.ts` — a fixture entry, which is what the
118
+ insert menu, Studio → Components and `/components` render
114
119
  5. `bun run sanity:typegen && bun typecheck`
@@ -44,10 +44,3 @@ export const ashbyJobBoardBlock = defineType({
44
44
  },
45
45
  },
46
46
  })
47
-
48
- export const ashbyJobBoardBlockReference = defineType({
49
- name: "ashbyJobBoardBlockReference",
50
- title: "Ashby Job Board",
51
- type: "reference",
52
- to: [{ type: "ashbyJobBoardBlock" }],
53
- })
@@ -125,10 +125,16 @@ selectable block. Adding this integration installs the
125
125
  Ashby job board into the page builder" and it makes the edits and verifies the
126
126
  typecheck. To do it by hand:
127
127
 
128
- 1. `schemas/index.ts` — import `ashbyJobBoardBlock`, add it to `schema.types`
128
+ 1. `schemas/components/reusable/ashby-job-board.ts` — re-declare the adapter
129
+ through `defineBlock`, so the block is an inline object with Anchor ID and
130
+ Presentation Mode; export it from `schemas/components/index.ts` and add it to
131
+ `schema.types` in `schemas/index.ts`
129
132
  2. `page-builder-config.ts` — add `"ashbyJobBoardBlock"` to
130
- `PageBuilderDocumentType` and an `ashbyJobBoardBlockReference` member
131
- 3. `structure.ts` — add `S.documentTypeListItem("ashbyJobBoardBlock")`
132
- 4. `components/page-builder/renderer.tsx` — add a `case "ashbyJobBoardBlock"`
133
- that awaits `getCareerJobs(block.boardName)` and renders the list
133
+ `PageBuilderDocumentType` and a `pageBuilderBlockMembers` entry with
134
+ `pageTypes` and `category`
135
+ 3. `components/page-builder/registry.tsx` — one entry keyed
136
+ `ashbyJobBoardBlock` that awaits `getCareerJobs(block.boardName)` and renders
137
+ the list
138
+ 4. `components/page-builder/library.ts` — a fixture entry, which is what the
139
+ insert menu, Studio → Components and `/components` render
134
140
  5. `bun run sanity:typegen && bun typecheck`
@@ -45,6 +45,7 @@ export const metadata = generatePageMetadata({ title: 'About' })
45
45
  // next/image sizes — keys mirror the element's Tailwind variants
46
46
  buildImageSizes({ base: '100vw', desktop: '796px' })
47
47
  buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 })
48
+ buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 }, { maxWidth: 1440 })
48
49
 
49
50
  // Refs — in a component, use the hook: an inline mergeRefs([...]) is a new
50
51
  // callback each render, so React detaches and reattaches every ref.
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -45,6 +45,7 @@ export const metadata = generatePageMetadata({ title: 'About' })
45
45
  // next/image sizes — keys mirror the element's Tailwind variants
46
46
  buildImageSizes({ base: '100vw', desktop: '796px' })
47
47
  buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 })
48
+ buildGridImageSizes({ base: 1, tablet: 2, desktop: 3 }, { maxWidth: 1440 })
48
49
 
49
50
  // Refs — in a component, use the hook: an inline mergeRefs([...]) is a new
50
51
  // callback each render, so React detaches and reattaches every ref.
@@ -15,40 +15,55 @@ export type ByBreakpoint<
15
15
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
16
16
  > = { base: T } & Partial<Record<Extract<keyof B, string>, T>>
17
17
 
18
+ export type ImageSizesOptions<
19
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
20
+ > = { breakpoints?: B }
21
+
22
+ export type GridImageSizesOptions<
23
+ B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
24
+ > = ImageSizesOptions<B> & {
25
+ /** The grid container's `max-width` in px, past which columns stop growing. */
26
+ maxWidth?: number
27
+ }
28
+
29
+ type Condition<T> = { min: number; value: T }
30
+
18
31
  function columnWidth(columns: number): string {
19
32
  return `${Math.round(100 / columns)}vw`
20
33
  }
21
34
 
35
+ function toConditions<T>(
36
+ overrides: Readonly<Record<string, T | undefined>>,
37
+ scale: BreakpointMap
38
+ ): Condition<T>[] {
39
+ return Object.entries(overrides).flatMap(([name, value]) => {
40
+ const min = scale[name]
41
+ return value !== undefined && min !== undefined ? [{ min, value }] : []
42
+ })
43
+ }
44
+
22
45
  /**
23
46
  * Widest first, since `sizes` takes the first matching condition and the last
24
47
  * entry must be bare. `min-width` matches the Tailwind variant's boundary; a
25
48
  * `max-width: <next> - 1` one would leave a fractional-pixel gap.
26
49
  */
27
- function toSizes(
28
- base: string,
29
- overrides: Readonly<Record<string, string | undefined>>,
30
- scale: BreakpointMap
31
- ): string {
32
- const conditions: string[] = []
33
-
34
- for (const [name, min] of Object.entries(scale).sort(
35
- ([, a], [, b]) => b - a
36
- )) {
37
- const value = overrides[name]
38
- if (value !== undefined) {
39
- conditions.push(`(min-width: ${min}px) ${value}`)
40
- }
41
- }
50
+ function joinSizes(base: string, conditions: Condition<string>[]): string {
51
+ const sorted = [...conditions].sort((a, b) => b.min - a.min)
42
52
 
43
- return [...conditions, base].join(", ")
53
+ return [
54
+ ...sorted.map(({ min, value }) => `(min-width: ${min}px) ${value}`),
55
+ base,
56
+ ].join(", ")
44
57
  }
45
58
 
46
59
  /** Builds `sizes` from the CSS length the image renders at per breakpoint. */
47
60
  export function buildImageSizes<
48
61
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
49
- >(widths: ByBreakpoint<string, B>, breakpoints?: B): string {
62
+ >(widths: ByBreakpoint<string, B>, options: ImageSizesOptions<B> = {}): string {
50
63
  const { base, ...overrides } = widths
51
- return toSizes(base, overrides, breakpoints ?? BASEMENT_BREAKPOINTS)
64
+ const scale = options.breakpoints ?? BASEMENT_BREAKPOINTS
65
+
66
+ return joinSizes(base, toConditions<string>(overrides, scale))
52
67
  }
53
68
 
54
69
  /**
@@ -58,15 +73,39 @@ export function buildImageSizes<
58
73
  */
59
74
  export function buildGridImageSizes<
60
75
  B extends BreakpointMap = typeof BASEMENT_BREAKPOINTS,
61
- >(columns: ByBreakpoint<number, B>, breakpoints?: B): string {
76
+ >(
77
+ columns: ByBreakpoint<number, B>,
78
+ options: GridImageSizesOptions<B> = {}
79
+ ): string {
80
+ const { maxWidth, breakpoints } = options
62
81
  const { base, ...overrides } = columns
63
- const widths: Record<string, string> = {}
82
+ const steps = toConditions<number>(
83
+ overrides,
84
+ breakpoints ?? BASEMENT_BREAKPOINTS
85
+ )
86
+ const cap =
87
+ maxWidth !== undefined && Number.isFinite(maxWidth) && maxWidth > 0
88
+ ? maxWidth
89
+ : undefined
90
+
91
+ // The cap rarely lands on a breakpoint, so it needs its own condition.
92
+ if (cap !== undefined && !steps.some(({ min }) => min === cap)) {
93
+ const below = steps
94
+ .filter(({ min }) => min < cap)
95
+ .sort((a, b) => a.min - b.min)
96
+ .at(-1)
64
97
 
65
- for (const [name, count] of Object.entries(overrides)) {
66
- if (count !== undefined) {
67
- widths[name] = columnWidth(count)
68
- }
98
+ steps.push({ min: cap, value: below?.value ?? base })
69
99
  }
70
100
 
71
- return toSizes(columnWidth(base), widths, breakpoints ?? BASEMENT_BREAKPOINTS)
101
+ return joinSizes(
102
+ columnWidth(base),
103
+ steps.map(({ min, value }) => ({
104
+ min,
105
+ value:
106
+ cap !== undefined && min >= cap
107
+ ? `${Math.round(cap / value)}px`
108
+ : columnWidth(value),
109
+ }))
110
+ )
72
111
  }
@@ -11,14 +11,24 @@ page.
11
11
  Studio with a slug, not a directory. `app/(content)/[[...slug]]/page.tsx`
12
12
  resolves every one of them; a hand-written route shadows that resolver and
13
13
  becomes a URL no editor can see, move or delete. The only things that belong in
14
- `app/` are non-page endpoints — the Studio, API handlers, sitemap, feed.
14
+ `app/` are non-page endpoints — the Studio, API handlers, sitemap, feed, and
15
+ `app/components`, the Studio's live block preview: it renders fixtures rather
16
+ than content, is gated to Studio embeds by `isStudioEmbed()` and is `noindex`.
15
17
 
16
18
  ## Page-builder blocks
17
19
 
20
+ **Blocks are inline objects, never documents.** A placement belongs to exactly
21
+ one page, so the page's `pageBuilder` array stores the block itself and nothing
22
+ is dereferenced at read time. Don't reintroduce a block-as-document with a
23
+ reference member — that model was removed deliberately: no block was ever
24
+ reused, and it cost a hop on every read plus a second id to address.
25
+
18
26
  **A block is `defineBlock` + a registry entry.** Define the schema with
19
27
  `defineBlock` (`lib/integrations/sanity/schemas/shared/block.ts`), never
20
- `defineType` — it implies `type: "document"` and appends **Anchor ID** and
21
- **Presentation Mode** for you. Register the component in
28
+ `defineType` — it implies `type: "object"` and appends **Anchor ID** and
29
+ **Presentation Mode** for you. The array member's type name *is* the block's
30
+ `_type`, so the registry, the insert menu and the fixture library all key off
31
+ the same string. Register the component in
22
32
  `components/page-builder/registry.tsx`; the renderer is driven by that map, so
23
33
  there is no switch to extend.
24
34
 
@@ -33,10 +43,12 @@ that was never registered — is a typecheck error.
33
43
  drives the insert menu; `misc` only when nothing else fits). Never collapse them
34
44
  into one field: that is what made the menu a single flat list.
35
45
 
36
- **And a preview image.** The insert menu renders one tile per block from a
37
- `studioPreview` document (Components → Component Previews) keyed
38
- `block.<memberName>`. A block without one falls back to its schema icon, which
39
- is what made the old menu unreadable.
46
+ **And a fixture.** A block isn't done until `components/page-builder/library.ts`
47
+ has an entry for it: a stand-in value typed as the block's query result, plus a
48
+ one-line note. That entry is the block's preview everywhere — the live pane in
49
+ the insert menu, its row in Studio → Components, and the `/components` route,
50
+ all rendered by the production renderer. Without one the picker just says the
51
+ component has no preview.
40
52
 
41
53
  **Never hardcode vertical padding in a block component.** The renderer wraps
42
54
  each block in `<Section>` (`components/page-builder/section.tsx`), which owns
@@ -18,14 +18,18 @@ bun dev
18
18
 
19
19
  ## Page-builder blocks
20
20
 
21
- A block is two halves — a schema document and a React component — and each half
22
- has one place that makes it a block rather than an ordinary document or a loose
21
+ A block is two halves — a schema type and a React component — and each half has
22
+ one place that makes it a block rather than an ordinary object type or a loose
23
23
  component. Use them and Anchor ID, Presentation Mode and the spacing scale come
24
24
  for free.
25
25
 
26
+ Blocks are **inline objects** in the page's `pageBuilder` array, never documents
27
+ and never referenced: a placement belongs to exactly one page, so the block
28
+ lives on that page and nothing is dereferenced at read time.
29
+
26
30
  **Define the schema with `defineBlock`, never `defineType`**
27
31
  (`lib/integrations/sanity/schemas/shared/block.ts`). It implies
28
- `type: "document"` and appends two fields to every block:
32
+ `type: "object"` and appends two fields to every block:
29
33
 
30
34
  - **Anchor ID** — optional. Left empty, the anchor is generated from the block's
31
35
  own title at render time (`headline` / `heading` / `teaserHeading` / `title` /
@@ -54,6 +58,14 @@ block in `<Section>` (`components/page-builder/section.tsx`), which owns the
54
58
  component returns its content only — no outer `<section>`, no `py-*` / `pt-*` /
55
59
  `pb-*` on its root. Internal gaps between a block's own parts are fine.
56
60
 
61
+ **Every block ships a fixture in `components/page-builder/library.ts`**: a
62
+ stand-in value typed as the block's query result, plus a one-line note. It is
63
+ the block's only preview, and it is rendered by the production renderer at
64
+ `/components` — a route the Studio embeds in an iframe, so the insert menu and
65
+ **Components** in the Studio show the real block rather than a screenshot. The
66
+ route is gated to Studio embeds and `noindex`; a block with no entry simply has
67
+ no preview.
68
+
57
69
  ### The spacing scale is content, not code
58
70
 
59
71
  Editors own it at **Layout → Spacing Scale**: a list of steps, each with a name
@@ -10,13 +10,14 @@ export type UnlockResult =
10
10
  | { ok: true; payload: GatedContentPayload }
11
11
  | { ok: false; error: string }
12
12
 
13
- type UnlockInput = { documentId: string; email: string }
13
+ type UnlockInput = { pageId: string; blockKey: string; email: string }
14
14
 
15
15
  export async function unlockGatedContent({
16
- documentId,
16
+ pageId,
17
+ blockKey,
17
18
  email,
18
19
  }: UnlockInput): Promise<UnlockResult> {
19
- if (!documentId) return { ok: false, error: "Missing content id." }
20
+ if (!(pageId && blockKey)) return { ok: false, error: "Missing content id." }
20
21
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
21
22
  return { ok: false, error: "Enter a valid email address." }
22
23
  }
@@ -27,7 +28,7 @@ export async function unlockGatedContent({
27
28
 
28
29
  const { data } = await fetchSanity<GATED_CONTENT_QUERY_RESULT>({
29
30
  query: GATED_CONTENT_QUERY,
30
- params: { id: documentId },
31
+ params: { pageId, blockKey },
31
32
  // stega must stay off: it injects invisible characters into strings, which
32
33
  // would corrupt the asset URLs returned for download/playback.
33
34
  stega: false,
@@ -0,0 +1,81 @@
1
+ import type { Metadata } from "next"
2
+ import { notFound } from "next/navigation"
3
+ import { SpacingScale } from "@/components/layout/spacing-scale"
4
+ import {
5
+ COMPONENT_LIBRARY,
6
+ COMPONENT_PREVIEW_PARAM,
7
+ getLibraryEntry,
8
+ type LibraryEntry,
9
+ } from "@/components/page-builder/library"
10
+ import { Renderer } from "@/components/page-builder/renderer"
11
+ import { Stage } from "@/components/page-builder/stage"
12
+ import { isStudioEmbed } from "@/lib/utils/studio-embed"
13
+
14
+ export const metadata: Metadata = {
15
+ title: "Components",
16
+ robots: { index: false, follow: false, nocache: true },
17
+ }
18
+
19
+ type Props = {
20
+ searchParams: Promise<Record<string, string | string[] | undefined>>
21
+ }
22
+
23
+ const firstValue = (value: string | string[] | undefined) =>
24
+ Array.isArray(value) ? value[0] : value
25
+
26
+ const PREVIEW_PAGE_ID = "component-preview"
27
+
28
+ const EntryHeader = ({ entry }: { entry: LibraryEntry }) => (
29
+ <header className="sticky top-0 z-10 flex items-baseline gap-3 border-neutral-200 border-b bg-white/85 px-6 py-3 backdrop-blur">
30
+ <h2 className="font-mono text-neutral-900 text-xs uppercase">
31
+ {entry.title}
32
+ </h2>
33
+ <p className="text-neutral-500 text-xs">{entry.notes}</p>
34
+ </header>
35
+ )
36
+
37
+ const PreviewStage = ({ entry }: { entry: LibraryEntry }) => (
38
+ <Stage background={entry.background}>
39
+ <Renderer
40
+ blocks={[entry.block]}
41
+ pageId={PREVIEW_PAGE_ID}
42
+ pinnedCategory={null}
43
+ searchParams={undefined}
44
+ />
45
+ </Stage>
46
+ )
47
+
48
+ const Page = async ({ searchParams }: Props) => {
49
+ const [params, embedded] = await Promise.all([searchParams, isStudioEmbed()])
50
+
51
+ if (!embedded) notFound()
52
+
53
+ const requested = firstValue(params[COMPONENT_PREVIEW_PARAM])
54
+ const entry = getLibraryEntry(requested)
55
+
56
+ if (requested && !entry) notFound()
57
+
58
+ // Outside the `(content)` group, so `<SpacingScale />` is on this route.
59
+ if (entry) {
60
+ return (
61
+ <main className="flex flex-col">
62
+ <SpacingScale />
63
+ <PreviewStage entry={entry} />
64
+ </main>
65
+ )
66
+ }
67
+
68
+ return (
69
+ <main className="flex flex-col">
70
+ <SpacingScale />
71
+ {COMPONENT_LIBRARY.map((item) => (
72
+ <section key={item.documentType}>
73
+ <EntryHeader entry={item} />
74
+ <PreviewStage entry={item} />
75
+ </section>
76
+ ))}
77
+ </main>
78
+ )
79
+ }
80
+
81
+ export default Page
@@ -13,14 +13,15 @@ type GatedContentBlock = Extract<PageBuilderBlock, { _type: "gatedContent" }>
13
13
 
14
14
  type GatedContentProps = {
15
15
  block: GatedContentBlock
16
+ pageId: string
16
17
  }
17
18
 
18
19
  type GateState = "locked" | "loading" | "unlocked"
19
20
 
20
21
  const STORAGE_PREFIX = "gated-content:"
21
22
 
22
- export function GatedContent({ block }: GatedContentProps) {
23
- const storageKey = `${STORAGE_PREFIX}${block._id}`
23
+ export function GatedContent({ block, pageId }: GatedContentProps) {
24
+ const storageKey = `${STORAGE_PREFIX}${pageId}:${block._key}`
24
25
  const [state, setState] = useState<GateState>("locked")
25
26
  const [payload, setPayload] = useState<GatedContentPayload | null>(null)
26
27
  const [error, setError] = useState<string | null>(null)
@@ -33,7 +34,11 @@ export function GatedContent({ block }: GatedContentProps) {
33
34
  setState("loading")
34
35
  setError(null)
35
36
 
36
- const result = await unlockGatedContent({ documentId: block._id, email })
37
+ const result = await unlockGatedContent({
38
+ pageId,
39
+ blockKey: block._key,
40
+ email,
41
+ })
37
42
 
38
43
  if (!result.ok) {
39
44
  setError(result.error)