@notionhq/custom-blocks 0.1.1 → 0.1.3

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 (80) hide show
  1. package/HOST.md +29 -18
  2. package/README.md +56 -15
  3. package/dist/bridge/SandboxBridge.d.ts.map +1 -1
  4. package/dist/bridge/SandboxBridge.js +11 -0
  5. package/dist/bridge/contrast.d.ts +5 -0
  6. package/dist/bridge/contrast.d.ts.map +1 -0
  7. package/dist/bridge/contrast.js +3 -0
  8. package/dist/bridge/hostState.d.ts +3 -0
  9. package/dist/bridge/hostState.d.ts.map +1 -1
  10. package/dist/bridge/loadManifest.d.ts +9 -8
  11. package/dist/bridge/loadManifest.d.ts.map +1 -1
  12. package/dist/bridge/loadManifest.js +6 -14
  13. package/dist/bridge/manifest.d.ts +3 -8
  14. package/dist/bridge/manifest.d.ts.map +1 -1
  15. package/dist/bridge/manifest.js +0 -8
  16. package/dist/bridge/messages/contrastModeChanged.d.ts +8 -0
  17. package/dist/bridge/messages/contrastModeChanged.d.ts.map +1 -0
  18. package/dist/bridge/messages/contrastModeChanged.js +7 -0
  19. package/dist/bridge/messages/hostToSandbox.d.ts +4 -0
  20. package/dist/bridge/messages/hostToSandbox.d.ts.map +1 -1
  21. package/dist/bridge/messages/hostToSandbox.js +2 -0
  22. package/dist/bridge/messages/init.d.ts +4 -1
  23. package/dist/bridge/messages/init.d.ts.map +1 -1
  24. package/dist/bridge/messages/init.js +6 -1
  25. package/dist/customBlock.d.ts +4 -0
  26. package/dist/customBlock.d.ts.map +1 -1
  27. package/dist/customBlock.js +5 -0
  28. package/dist/host/createCustomBlockHost.d.ts +2 -0
  29. package/dist/host/createCustomBlockHost.d.ts.map +1 -1
  30. package/dist/host/createCustomBlockHost.js +22 -0
  31. package/dist/host/lifecycle/types.d.ts +2 -0
  32. package/dist/host/lifecycle/types.d.ts.map +1 -1
  33. package/dist/host.d.ts +4 -0
  34. package/dist/host.d.ts.map +1 -1
  35. package/dist/host.js +2 -0
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/init.d.ts +7 -6
  39. package/dist/init.d.ts.map +1 -1
  40. package/dist/init.js +20 -10
  41. package/dist/react/NotionTokenScope.d.ts +10 -0
  42. package/dist/react/NotionTokenScope.d.ts.map +1 -0
  43. package/dist/react/NotionTokenScope.js +11 -0
  44. package/dist/react/index.d.ts +2 -1
  45. package/dist/react/index.d.ts.map +1 -1
  46. package/dist/react/index.js +2 -1
  47. package/dist/react/standalonePreview.d.ts.map +1 -1
  48. package/dist/react/standalonePreview.js +2 -0
  49. package/dist/react/useRuntimeState.d.ts +11 -0
  50. package/dist/react/useRuntimeState.d.ts.map +1 -1
  51. package/dist/react/useRuntimeState.js +12 -0
  52. package/dist/styles/nds.css +1 -0
  53. package/dist/version.js +1 -1
  54. package/docs/block-location.md +17 -3
  55. package/docs/deployment.md +41 -0
  56. package/docs/lifecycle.md +3 -2
  57. package/docs/vite-plugin.md +5 -29
  58. package/package.json +2 -1
  59. package/src/bridge/SandboxBridge.ts +12 -0
  60. package/src/bridge/contrast.ts +6 -0
  61. package/src/bridge/hostState.ts +3 -0
  62. package/src/bridge/loadManifest.ts +8 -17
  63. package/src/bridge/manifest.ts +3 -9
  64. package/src/bridge/messages/contrastModeChanged.ts +12 -0
  65. package/src/bridge/messages/hostToSandbox.ts +2 -0
  66. package/src/bridge/messages/init.ts +6 -1
  67. package/src/customBlock.ts +13 -1
  68. package/src/host/createCustomBlockHost.ts +27 -0
  69. package/src/host/lifecycle/types.ts +3 -0
  70. package/src/host.ts +4 -0
  71. package/src/index.ts +1 -0
  72. package/src/init.ts +22 -10
  73. package/src/react/NotionTokenScope.tsx +25 -0
  74. package/src/react/index.ts +5 -0
  75. package/src/react/standalonePreview.ts +2 -0
  76. package/src/react/useRuntimeState.ts +17 -0
  77. package/src/styles/nds.css +1 -0
  78. package/src/styles/nds.css.metadata.json +6 -0
  79. package/vite-plugin/index.d.ts +2 -2
  80. package/vite-plugin/index.js +8 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notionhq/custom-blocks",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -18,6 +18,7 @@
