@dfosco/storyboard-react 2.8.0 → 3.1.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.
@@ -0,0 +1,242 @@
1
+ .embed {
2
+ position: relative;
3
+ overflow: hidden;
4
+ background: var(--bgColor-default, #ffffff);
5
+ }
6
+
7
+ .iframeContainer {
8
+ width: 100%;
9
+ height: 100%;
10
+ overflow: hidden;
11
+ }
12
+
13
+ .iframe {
14
+ border: none;
15
+ display: block;
16
+ }
17
+
18
+ .dragOverlay {
19
+ position: absolute;
20
+ inset: 0;
21
+ z-index: 1;
22
+ cursor: grab;
23
+ }
24
+
25
+ .empty {
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ height: 100%;
30
+ color: var(--fgColor-muted, #656d76);
31
+ font-size: 14px;
32
+ font-style: italic;
33
+ cursor: pointer;
34
+ }
35
+
36
+ .empty p {
37
+ margin: 0;
38
+ }
39
+
40
+ .editBtn {
41
+ all: unset;
42
+ cursor: pointer;
43
+ position: absolute;
44
+ top: 8px;
45
+ right: 8px;
46
+ width: 28px;
47
+ height: 28px;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ border-radius: 6px;
52
+ background: rgba(255, 255, 255, 0.92);
53
+ backdrop-filter: blur(12px);
54
+ -webkit-backdrop-filter: blur(12px);
55
+ border: 1px solid rgba(0, 0, 0, 0.12);
56
+ box-shadow: 0 1px 4px rgba(0, 0, 0, 0.15);
57
+ font-size: 14px;
58
+ opacity: 0;
59
+ transition: opacity 150ms;
60
+ z-index: 2;
61
+ }
62
+
63
+ .embed:hover .editBtn {
64
+ opacity: 1;
65
+ }
66
+
67
+ .editBtn:hover {
68
+ background: rgba(255, 255, 255, 0.98);
69
+ }
70
+
71
+ @media (prefers-color-scheme: dark) {
72
+ .editBtn {
73
+ background: rgba(22, 27, 34, 0.88);
74
+ border-color: rgba(255, 255, 255, 0.1);
75
+ }
76
+
77
+ .editBtn:hover {
78
+ background: rgba(30, 37, 46, 0.95);
79
+ }
80
+ }
81
+
82
+ .urlForm {
83
+ display: flex;
84
+ flex-direction: column;
85
+ gap: 8px;
86
+ padding: 24px;
87
+ height: 100%;
88
+ box-sizing: border-box;
89
+ justify-content: center;
90
+ }
91
+
92
+ .urlLabel {
93
+ font-size: 12px;
94
+ font-weight: 600;
95
+ color: var(--fgColor-muted, #656d76);
96
+ text-transform: uppercase;
97
+ letter-spacing: 0.5px;
98
+ }
99
+
100
+ .urlInput {
101
+ all: unset;
102
+ padding: 8px 10px;
103
+ font-size: 14px;
104
+ font-family: ui-monospace, SFMono-Regular, monospace;
105
+ border: 1px solid var(--borderColor-default, #d0d7de);
106
+ border-radius: 6px;
107
+ background: var(--bgColor-default, #ffffff);
108
+ color: var(--fgColor-default, #1f2328);
109
+ }
110
+
111
+ .urlInput:focus {
112
+ border-color: var(--bgColor-accent-emphasis, #2f81f7);
113
+ box-shadow: 0 0 0 2px rgba(47, 129, 247, 0.3);
114
+ }
115
+
116
+ .urlActions {
117
+ display: flex;
118
+ gap: 8px;
119
+ }
120
+
121
+ .urlSave,
122
+ .urlCancel {
123
+ all: unset;
124
+ cursor: pointer;
125
+ padding: 6px 14px;
126
+ font-size: 13px;
127
+ font-weight: 500;
128
+ border-radius: 6px;
129
+ text-align: center;
130
+ }
131
+
132
+ .urlSave {
133
+ background: var(--bgColor-accent-emphasis, #2f81f7);
134
+ color: var(--fgColor-onEmphasis, #ffffff);
135
+ }
136
+
137
+ .urlSave:hover {
138
+ background: var(--bgColor-accent-emphasis, #388bfd);
139
+ }
140
+
141
+ .urlCancel {
142
+ color: var(--fgColor-muted, #656d76);
143
+ }
144
+
145
+ .urlCancel:hover {
146
+ color: var(--fgColor-default, #1f2328);
147
+ background: var(--bgColor-muted, #f6f8fa);
148
+ }
149
+
150
+ .resizeHandle {
151
+ position: absolute;
152
+ bottom: 0;
153
+ right: 0;
154
+ width: 16px;
155
+ height: 16px;
156
+ cursor: nwse-resize;
157
+ background: linear-gradient(
158
+ 135deg,
159
+ transparent 40%,
160
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 40%,
161
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 50%,
162
+ transparent 50%,
163
+ transparent 65%,
164
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 65%,
165
+ var(--borderColor-muted, rgba(0, 0, 0, 0.15)) 75%,
166
+ transparent 75%
167
+ );
168
+ opacity: 0;
169
+ transition: opacity 150ms;
170
+ z-index: 2;
171
+ }
172
+
173
+ .embed:hover ~ .resizeHandle,
174
+ .resizeHandle:hover {
175
+ opacity: 1;
176
+ }
177
+
178
+ .zoomBar {
179
+ position: absolute;
180
+ bottom: 8px;
181
+ left: 50%;
182
+ transform: translateX(-50%);
183
+ display: flex;
184
+ align-items: center;
185
+ border-radius: 10px;
186
+ border: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
187
+ background: var(--trigger-bg, var(--bgColor-muted, #f6f8fa));
188
+ opacity: 0;
189
+ transition: opacity 150ms;
190
+ z-index: 3;
191
+ }
192
+
193
+ .embed:hover .zoomBar {
194
+ opacity: 1;
195
+ }
196
+
197
+ .zoomBtn {
198
+ all: unset;
199
+ cursor: pointer;
200
+ width: 36px;
201
+ height: 32px;
202
+ display: flex;
203
+ align-items: center;
204
+ justify-content: center;
205
+ font-size: 16px;
206
+ font-weight: 600;
207
+ color: var(--trigger-text, var(--fgColor-default, #1f2328));
208
+ transition: background 120ms;
209
+ }
210
+
211
+ .zoomBtn:first-child {
212
+ border-radius: 7px 0 0 7px;
213
+ }
214
+
215
+ .zoomBtn:last-child {
216
+ border-radius: 0 7px 7px 0;
217
+ }
218
+
219
+ .zoomBtn:hover:not(:disabled) {
220
+ background: var(--trigger-bg-hover, var(--bgColor-neutral-muted, #eaeef2));
221
+ }
222
+
223
+ .zoomBtn:disabled {
224
+ opacity: 0.3;
225
+ cursor: default;
226
+ }
227
+
228
+ .zoomLabel {
229
+ display: flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ min-width: 48px;
233
+ height: 32px;
234
+ padding: 0 4px;
235
+ font-size: 11px;
236
+ font-weight: 600;
237
+ font-variant-numeric: tabular-nums;
238
+ color: var(--trigger-text, var(--fgColor-default, #1f2328));
239
+ border-left: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
240
+ border-right: 1.5px solid var(--trigger-border, var(--borderColor-muted, #d0d7de));
241
+ user-select: none;
242
+ }
@@ -0,0 +1,98 @@
1
+ import { useState, useRef, useEffect, useCallback } from 'react'
2
+ import { readProp, stickyNoteSchema } from './widgetProps.js'
3
+ import styles from './StickyNote.module.css'
4
+
5
+ const COLORS = {
6
+ yellow: { bg: '#fff8c5', border: '#d4a72c', dot: '#e8c846' },
7
+ blue: { bg: '#ddf4ff', border: '#54aeff', dot: '#74b9ff' },
8
+ green: { bg: '#dafbe1', border: '#4ac26b', dot: '#6dd58c' },
9
+ pink: { bg: '#ffebe9', border: '#ff8182', dot: '#ff9a9e' },
10
+ purple: { bg: '#fbefff', border: '#c297ff', dot: '#d4a8ff' },
11
+ orange: { bg: '#fff1e5', border: '#d18616', dot: '#e8a844' },
12
+ }
13
+
14
+ export default function StickyNote({ props, onUpdate }) {
15
+ const text = readProp(props, 'text', stickyNoteSchema)
16
+ const color = readProp(props, 'color', stickyNoteSchema)
17
+ const palette = COLORS[color] ?? COLORS.yellow
18
+ const textareaRef = useRef(null)
19
+ const [editing, setEditing] = useState(false)
20
+
21
+ useEffect(() => {
22
+ if (editing && textareaRef.current) {
23
+ textareaRef.current.focus()
24
+ textareaRef.current.selectionStart = textareaRef.current.value.length
25
+ }
26
+ }, [editing])
27
+
28
+ const handleTextChange = useCallback((e) => {
29
+ onUpdate?.({ text: e.target.value })
30
+ }, [onUpdate])
31
+
32
+ const handleColorChange = useCallback((newColor) => {
33
+ onUpdate?.({ color: newColor })
34
+ }, [onUpdate])
35
+
36
+ return (
37
+ <div className={styles.container}>
38
+ <article
39
+ className={styles.sticky}
40
+ style={{ '--sticky-bg': palette.bg, '--sticky-border': palette.border }}
41
+ >
42
+ <p
43
+ className={styles.text}
44
+ style={editing ? { visibility: 'hidden' } : undefined}
45
+ onDoubleClick={() => setEditing(true)}
46
+ role="button"
47
+ tabIndex={0}
48
+ onKeyDown={(e) => { if (e.key === 'Enter') setEditing(true) }}
49
+ >
50
+ {text || 'Double-click to edit…'}
51
+ </p>
52
+ {editing && (
53
+ <textarea
54
+ ref={textareaRef}
55
+ className={styles.textarea}
56
+ value={text}
57
+ onChange={handleTextChange}
58
+ onBlur={() => setEditing(false)}
59
+ onMouseDown={(e) => e.stopPropagation()}
60
+ onPointerDown={(e) => e.stopPropagation()}
61
+ onKeyDown={(e) => {
62
+ if (e.key === 'Escape') setEditing(false)
63
+ }}
64
+ placeholder="Type here…"
65
+ />
66
+ )}
67
+ </article>
68
+
69
+ {/* Color picker — dot trigger below the sticky */}
70
+ <div
71
+ className={styles.pickerArea}
72
+ onMouseDown={(e) => e.stopPropagation()}
73
+ onPointerDown={(e) => e.stopPropagation()}
74
+ >
75
+ <span
76
+ className={styles.pickerDot}
77
+ style={{ background: palette.dot }}
78
+ />
79
+ <div className={styles.pickerPopup}>
80
+ {Object.entries(COLORS).map(([colorName, c]) => (
81
+ <button
82
+ key={colorName}
83
+ className={`${styles.colorDot} ${colorName === color ? styles.active : ''}`}
84
+ style={{ background: c.bg, borderColor: c.border }}
85
+ onClick={(e) => {
86
+ e.stopPropagation()
87
+ handleColorChange(colorName)
88
+ }}
89
+ title={colorName}
90
+ aria-label={`Set color to ${colorName}`}
91
+ />
92
+ ))}
93
+ </div>
94
+ </div>
95
+ </div>
96
+ )
97
+ }
98
+
@@ -0,0 +1,111 @@
1
+ .container {
2
+ position: relative;
3
+ }
4
+
5
+ .sticky {
6
+ background: var(--sticky-bg, #fff8c5);
7
+ border-radius: 6px;
8
+ border: 2px solid color-mix(in srgb, var(--sticky-bg) 80%, rgb(0, 0, 0) 10%);
9
+ min-width: 180px;
10
+ box-shadow: 2px 3px 8px rgba(0, 0, 0, 0.04);
11
+ font-family: var(--tc-font-stack, system-ui, -apple-system, sans-serif);
12
+ resize: both;
13
+ overflow: auto;
14
+ position: relative;
15
+ }
16
+
17
+ .text {
18
+ padding: 16px 20px;
19
+ margin: 0;
20
+ font-size: 18px;
21
+ line-height: 1.5;
22
+ color: color-mix(in srgb, var(--sticky-bg), black 70%);
23
+ white-space: pre-wrap;
24
+ word-break: break-word;
25
+ cursor: text;
26
+ min-height: 60px;
27
+ }
28
+
29
+ .textarea {
30
+ position: absolute;
31
+ top: 0;
32
+ left: 0;
33
+ width: 100%;
34
+ height: 100%;
35
+ box-sizing: border-box;
36
+ padding: 16px 20px;
37
+ margin: 0;
38
+ border: none;
39
+ outline: none;
40
+ background: transparent;
41
+ font-family: inherit;
42
+ font-size: 18px;
43
+ line-height: 1.5;
44
+ color: color-mix(in srgb, var(--sticky-bg) 80%, rgb(0, 0, 0) 98%);
45
+ white-space: pre-wrap;
46
+ word-break: break-word;
47
+ resize: none;
48
+ }
49
+
50
+ /* Color picker area — sits below the sticky */
51
+
52
+ .pickerArea {
53
+ display: flex;
54
+ justify-content: center;
55
+ padding-top: 6px;
56
+ position: relative;
57
+ }
58
+
59
+ .pickerDot {
60
+ width: 8px;
61
+ height: 8px;
62
+ border-radius: 50%;
63
+ opacity: 0.5;
64
+ transition: opacity 150ms;
65
+ cursor: pointer;
66
+ }
67
+
68
+ .pickerPopup {
69
+ position: absolute;
70
+ top: 4px;
71
+ display: flex;
72
+ gap: 5px;
73
+ padding: 6px 10px;
74
+ background: var(--bgColor-default, #ffffff);
75
+ border-radius: 20px;
76
+ box-shadow:
77
+ 0 0 0 1px rgba(0, 0, 0, 0.08),
78
+ 0 4px 12px rgba(0, 0, 0, 0.12);
79
+ opacity: 0;
80
+ pointer-events: none;
81
+ transition: opacity 150ms;
82
+ z-index: 10;
83
+ }
84
+
85
+ .pickerArea:hover .pickerDot {
86
+ opacity: 0;
87
+ }
88
+
89
+ .pickerArea:hover .pickerPopup {
90
+ opacity: 1;
91
+ pointer-events: auto;
92
+ }
93
+
94
+ .colorDot {
95
+ all: unset;
96
+ width: 20px;
97
+ height: 20px;
98
+ border-radius: 50%;
99
+ border: 2px solid transparent;
100
+ cursor: pointer;
101
+ transition: transform 100ms;
102
+ }
103
+
104
+ .colorDot:hover {
105
+ transform: scale(1.15);
106
+ }
107
+
108
+ .colorDot.active {
109
+ border-color: var(--sticky-border);
110
+ box-shadow: 0 0 0 1px var(--sticky-border);
111
+ }
@@ -0,0 +1,15 @@
1
+ import styles from './WidgetWrapper.module.css'
2
+
3
+ /**
4
+ * Common wrapper for all canvas widgets.
5
+ * Provides shadow/border styling.
6
+ */
7
+ export default function WidgetWrapper({ children, className }) {
8
+ return (
9
+ <section className={`${styles.wrapper} ${className || ''}`}>
10
+ <div className={styles.content}>
11
+ {children}
12
+ </div>
13
+ </section>
14
+ )
15
+ }
@@ -0,0 +1,23 @@
1
+ .wrapper {
2
+ background: var(--bgColor-default, #ffffff);
3
+ border-radius: var(--borderRadius-large, 12px);
4
+ overflow: hidden;
5
+ min-width: 200px;
6
+ box-shadow:
7
+ 0 0 0 1px rgba(0, 0, 0, 0.06),
8
+ 0 0 0 2px rgba(255, 255, 255, 0.04),
9
+ 0 2px 8px rgba(0, 0, 0, 0.08);
10
+ }
11
+
12
+ .content {
13
+ position: relative;
14
+ }
15
+
16
+ @media (prefers-color-scheme: dark) {
17
+ .wrapper {
18
+ box-shadow:
19
+ 0 0 0 1px rgba(255, 255, 255, 0.08),
20
+ 0 0 0 2px rgba(0, 0, 0, 0.3),
21
+ 0 2px 8px rgba(0, 0, 0, 0.25);
22
+ }
23
+ }
@@ -0,0 +1,23 @@
1
+ import StickyNote from './StickyNote.jsx'
2
+ import MarkdownBlock from './MarkdownBlock.jsx'
3
+ import PrototypeEmbed from './PrototypeEmbed.jsx'
4
+ import LinkPreview from './LinkPreview.jsx'
5
+
6
+ /**
7
+ * Maps widget type strings to their React components.
8
+ * Each component receives: { id, props, onUpdate }
9
+ */
10
+ export const widgetRegistry = {
11
+ 'sticky-note': StickyNote,
12
+ 'markdown': MarkdownBlock,
13
+ 'prototype': PrototypeEmbed,
14
+ 'link-preview': LinkPreview,
15
+ }
16
+
17
+ /**
18
+ * Resolve a widget type string to its component.
19
+ * Returns null for unknown types.
20
+ */
21
+ export function getWidgetComponent(type) {
22
+ return widgetRegistry[type] ?? null
23
+ }
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Canvas Widget Props API
3
+ *
4
+ * Every canvas widget receives its data through a structured `props` object
5
+ * stored in .canvas.json. This module defines the prop schema system that
6
+ * widgets use to declare, read, and update their editable properties.
7
+ *
8
+ * ## Prop Categories
9
+ *
10
+ * Widget props are grouped into three categories:
11
+ *
12
+ * ### `content` — User-editable content
13
+ * Text, markdown, URLs — the stuff users type or paste.
14
+ * Updated frequently (every keystroke when editing).
15
+ * Examples: sticky note text, markdown content, embed URL.
16
+ *
17
+ * ### `settings` — Widget configuration
18
+ * One-off choices that affect appearance or behavior.
19
+ * Updated infrequently (user picks from a menu).
20
+ * Examples: sticky note color, markdown width, embed layout.
21
+ *
22
+ * ### `size` — Dimensions
23
+ * Width and height of the widget.
24
+ * Updated via resize handles or explicit input.
25
+ * Examples: markdown block width, prototype embed width/height.
26
+ *
27
+ * ## Storage Format (.canvas.json)
28
+ *
29
+ * Props are stored flat in the widget's `props` object:
30
+ *
31
+ * ```json
32
+ * {
33
+ * "id": "sticky-1",
34
+ * "type": "sticky-note",
35
+ * "position": { "x": 100, "y": 200 },
36
+ * "props": {
37
+ * "text": "Hello world",
38
+ * "color": "yellow"
39
+ * }
40
+ * }
41
+ * ```
42
+ *
43
+ * ## Widget Contract
44
+ *
45
+ * Every widget component receives:
46
+ * - `id` — stable widget identifier
47
+ * - `props` — the flat props object (may be null/undefined)
48
+ * - `onUpdate` — callback to persist prop changes: onUpdate({ key: value })
49
+ * - `onRemove` — callback to delete the widget
50
+ *
51
+ * `onUpdate` accepts a partial object that is shallow-merged into `props`.
52
+ * Multiple keys can be updated in one call:
53
+ * onUpdate({ text: 'new text', color: 'blue' })
54
+ *
55
+ * ## Declaring Widget Props (Schema)
56
+ *
57
+ * Each widget type exports a `schema` describing its props.
58
+ * This is used by the toolbar, canvas settings, and future widget inspectors.
59
+ */
60
+
61
+ /**
62
+ * @typedef {'text' | 'select' | 'number' | 'url' | 'boolean'} PropType
63
+ *
64
+ * @typedef {Object} PropDef
65
+ * @property {PropType} type — input type for editing
66
+ * @property {string} label — human-readable label
67
+ * @property {string} category — 'content' | 'settings' | 'size'
68
+ * @property {*} defaultValue — fallback when prop is missing
69
+ * @property {Array} [options] — choices for 'select' type
70
+ * @property {number} [min] — minimum for 'number' type
71
+ * @property {number} [max] — maximum for 'number' type
72
+ */
73
+
74
+ /**
75
+ * Read a prop value with fallback to schema default.
76
+ * @param {object} props — widget props object (may be null)
77
+ * @param {string} key — prop name
78
+ * @param {object} schema — widget schema
79
+ * @returns {*}
80
+ */
81
+ export function readProp(props, key, schema) {
82
+ const value = props?.[key]
83
+ if (value !== undefined && value !== null) return value
84
+ return schema[key]?.defaultValue ?? null
85
+ }
86
+
87
+ /**
88
+ * Read all props with defaults applied from schema.
89
+ * @param {object} props — widget props object (may be null)
90
+ * @param {object} schema — widget schema
91
+ * @returns {object}
92
+ */
93
+ export function readAllProps(props, schema) {
94
+ const result = {}
95
+ for (const key of Object.keys(schema)) {
96
+ result[key] = readProp(props, key, schema)
97
+ }
98
+ return result
99
+ }
100
+
101
+ /**
102
+ * Get default props for a widget type from its schema.
103
+ * Used when creating new widgets.
104
+ * @param {object} schema — widget schema
105
+ * @returns {object}
106
+ */
107
+ export function getDefaults(schema) {
108
+ const result = {}
109
+ for (const [key, def] of Object.entries(schema)) {
110
+ if (def.defaultValue !== undefined) {
111
+ result[key] = def.defaultValue
112
+ }
113
+ }
114
+ return result
115
+ }
116
+
117
+ // ── Widget Schemas ──────────────────────────────────────────────────
118
+
119
+ export const stickyNoteSchema = {
120
+ text: { type: 'text', label: 'Text', category: 'content', defaultValue: '' },
121
+ color: { type: 'select', label: 'Color', category: 'settings', defaultValue: 'yellow',
122
+ options: ['yellow', 'blue', 'green', 'pink', 'purple', 'orange'] },
123
+ }
124
+
125
+ export const markdownSchema = {
126
+ content: { type: 'text', label: 'Content', category: 'content', defaultValue: '' },
127
+ width: { type: 'number', label: 'Width', category: 'size', defaultValue: 360, min: 200, max: 1200 },
128
+ }
129
+
130
+ export const prototypeEmbedSchema = {
131
+ src: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
132
+ label: { type: 'text', label: 'Label', category: 'settings', defaultValue: '' },
133
+ zoom: { type: 'number', label: 'Zoom', category: 'settings', defaultValue: 100, min: 25, max: 200 },
134
+ width: { type: 'number', label: 'Width', category: 'size', defaultValue: 800, min: 200, max: 2000 },
135
+ height: { type: 'number', label: 'Height', category: 'size', defaultValue: 600, min: 200, max: 1500 },
136
+ }
137
+
138
+ export const linkPreviewSchema = {
139
+ url: { type: 'url', label: 'URL', category: 'content', defaultValue: '' },
140
+ title: { type: 'text', label: 'Title', category: 'content', defaultValue: '' },
141
+ }
142
+
143
+ /**
144
+ * Schema registry — maps widget type strings to their schemas.
145
+ */
146
+ export const schemas = {
147
+ 'sticky-note': stickyNoteSchema,
148
+ 'markdown': markdownSchema,
149
+ 'prototype': prototypeEmbedSchema,
150
+ 'link-preview': linkPreviewSchema,
151
+ }
@@ -1,16 +1,14 @@
1
1
  import { useSyncExternalStore } from 'react'
2
- import { getFlag, subscribeToHash, getHashSnapshot, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
2
+ import { getFlag, subscribeToStorage, getStorageSnapshot } from '@dfosco/storyboard-core'
3
3
 
4
4
  /**
5
5
  * React hook for reading a feature flag value.
6
- * Re-renders when the flag changes (via hash or localStorage).
6
+ * Re-renders when the flag changes (via localStorage).
7
7
  *
8
8
  * @param {string} key - Flag key (without "flag." prefix)
9
9
  * @returns {boolean} Current resolved flag value
10
10
  */
11
11
  export function useFeatureFlag(key) {
12
- // Subscribe to both hash and storage changes for reactivity
13
- useSyncExternalStore(subscribeToHash, getHashSnapshot)
14
12
  useSyncExternalStore(subscribeToStorage, getStorageSnapshot)
15
13
  return getFlag(key)
16
14
  }