18
18
  "import": "./dist/index.js",
19
19
  "types": "./dist/index.d.ts"
20
20
  },
21
+ "./nds.css": "./dist/styles/nds.css",
21
22
  "./react": {
22
23
  "import": "./dist/react/index.js",
23
24
  "types": "./dist/react/index.d.ts"
@@ -14,6 +14,7 @@ import type {
14
14
  } from "../types.js"
15
15
  import { unreachable } from "../utils.js"
16
16
  import { CUSTOM_BLOCKS_SDK_VERSION } from "../version.js"
17
+ import { DEFAULT_CONTRAST_MODE } from "./contrast.js"
17
18
  import type {
18
19
  NotionDataSource,
19
20
  NotionDataSourceBindings,
@@ -79,6 +80,7 @@ export class SandboxBridge {
79
80
  private hostState: CustomBlockHostState = {
80
81
  status: "uninitialized",
81
82
  theme: "light",
83
+ contrastMode: DEFAULT_CONTRAST_MODE,
82
84
  }
83
85
  private listeners = new Set<() => void>()
84
86
  private messageLog: MessageLogEntry[] = []
@@ -301,6 +303,15 @@ export class SandboxBridge {
301
303
  return
302
304
  }
303
305
 
306
+ case "contrastModeChanged": {
307
+ this.hostState = {
308
+ ...hostState,
309
+ contrastMode: message.contrastMode,
310
+ }
311
+ this.notify()
312
+ return
313
+ }
314
+
304
315
  case "parentChanged": {
305
316
  this.hostState = {
306
317
  ...hostState,
@@ -538,6 +549,7 @@ export class SandboxBridge {
538
549
  this.hostState = {
539
550
  status: "initialized",
540
551
  theme: message.theme,
552
+ contrastMode: message.contrastMode,
541
553
  blockId,
542
554
  parent,
543
555
  page,
@@ -0,0 +1,6 @@
1
+ import * as v from "valibot"
2
+
3
+ export const notionContrastModeSchema = v.picklist(["standard", "high"])
4
+ export type NotionContrastMode = v.InferOutput<typeof notionContrastModeSchema>
5
+
6
+ export const DEFAULT_CONTRAST_MODE: NotionContrastMode = "standard"
@@ -1,3 +1,4 @@
1
+ import type { NotionContrastMode } from "./contrast.js"
1
2
  import type { NotionDataSource } from "./dataSources/dataSource.js"
2
3
  import type {
3
4
  NotionDataSourcePage,
@@ -20,11 +21,13 @@ export type CustomBlockHostState = UninitializedHostState | InitializedHostState
20
21
  export type UninitializedHostState = {
21
22
  status: "uninitialized"
22
23
  theme: NotionTheme
24
+ contrastMode: NotionContrastMode
23
25
  }
24
26
 
25
27
  export type InitializedHostState = {
26
28
  status: "initialized"
27
29
  theme: NotionTheme
30
+ contrastMode: NotionContrastMode
28
31
  blockId: NotionBlockId
29
32
  parent: NotionParent
30
33
  page: CustomBlockPage
@@ -4,13 +4,12 @@ import { type CustomBlockManifest, manifestSchema } from "./manifest.js"
4
4
 
5
5
  const MANIFEST_URL = "custom_blocks.json"
6
6
 
7
- export type CustomBlockConnectErrorCode =
7
+ type CustomBlockConnectErrorCode =
8
8
  | "manifest_unavailable"
9
9
  | "manifest_invalid"
10
10
  | (string & {})
11
11
 
12
- export type CustomBlockConnectError =
13
- CustomBlockErrorInfo<CustomBlockConnectErrorCode>
12
+ type CustomBlockConnectError = CustomBlockErrorInfo<CustomBlockConnectErrorCode>
14
13
 
15
14
  export type ManifestLoadResult =
16
15
  | {
@@ -23,21 +22,13 @@ export type ManifestLoadResult =
23
22
  }
24
23
 
25
24
  /**
26
- * Attempts to load a `custom_blocks.json` manifest co-located with the bundle.
27
- * A missing manifest means the block has no declared data requirements.
28
- * Other failures return a structured error so the host can reject the manifest.
29
- * The SDK validates the manifest locally for author feedback; the host still
30
- * validates the connect message as the iframe trust boundary.
25
+ * Attempts to load the custom block's optional self-hosted manifest, co-located with the bundle at
26
+ * `/custom_blocks.json`. This is to support local development where the block is served from a
27
+ * local dev server.
28
+ *
29
+ * It is up to the host to decide whether to use this manifest or provide its own persisted manifest.
31
30
  */
32
- export async function loadManifest(): Promise<ManifestLoadResult> {
33
- if (typeof fetch !== "function") {
34
- const message = `No fetch API available; cannot load ${MANIFEST_URL}.`
35
- console.warn(`[custom-blocks-sdk] ${message}`)
36
- return {
37
- manifest: null,
38
- error: { code: "manifest_unavailable", message, isRetryable: true },
39
- }
40
- }
31
+ export async function attemptToLoadSelfHostedManifest(): Promise<ManifestLoadResult> {
41
32
  let response: Response
42
33
  try {
43
34
  response = await fetch(MANIFEST_URL, { credentials: "omit" })
@@ -1,15 +1,6 @@
1
1
  import * as v from "valibot"
2
2
  import { notionPropertyTypeSchema } from "./dataSources/propertySchema.js"
3
3
 
4
- /**
5
- * User-authored manifest declaring the data sources the custom block expects.
6
- * Lives at `custom_blocks.json` in the project root. The sandbox may send it in
7
- * `connect`, and the host returns the authoritative manifest in `init`. The
8
- * `notionCustomBlock` Vite plugin from
9
- * `@notionhq/custom-blocks/vite` wires the JSON file into the dev server and
10
- * the build output.
11
- */
12
-
13
4
  /**
14
5
  * Decorative icon attached to a manifest data source. Mirrors the
15
6
  * `emoji` / `external` icon variants the public Notion API uses, so the host
@@ -50,4 +41,7 @@ export const manifestSchema = v.object({
50
41
  dataSources: v.record(v.string(), manifestDataSourceSchema),
51
42
  })
52
43
 
44
+ /**
45
+ * Manifest declaring the data sources required by a custom block.
46
+ */
53
47
  export type CustomBlockManifest = v.InferOutput<typeof manifestSchema>
@@ -0,0 +1,12 @@
1
+ import * as v from "valibot"
2
+ import { notionContrastModeSchema } from "../contrast.js"
3
+
4
+ /** Message sent by the host when contrast changes after initialization. */
5
+ export const contrastModeChangedMessageSchema = v.object({
6
+ type: v.literal("contrastModeChanged"),
7
+ contrastMode: notionContrastModeSchema,
8
+ })
9
+
10
+ export type ContrastModeChangedMessage = v.InferOutput<
11
+ typeof contrastModeChangedMessageSchema
12
+ >
@@ -1,4 +1,5 @@
1
1
  import * as v from "valibot"
2
+ import { contrastModeChangedMessageSchema } from "./contrastModeChanged.js"
2
3
  import { createPageResultMessageSchema } from "./createPageResult.js"
3
4
  import { currentUserChangedMessageSchema } from "./currentUserChanged.js"
4
5
  import { dataSourcesChangedMessageSchema } from "./dataSourcesChanged.js"
@@ -19,6 +20,7 @@ import { updatePageResultMessageSchema } from "./updatePageResult.js"
19
20
  export const hostToSandboxMessageSchema = v.variant("type", [
20
21
  initMessageSchema,
21
22
  themeChangedMessageSchema,
23
+ contrastModeChangedMessageSchema,
22
24
  parentChangedMessageSchema,
23
25
  pageChangedMessageSchema,
24
26
  currentUserChangedMessageSchema,
@@ -1,4 +1,5 @@
1
1
  import * as v from "valibot"
2
+ import { DEFAULT_CONTRAST_MODE, notionContrastModeSchema } from "../contrast.js"
2
3
  import { notionDataSourceBindingsSchema } from "../dataSources/dataSource.js"
3
4
  import type { CustomBlockErrorCode, CustomBlockErrorInfo } from "../errors.js"
4
5
  import { notionBlockIdSchema } from "../ids.js"
@@ -66,7 +67,9 @@ export class CustomBlockInitializationError
66
67
  /**
67
68
  * Initialization message sent by the host to the sandbox exactly once, in response to the
68
69
  * sandbox's `connect` message. The sandbox echoes `initializationId` in `initResult`. After
69
- * successful initialization, live updates flow through narrower messages.
70
+ * successful initialization, live updates flow through narrower messages
71
+ * (`themeChanged`, `contrastModeChanged`, `parentChanged`, `pageChanged`,
72
+ * `dataSourcesChanged`).
70
73
  */
71
74
  export const initMessageSchema = v.variant("status", [
72
75
  v.object({
@@ -74,6 +77,8 @@ export const initMessageSchema = v.variant("status", [
74
77
  initializationId: v.string(),
75
78
  status: v.literal("success"),
76
79
  theme: notionThemeSchema,
80
+ // TODO(custom-blocks): Make this required when next bumping the protocol version.
81
+ contrastMode: v.optional(notionContrastModeSchema, DEFAULT_CONTRAST_MODE),
77
82
  blockId: notionBlockIdSchema,
78
83
  parent: notionParentSchema,
79
84
  page: customBlockPageSchema,
@@ -1,4 +1,5 @@
1
1
  import { autoResize } from "./autoResize.js"
2
+ import type { NotionContrastMode } from "./bridge/contrast.js"
2
3
  import type { NotionDataSource } from "./bridge/dataSources/dataSource.js"
3
4
  import type {
4
5
  CustomBlockHostState,
@@ -13,10 +14,15 @@ import type { NotionTheme } from "./bridge/theme.js"
13
14
  import type { NotionUser } from "./bridge/users/user.js"
14
15
 
15
16
  export type CustomBlockState =
16
- | { status: "uninitialized"; theme: NotionTheme }
17
+ | {
18
+ status: "uninitialized"
19
+ theme: NotionTheme
20
+ contrastMode: NotionContrastMode
21
+ }
17
22
  | {
18
23
  status: "initialized"
19
24
  theme: NotionTheme
25
+ contrastMode: NotionContrastMode
20
26
  blockId: NotionBlockId
21
27
  parent: NotionParent
22
28
  page: CustomBlockPage
@@ -45,6 +51,10 @@ export const customBlock = {
45
51
  return getInitializedHostState("getTheme").theme
46
52
  },
47
53
 
54
+ getContrastMode(): NotionContrastMode {
55
+ return getInitializedHostState("getContrastMode").contrastMode
56
+ },
57
+
48
58
  getBlockId(): NotionBlockId {
49
59
  return getInitializedHostState("getBlockId").blockId
50
60
  },
@@ -76,12 +86,14 @@ function toPublicState(hostState: CustomBlockHostState): CustomBlockState {
76
86
  lastPublicState = {
77
87
  status: "uninitialized",
78
88
  theme: hostState.theme,
89
+ contrastMode: hostState.contrastMode,
79
90
  }
80
91
  return lastPublicState
81
92
  }
82
93
  lastPublicState = {
83
94
  status: "initialized",
84
95
  theme: hostState.theme,
96
+ contrastMode: hostState.contrastMode,
85
97
  blockId: hostState.blockId,
86
98
  parent: hostState.parent,
87
99
  page: hostState.page,
@@ -1,7 +1,12 @@
1
1
  import * as v from "valibot"
2
+ import {
3
+ DEFAULT_CONTRAST_MODE,
4
+ type NotionContrastMode,
5
+ } from "../bridge/contrast.js"
2
6
  import type { NotionDataSourceId } from "../bridge/ids.js"
3
7
  import type { CustomBlockManifest } from "../bridge/manifest.js"
4
8
  import type { ConnectMessage } from "../bridge/messages/connect.js"
9
+ import type { ContrastModeChangedMessage } from "../bridge/messages/contrastModeChanged.js"
5
10
  import type { CreatePageMessage } from "../bridge/messages/createPage.js"
6
11
  import type { CreatePageResultMessage } from "../bridge/messages/createPageResult.js"
7
12
  import type { DataSourcesChangedMessage } from "../bridge/messages/dataSourcesChanged.js"
@@ -71,6 +76,7 @@ export type CustomBlockHostHandle = {
71
76
  stop: () => void
72
77
  post: (message: HostToSandboxMessage) => void
73
78
  setTheme: (theme: NotionTheme) => void
79
+ setContrastMode: (contrastMode: NotionContrastMode) => void
74
80
  setParent: (parent: NotionParent) => void
75
81
  setPage: (page: CustomBlockPage) => void
76
82
  setDataSources: (dataSources: CustomBlockHostDataSourcesPayload) => void
@@ -108,6 +114,7 @@ export function createCustomBlockHost(
108
114
  )
109
115
 
110
116
  let theme = initialState.theme
117
+ let contrastMode = initialState.contrastMode ?? DEFAULT_CONTRAST_MODE
111
118
  let parent = initialState.parent
112
119
  let page = initialState.page
113
120
  let dataSources = initialState.dataSources
@@ -119,6 +126,7 @@ export function createCustomBlockHost(
119
126
  let activeInitializationId: string | undefined
120
127
  let connectedSandbox: ConnectSuccessMessage | undefined
121
128
  let pendingThemeChange = false
129
+ let pendingContrastModeChange = false
122
130
  let pendingParentChange = false
123
131
  let pendingPageChange = false
124
132
  let pendingDataSourcesChange: CustomBlockHostDataSourcesPayload | undefined
@@ -201,6 +209,7 @@ export function createCustomBlockHost(
201
209
  initializationId: connect.initializationId,
202
210
  status: "success",
203
211
  theme,
212
+ contrastMode,
204
213
  blockId: initialState.blockId,
205
214
  parent,
206
215
  page,
@@ -376,6 +385,10 @@ export function createCustomBlockHost(
376
385
  pendingThemeChange = false
377
386
  post({ type: "themeChanged", theme })
378
387
  }
388
+ if (pendingContrastModeChange) {
389
+ pendingContrastModeChange = false
390
+ post({ type: "contrastModeChanged", contrastMode })
391
+ }
379
392
  if (pendingParentChange) {
380
393
  pendingParentChange = false
381
394
  post({ type: "parentChanged", parent })
@@ -527,6 +540,20 @@ export function createCustomBlockHost(
527
540
  const message: ThemeChangedMessage = { type: "themeChanged", theme }
528
541
  post(message)
529
542
  },
543
+ setContrastMode(nextContrastMode) {
544
+ contrastMode = nextContrastMode
545
+ if (initStatus !== "success") {
546
+ if (initStatus === "waitingForInitResult") {
547
+ pendingContrastModeChange = true
548
+ }
549
+ return
550
+ }
551
+ const message: ContrastModeChangedMessage = {
552
+ type: "contrastModeChanged",
553
+ contrastMode,
554
+ }
555
+ post(message)
556
+ },
530
557
  setParent(nextParent) {
531
558
  parent = nextParent
532
559
  if (initStatus !== "success") {
@@ -1,3 +1,4 @@
1
+ import type { NotionContrastMode } from "../../bridge/contrast.js"
1
2
  import type { NotionDataSourceBindings } from "../../bridge/dataSources/dataSource.js"
2
3
  import type { NotionBlockId } from "../../bridge/ids.js"
3
4
  import type { CustomBlockManifest } from "../../bridge/manifest.js"
@@ -28,6 +29,8 @@ export type CustomBlockHostInitialDataSources =
28
29
 
29
30
  export type CustomBlockHostInitialState = {
30
31
  theme: NotionTheme
32
+ // TODO(custom-blocks): Make this required when next bumping the protocol version.
33
+ contrastMode?: NotionContrastMode
31
34
  blockId: NotionBlockId
32
35
  parent: NotionParent
33
36
  page: CustomBlockPage
package/src/host.ts CHANGED
@@ -10,6 +10,8 @@
10
10
  * surface lives in the default `@notionhq/custom-blocks` entrypoint.
11
11
  */
12
12
 
13
+ export type { NotionContrastMode } from "./bridge/contrast.js"
14
+ export { notionContrastModeSchema } from "./bridge/contrast.js"
13
15
  export type {
14
16
  NotionDataSourceBinding,
15
17
  NotionDataSourceBindings,
@@ -40,6 +42,8 @@ export {
40
42
  export { readIncomingType } from "./bridge/incomingType.js"
41
43
  export type { ConnectMessage } from "./bridge/messages/connect.js"
42
44
  export { connectMessageSchema } from "./bridge/messages/connect.js"
45
+ export type { ContrastModeChangedMessage } from "./bridge/messages/contrastModeChanged.js"
46
+ export { contrastModeChangedMessageSchema } from "./bridge/messages/contrastModeChanged.js"
43
47
  export type { CreatePageMessage } from "./bridge/messages/createPage.js"
44
48
  export { createPageMessageSchema } from "./bridge/messages/createPage.js"
45
49
  export type {
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * should import hooks and components from `@notionhq/custom-blocks/react`.
8
8
  */
9
9
 
10
+ export type { NotionContrastMode } from "./bridge/contrast.js"
10
11
  export type {
11
12
  NotionCollectionSchema,
12
13
  NotionDataSource,
package/src/init.ts CHANGED
@@ -1,6 +1,7 @@
1
+ import type { NotionContrastMode } from "./bridge/contrast.js"
1
2
  import type { NotionDataSource } from "./bridge/dataSources/dataSource.js"
2
3
  import type { NotionBlockId } from "./bridge/ids.js"
3
- import { loadManifest } from "./bridge/loadManifest.js"
4
+ import { attemptToLoadSelfHostedManifest } from "./bridge/loadManifest.js"
4
5
  import { CustomBlockInitializationError } from "./bridge/messages/init.js"
5
6
  import type { CustomBlockPage } from "./bridge/pages/page.js"
6
7
  import type { NotionParent } from "./bridge/parent.js"
@@ -14,6 +15,7 @@ import { unreachable } from "./utils.js"
14
15
  */
15
16
  export type CustomBlockInitPayload = {
16
17
  theme: NotionTheme
18
+ contrastMode: NotionContrastMode
17
19
  blockId: NotionBlockId
18
20
  parent: NotionParent
19
21
  page: CustomBlockPage
@@ -61,26 +63,35 @@ const NOT_IN_IFRAME_MESSAGE =
61
63
  let initPromise: Promise<CustomBlockInitPayload> | undefined
62
64
 
63
65
  /**
64
- * Performs the SDK <-> host handshake: loads `custom_blocks.json`, posts
65
- * `connect`, then awaits the host's `init` message. Resolves after the sandbox
66
- * applies the payload and returns `initResult.success`.
66
+ * Initializes the custom block by running the SDK <-> host handshake.
67
67
  *
68
- * Rejects with a `CustomBlockInitializationError` if initialization fails.
68
+ * Resolves with the block's initial context (theme, block location, current
69
+ * user, data sources) once the host has initialized the block. Rejects with a
70
+ * `CustomBlockInitializationError` if initialization fails.
69
71
  *
70
72
  * Idempotent: subsequent calls return the same promise as the first and ignore any new options.
71
- * Mount your React tree (or call any SDK hook / `customBlock.subscribe`) only after the
72
- * returned promise resolves.
73
+ * Mount your React tree (or call any SDK hook) only after the returned promise resolves.
73
74
  */
74
75
  export function initCustomBlock(
75
76
  opts: InitCustomBlockOptions = {},
76
77
  ): Promise<CustomBlockInitPayload> {
77
78
  if (initPromise === undefined) {
78
- initPromise = initializeCustomBlock(opts)
79
+ initPromise = performHandshake(opts)
79
80
  }
80
81
  return initPromise
81
82
  }
82
83
 
83
- async function initializeCustomBlock(
84
+ /**
85
+ * Performs the host <-> sandbox SDK handshake:
86
+ * 1. Attempts to load an optional self-hosted `custom_blocks.json`
87
+ * 2. Sends `connect` with the manifest
88
+ * 3. Awaits the host's `init` message
89
+ * 4. Applies the `init` payload and acknowledges with `initResult` (fire-and-forget)
90
+ *
91
+ * Resolves with the `init` payload after the process above. Rejects with a
92
+ * `CustomBlockInitializationError` if any step fails.
93
+ */
94
+ async function performHandshake(
84
95
  opts: InitCustomBlockOptions,
85
96
  ): Promise<CustomBlockInitPayload> {
86
97
  try {
@@ -92,7 +103,7 @@ async function initializeCustomBlock(
92
103
  }
93
104
 
94
105
  // Load the manifest and send it to the host.
95
- const manifestResult = await loadManifest()
106
+ const manifestResult = await attemptToLoadSelfHostedManifest()
96
107
  customBlockHost.sendConnect(manifestResult)
97
108
 
98
109
  const timeoutMs = opts.timeoutMs ?? DEFAULT_INIT_TIMEOUT_MS
@@ -103,6 +114,7 @@ async function initializeCustomBlock(
103
114
  case "initialized":
104
115
  return {
105
116
  theme: hostState.theme,
117
+ contrastMode: hostState.contrastMode,
106
118
  blockId: hostState.blockId,
107
119
  parent: hostState.parent,
108
120
  page: hostState.page,
@@ -0,0 +1,25 @@
1
+ import type { ReactNode } from "react"
2
+ import { useContrastMode, useTheme } from "./useRuntimeState.js"
3
+
4
+ export type NotionTokenScopeProps = {
5
+ children: ReactNode
6
+ }
7
+
8
+ /**
9
+ * Makes Notion design tokens available to descendants and keeps their
10
+ * host-controlled appearance in sync.
11
+ */
12
+ export function NotionTokenScope({ children }: NotionTokenScopeProps) {
13
+ const theme = useTheme()
14
+ const contrastMode = useContrastMode()
15
+
16
+ return (
17
+ <div
18
+ className="nds"
19
+ data-display-mode={theme}
20
+ data-contrast-mode={contrastMode}
21
+ >
22
+ {children}
23
+ </div>
24
+ )
25
+ }
@@ -11,6 +11,10 @@ export {
11
11
  NotionCustomBlock,
12
12
  type NotionCustomBlockProps,
13
13
  } from "./NotionCustomBlock.js"
14
+ export {
15
+ NotionTokenScope,
16
+ type NotionTokenScopeProps,
17
+ } from "./NotionTokenScope.js"
14
18
  export { useCustomBlockAutoResize } from "./useCustomBlockAutoResize.js"
15
19
  export {
16
20
  type UseCustomBlockInitResult,
@@ -19,6 +23,7 @@ export {
19
23
  export { useDataSource } from "./useDataSource.js"
20
24
  export {
21
25
  useBlockId,
26
+ useContrastMode,
22
27
  useCurrentUser,
23
28
  useManifest,
24
29
  usePage,
@@ -1,4 +1,5 @@
1
1
  import * as v from "valibot"
2
+ import { DEFAULT_CONTRAST_MODE } from "../bridge/contrast.js"
2
3
  import { notionBlockIdSchema, notionPageIdSchema } from "../bridge/ids.js"
3
4
  import { customBlockHost } from "../bridge/sandboxClient.js"
4
5
 
@@ -11,6 +12,7 @@ export function seedStandalonePreviewState() {
11
12
  initializationId: "standalone-preview",
12
13
  status: "success",
13
14
  theme: "light",
15
+ contrastMode: DEFAULT_CONTRAST_MODE,
14
16
  blockId: previewBlockId,
15
17
  parent: { type: "page_id", page_id: previewPageId },
16
18
  page: { id: previewPageId, parent: { type: "workspace", workspace: true } },
@@ -1,4 +1,5 @@
1
1
  import { useSyncExternalStore } from "react"
2
+ import type { NotionContrastMode } from "../bridge/contrast.js"
2
3
  import type { NotionBlockId } from "../bridge/ids.js"
3
4
  import type { CustomBlockManifest } from "../bridge/manifest.js"
4
5
  import type { CustomBlockPage } from "../bridge/pages/page.js"
@@ -57,6 +58,22 @@ export function useTheme(): NotionTheme {
57
58
  return useSyncExternalStore(customBlock.subscribe, customBlock.getTheme)
58
59
  }
59
60
 
61
+ /**
62
+ * Returns the host's contrast preference. Re-renders on every protocol-v3
63
+ * `contrastModeChanged` message from the host.
64
+ *
65
+ * Throws if called before `initCustomBlock` has resolved.
66
+ *
67
+ * @example
68
+ * const contrastMode = useContrastMode()
69
+ */
70
+ export function useContrastMode(): NotionContrastMode {
71
+ return useSyncExternalStore(
72
+ customBlock.subscribe,
73
+ customBlock.getContrastMode,
74
+ )
75
+ }
76
+
60
77
  /**
61
78
  * Returns the viewing user's Notion profile. Re-renders whenever the host sends a
62
79
  * `currentUserChanged` message.