@westopp/windo 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.
@@ -21,6 +21,9 @@
21
21
  --accent-text: #ffffff;
22
22
  --accent-soft: color-mix(in srgb, var(--accent) 8%, transparent);
23
23
  --danger: #c0443c;
24
+ --success: #3c7a4a;
25
+ --warn: #99661c;
26
+ --deprecated: #a8433a;
24
27
  --surface: #ffffff;
25
28
  --shadow: 0 1px 2px rgba(16, 16, 18, 0.04), 0 8px 24px rgba(16, 16, 18, 0.06);
26
29
  --mono: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace;
@@ -41,6 +44,10 @@
41
44
  --accent-ui: color-mix(in oklab, var(--accent) 35%, white);
42
45
  --accent-soft: color-mix(in srgb, var(--accent-ui) 14%, transparent);
43
46
  --danger: #e0837b;
47
+ --success: #84c896;
48
+ --warn: #dcb064;
49
+ --deprecated: #e6918a;
50
+ --accent-contrast: #111113;
44
51
  --surface: #161618;
45
52
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 32px rgba(0, 0, 0, 0.45);
46
53
  }
@@ -373,7 +380,7 @@ button {
373
380
 
374
381
  [data-theme="dark"] .wb-tag-pill {
375
382
  background: var(--accent-ui);
376
- color: #111113;
383
+ color: var(--accent-contrast);
377
384
  }
378
385
 
379
386
  .wb-tag-pill .x {
@@ -476,7 +483,7 @@ button {
476
483
  [data-theme="dark"] .wb-check.checked {
477
484
  background: var(--accent-ui);
478
485
  border-color: var(--accent-ui);
479
- color: #111113;
486
+ color: var(--accent-contrast);
480
487
  }
481
488
 
482
489
  .wb-tag-divider {
@@ -642,29 +649,16 @@ button {
642
649
  }
643
650
 
644
651
  .wb-status.stable {
645
- color: #3c7a4a;
646
- background: rgba(60, 122, 74, 0.1);
652
+ color: var(--success);
653
+ background: color-mix(in srgb, var(--success) 10%, transparent);
647
654
  }
648
655
  .wb-status.beta {
649
- color: #99661c;
650
- background: rgba(153, 102, 28, 0.1);
656
+ color: var(--warn);
657
+ background: color-mix(in srgb, var(--warn) 10%, transparent);
651
658
  }
652
659
  .wb-status.deprecated {
653
- color: #a8433a;
654
- background: rgba(168, 67, 58, 0.1);
655
- }
656
-
657
- [data-theme="dark"] .wb-status.stable {
658
- color: #84c896;
659
- background: rgba(132, 200, 150, 0.12);
660
- }
661
- [data-theme="dark"] .wb-status.beta {
662
- color: #dcb064;
663
- background: rgba(220, 176, 100, 0.12);
664
- }
665
- [data-theme="dark"] .wb-status.deprecated {
666
- color: #e6918a;
667
- background: rgba(230, 145, 138, 0.12);
660
+ color: var(--deprecated);
661
+ background: color-mix(in srgb, var(--deprecated) 10%, transparent);
668
662
  }
669
663
 
670
664
  .wb-nav-empty {
@@ -768,7 +762,7 @@ button {
768
762
  [data-theme="dark"] .wb-trigger.on {
769
763
  background: var(--accent-ui);
770
764
  border-color: var(--accent-ui);
771
- color: #111113;
765
+ color: var(--accent-contrast);
772
766
  }
773
767
 
774
768
  .wb-trigger .tdot {
@@ -1242,6 +1236,45 @@ button {
1242
1236
  font-weight: 600;
1243
1237
  }
1244
1238
 
1239
+ /* Editable shared-state strip (ctx.ctxState) — same look as .wb-state, with inline editors */
1240
+
1241
+ .wb-ctxstate {
1242
+ display: flex;
1243
+ align-items: center;
1244
+ gap: 12px;
1245
+ flex-wrap: wrap;
1246
+ padding: 7px 16px;
1247
+ border-bottom: 1px solid var(--border);
1248
+ background: var(--inset);
1249
+ font-size: 11.5px;
1250
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
1251
+ flex-shrink: 0;
1252
+ }
1253
+
1254
+ .wb-ctxstate .wb-state-item {
1255
+ gap: 6px;
1256
+ }
1257
+
1258
+ .wb-ctxstate-input {
1259
+ width: 90px;
1260
+ padding: 1px 6px;
1261
+ border: 1px solid var(--border);
1262
+ border-radius: 5px;
1263
+ background: var(--bg);
1264
+ color: var(--text);
1265
+ font: inherit;
1266
+ font-weight: 600;
1267
+ }
1268
+
1269
+ .wb-ctxstate-input[type='number'] {
1270
+ width: 60px;
1271
+ }
1272
+
1273
+ .wb-ctxstate-input:focus {
1274
+ outline: none;
1275
+ border-color: var(--accent-ui);
1276
+ }
1277
+
1245
1278
  .wb-tab {
1246
1279
  height: 100%;
1247
1280
  padding: 0 1px;
@@ -1359,7 +1392,7 @@ button {
1359
1392
  [data-theme="dark"] .wb-save {
1360
1393
  background: var(--accent-ui);
1361
1394
  border-color: var(--accent-ui);
1362
- color: #111113;
1395
+ color: var(--accent-contrast);
1363
1396
  }
1364
1397
 
1365
1398
  .wb-save:disabled {
@@ -1401,8 +1434,8 @@ button {
1401
1434
  font-size: 10.5px;
1402
1435
  line-height: 1.6;
1403
1436
  color: var(--danger);
1404
- background: rgba(192, 68, 60, 0.07);
1405
- border: 1px solid rgba(192, 68, 60, 0.25);
1437
+ background: color-mix(in srgb, var(--danger) 7%, transparent);
1438
+ border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
1406
1439
  border-radius: var(--r);
1407
1440
  padding: 8px 10px;
1408
1441
  white-space: pre-wrap;
@@ -1411,11 +1444,7 @@ button {
1411
1444
  .wb-saved-flash {
1412
1445
  font-size: 11.5px;
1413
1446
  font-weight: 550;
1414
- color: #3c7a4a;
1415
- }
1416
-
1417
- [data-theme="dark"] .wb-saved-flash {
1418
- color: #84c896;
1447
+ color: var(--success);
1419
1448
  }
1420
1449
 
1421
1450
  .wb-schema {
@@ -1632,11 +1661,7 @@ button {
1632
1661
  color: var(--text);
1633
1662
  }
1634
1663
  .wb-copy.copied {
1635
- color: #3c7a4a;
1636
- }
1637
-
1638
- [data-theme="dark"] .wb-copy.copied {
1639
- color: #84c896;
1664
+ color: var(--success);
1640
1665
  }
1641
1666
 
1642
1667
  /* ---------- Variants gallery ---------- */
@@ -1885,6 +1910,10 @@ select.wb-ctrl-input {
1885
1910
  background-position: right 8px center;
1886
1911
  }
1887
1912
 
1913
+ [data-theme="dark"] select.wb-ctrl-input {
1914
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239b9ba2' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
1915
+ }
1916
+
1888
1917
  .wb-ctrl-row input[type="range"] {
1889
1918
  flex: 1;
1890
1919
  accent-color: var(--accent-ui);
@@ -1935,7 +1964,7 @@ select.wb-ctrl-input {
1935
1964
  }
1936
1965
 
1937
1966
  [data-theme="dark"] .wb-switch.on::after {
1938
- background: #111113;
1967
+ background: var(--accent-contrast);
1939
1968
  }
1940
1969
 
1941
1970
  .wb-context-empty {
@@ -2050,21 +2079,17 @@ select.wb-ctrl-input {
2050
2079
  color: var(--accent-ui);
2051
2080
  }
2052
2081
  .wb-log.warn .llevel {
2053
- color: #99661c;
2082
+ color: var(--warn);
2054
2083
  }
2055
2084
  .wb-log.error .llevel {
2056
2085
  color: var(--danger);
2057
2086
  }
2058
2087
 
2059
- [data-theme="dark"] .wb-log.warn .llevel {
2060
- color: #dcb064;
2061
- }
2062
-
2063
2088
  .wb-log.warn {
2064
- background: rgba(153, 102, 28, 0.05);
2089
+ background: color-mix(in srgb, var(--warn) 5%, transparent);
2065
2090
  }
2066
2091
  .wb-log.error {
2067
- background: rgba(192, 68, 60, 0.05);
2092
+ background: color-mix(in srgb, var(--danger) 5%, transparent);
2068
2093
  }
2069
2094
 
2070
2095
  .wb-log .lmsg {
@@ -2098,8 +2123,8 @@ select.wb-ctrl-input {
2098
2123
  margin: 14px 16px;
2099
2124
  padding: 12px 14px;
2100
2125
  border-radius: 10px;
2101
- background: rgba(192, 68, 60, 0.07);
2102
- border: 1px solid rgba(192, 68, 60, 0.25);
2126
+ background: color-mix(in srgb, var(--danger) 7%, transparent);
2127
+ border: 1px solid color-mix(in srgb, var(--danger) 25%, transparent);
2103
2128
  }
2104
2129
 
2105
2130
  .wb-render-error .rtitle {
@@ -2147,8 +2172,8 @@ select.wb-ctrl-input {
2147
2172
  margin: 12px 16px 0 16px;
2148
2173
  padding: 8px 11px;
2149
2174
  border-radius: var(--r);
2150
- background: rgba(168, 67, 58, 0.06);
2151
- border: 1px solid rgba(168, 67, 58, 0.22);
2175
+ background: color-mix(in srgb, var(--deprecated) 6%, transparent);
2176
+ border: 1px solid color-mix(in srgb, var(--deprecated) 22%, transparent);
2152
2177
  color: var(--danger);
2153
2178
  font-size: 12px;
2154
2179
  line-height: 1.45;
@@ -30,6 +30,8 @@ export interface BridgeApi {
30
30
  /** Increments each time the preview (re)signals ready — e.g. after an HMR or dep-optimize reload. The chrome re-syncs selection/props/env when it changes. */
31
31
  readyNonce: number
32
32
  title: string
33
+ /** Config `faviconUrl` — drives the chrome logo + browser favicon. `null` falls back to the built-in `wn` mark. */
34
+ faviconUrl: string | null
33
35
  groups: WindoGroup[]
34
36
  /** The config's declared tag set (filter options for the sidebar). */
35
37
  tags: string[]
@@ -45,10 +47,18 @@ export interface BridgeApi {
45
47
  stateValues: Record<string, Record<string, unknown>>
46
48
  /** Live `disabled` flag per action id (keyed windo id → action id), echoed from the preview. */
47
49
  actionDisabled: Record<string, Record<string, boolean>>
50
+ /** Config seed for the shared `ctxState` (from the manifest) — fills keys the chrome hasn't persisted. */
51
+ ctxStateDefaults: Record<string, unknown>
52
+ /** Latest shared-state snapshot echoed after a component wrote it via `ctx.setCtxState`. */
53
+ ctxState: Record<string, unknown>
54
+ /** Latest colour scheme a component pushed via `ctx.toggleTheme`/`setColorScheme` (null until one does). Re-wrapped each echo so repeated same-value pushes still propagate. */
55
+ colorScheme: { value: 'light' | 'dark' } | null
48
56
  // outbound
49
57
  select: (id: string) => void
50
58
  setProps: (id: string, json: string) => void
51
59
  setEnv: (env: WindoEnvState) => void
60
+ /** Push the shared state down to the preview (editor edit or reload re-sync). */
61
+ setCtxState: (state: Record<string, unknown>) => void
52
62
  invokeAction: (id: string, actionId: string) => void
53
63
  }
54
64
 
@@ -146,6 +156,10 @@ export interface InspectorProps {
146
156
  clearLogs: () => void
147
157
  /** Live component-local state snapshot for the selected windo (drives the read-only State strip). */
148
158
  state: Record<string, unknown>
159
+ /** Shared, cross-component state — drives the editable Shared strip. */
160
+ ctxState: Record<string, unknown>
161
+ /** Edit one key of the shared state (pushes down to the preview). */
162
+ setCtxStateValue: (key: string, value: unknown) => void
149
163
  /** Context metadata filtered to this windo (its `uses` providers + all ambient). */
150
164
  contexts: WindoContextMeta[]
151
165
  env: ChromeEnv
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  WINDO_MSG,
15
15
  } from './protocol'
16
16
  export type {
17
+ Ctxual,
17
18
  WindoAction,
18
19
  WindoActionMeta,
19
20
  WindoActionTrigger,
@@ -34,6 +35,7 @@ export type {
34
35
  WindoFactoryArg,
35
36
  WindoFieldError,
36
37
  WindoGroup,
38
+ WindoInitContext,
37
39
  WindoLogEntry,
38
40
  WindoLogger,
39
41
  WindoManifestEntry,
@@ -40,6 +40,7 @@ const CHROME_HTML = `<!doctype html>
40
40
  <meta charset="utf-8" />
41
41
  <meta name="viewport" content="width=device-width, initial-scale=1" />
42
42
  <title>windo</title>
43
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Crect%20width%3D%2232%22%20height%3D%2232%22%20rx%3D%229%22%20fill%3D%22%23d83a2e%22%2F%3E%3Ctext%20x%3D%2216%22%20y%3D%2222%22%20text-anchor%3D%22middle%22%20font-size%3D%2216%22%20font-weight%3D%22800%22%20font-family%3D%22system-ui%2C-apple-system%2CHelvetica%2CArial%2Csans-serif%22%20fill%3D%22%23f5c518%22%3Ewn%3C%2Ftext%3E%3C%2Fsvg%3E" />
43
44
  </head>
44
45
  <body>
45
46
  <div id="root"></div>
@@ -10,7 +10,10 @@ export function buildRenderContext<State>(
10
10
  contexts: WindoContextMap,
11
11
  postLog: (entry: WindoLogEntry) => void,
12
12
  state: State,
13
- setState: (patch: Partial<State>) => void
13
+ setState: (patch: Partial<State>) => void,
14
+ ctxState: Record<string, unknown>,
15
+ setCtxState: (patch: Record<string, unknown>) => void,
16
+ setColorScheme: (scheme: 'light' | 'dark') => void
14
17
  ): WindoRenderContext<State> {
15
18
  const logger = {
16
19
  log: (...args: unknown[]) => postLog({ ts: Date.now(), args }),
@@ -26,6 +29,10 @@ export function buildRenderContext<State>(
26
29
  state,
27
30
  setState,
28
31
  contexts: {},
32
+ ctxState,
33
+ setCtxState,
34
+ setColorScheme,
35
+ toggleTheme: () => setColorScheme(env.colorScheme === 'light' ? 'dark' : 'light'),
29
36
  }
30
37
 
31
38
  for (const name of Object.keys(contexts)) {
@@ -10,7 +10,7 @@ import type { z } from 'zod'
10
10
  import { describeSchema } from '../descriptor'
11
11
  import type { WindoHostMessage, WindoPreviewMessage } from '../protocol'
12
12
  import { isHostMessage, isWindoMessage, WINDO_MSG } from '../protocol'
13
- import type { WindoActionMeta, WindoContextMap, WindoDefinition, WindoEnvState, WindoFactoryArg, WindoGroup, WindoLogEntry, WindoManifestEntry, WindoRenderContext, WindoVariantMeta } from '../types'
13
+ import type { WindoActionMeta, WindoContextMap, WindoDefinition, WindoEnvState, WindoFactoryArg, WindoGroup, WindoInitContext, WindoLogEntry, WindoManifestEntry, WindoPlacement, WindoPropDoc, WindoRenderContext, WindoVariant, WindoVariantMeta } from '../types'
14
14
  import { buildRenderContext } from './ctx'
15
15
  import { buildContextMeta, flattenZodError, idFromPath, sortEntries, summarizeLogArg, toCloneable } from './registry'
16
16
  import { PreviewRoot } from './render'
@@ -46,7 +46,14 @@ let currentSchema: z.ZodType | undefined
46
46
  let currentValues: unknown = {}
47
47
  let env: WindoEnvState = DEFAULT_ENV
48
48
  let currentState: Record<string, unknown> = {}
49
+ // Shared, cross-component state. Seeded once from the config and — unlike
50
+ // `currentState` — never reset on selection, so it survives switching windos.
51
+ let ctxState: Record<string, unknown> = { ...((config.ctxState as Record<string, unknown>) ?? {}) }
49
52
  let root: Root | null = null
53
+ // Raised while a windo's full-ctx fields are being resolved (init state, defaultProps,
54
+ // placement). Resolution must be a pure read of the env/ctxState surface — so the
55
+ // mutating callbacks no-op while it's set, closing every re-entrant render path.
56
+ let resolving = false
50
57
 
51
58
  function postToParent(msg: WindoPreviewMessage) {
52
59
  window.parent.postMessage(msg, '*')
@@ -60,15 +67,57 @@ function postLog(entry: WindoLogEntry) {
60
67
 
61
68
  /** The live ctx, rebuilt on demand so render-time, toolbar, and stage events all share the current state. */
62
69
  function makeCtx(): WindoRenderContext {
63
- return buildRenderContext(env, contexts, postLog, currentState, setState)
70
+ return buildRenderContext(env, contexts, postLog, currentState, setState, ctxState, setCtxState, setColorScheme)
71
+ }
72
+
73
+ /**
74
+ * The init-scoped ctx handed to a `state` resolver. State is resolved exactly once,
75
+ * here, before any component-local state exists — so it exposes the env surface only
76
+ * with a no-op `setState`. The shared `setCtxState`/`setColorScheme` are still wired
77
+ * through but no-op while `resolving` is set, preventing an author from re-triggering
78
+ * resolution mid-init. Typed as `WindoInitContext` to drop the absent `state`/`setState`.
79
+ */
80
+ function makeInitCtx(): WindoInitContext {
81
+ return buildRenderContext(env, contexts, postLog, {}, () => {}, ctxState, setCtxState, setColorScheme)
64
82
  }
65
83
 
66
84
  function setState(patch: Partial<Record<string, unknown>>) {
85
+ // No-op while resolving a windo's full-ctx fields — those are pure reads and must
86
+ // never drive a render, which would re-enter resolution and loop.
87
+ if (resolving) return
67
88
  currentState = { ...currentState, ...patch }
68
89
  render()
69
90
  postState()
70
91
  }
71
92
 
93
+ /** Merge a patch into the shared state, re-render, and echo it up to the chrome. */
94
+ function setCtxState(patch: Record<string, unknown>) {
95
+ if (resolving) return
96
+ ctxState = { ...ctxState, ...patch }
97
+ render()
98
+ postCtxState()
99
+ }
100
+
101
+ /** Cloneable snapshot of the shared state (drops functions/JSX before postMessage). */
102
+ function cloneableCtxState(): Record<string, unknown> {
103
+ const out: Record<string, unknown> = {}
104
+ for (const key of Object.keys(ctxState)) out[key] = toCloneable(ctxState[key])
105
+ return out
106
+ }
107
+
108
+ function postCtxState() {
109
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'ctx-state', state: cloneableCtxState() })
110
+ }
111
+
112
+ /** Set the canvas colour scheme from a component/action and echo it so the chrome's toggle stays in sync. */
113
+ function setColorScheme(scheme: 'light' | 'dark') {
114
+ if (resolving) return
115
+ if (env.colorScheme === scheme) return
116
+ env = { ...env, colorScheme: scheme }
117
+ render()
118
+ postToParent({ source: WINDO_MSG, dir: 'preview', type: 'color-scheme', colorScheme: scheme })
119
+ }
120
+
72
121
  /** Post the current state snapshot + each action's live `disabled` flag up to the chrome. */
73
122
  function postState() {
74
123
  if (!currentId || !currentDef) return
@@ -113,11 +162,13 @@ function manifestEntry(id: string, def: WindoDefinition): WindoManifestEntry {
113
162
  group: def.group,
114
163
  tags: def.tags ?? [],
115
164
  status: def.status ?? 'stable',
116
- placement: def.placement ?? 'center',
165
+ // The now-functionable fields can't be resolved here (no ctx at init): fall back
166
+ // to a safe static value, and assume the dynamic forms contribute a variant/state.
167
+ placement: typeof def.placement === 'function' ? 'center' : (def.placement ?? 'center'),
117
168
  uses: def.uses ?? [],
118
- hasVariants: (def.variants?.length ?? 0) > 0,
169
+ hasVariants: typeof def.variants === 'function' ? true : (def.variants?.length ?? 0) > 0,
119
170
  actions,
120
- hasState: !!def.state && Object.keys(def.state as object).length > 0,
171
+ hasState: typeof def.state === 'function' ? true : !!def.state && Object.keys(def.state as object).length > 0,
121
172
  }
122
173
  if (def.description !== undefined) entry.description = def.description
123
174
  if (def.deprecation !== undefined) entry.deprecation = def.deprecation
@@ -132,10 +183,85 @@ function computeDefaults(schema: z.ZodType | undefined): unknown {
132
183
 
133
184
  function safeCode(def: WindoDefinition, defaults: unknown): string | null {
134
185
  if (!def.code) return null
186
+ // `code` is a pure string producer — resolve it under `resolving` so it can't drive a
187
+ // render and post an inconsistent describe payload by mutating state mid-resolution.
188
+ resolving = true
135
189
  try {
136
- return def.code(defaults as never)
190
+ return def.code(defaults as never, makeCtx())
137
191
  } catch {
138
192
  return null
193
+ } finally {
194
+ resolving = false
195
+ }
196
+ }
197
+
198
+ /** Resolve the authored Props table — a static array or a `ctx => array` function — under `resolving` so it stays a pure read. */
199
+ function safeProps(def: WindoDefinition): WindoPropDoc[] {
200
+ const p = def.props
201
+ if (typeof p !== 'function') return p ?? []
202
+ resolving = true
203
+ try {
204
+ return p(makeCtx()) ?? []
205
+ } catch {
206
+ return []
207
+ } finally {
208
+ resolving = false
209
+ }
210
+ }
211
+
212
+ /** Resolve a windo's initial state — a static value or an init-ctx function — under `resolving`, degrading to `{}` if it throws. */
213
+ function safeState(def: WindoDefinition): Record<string, unknown> {
214
+ const state = def.state
215
+ if (typeof state !== 'function') return { ...((state as Record<string, unknown> | undefined) ?? {}) }
216
+ resolving = true
217
+ try {
218
+ return { ...((state as (c: WindoInitContext) => unknown)(makeInitCtx()) as Record<string, unknown>) }
219
+ } catch {
220
+ return {}
221
+ } finally {
222
+ resolving = false
223
+ }
224
+ }
225
+
226
+ /** Resolve `defaultProps` — a static value or a `ctx => props` function — under `resolving` so it can't drive a render. */
227
+ function safeDefaultProps(def: WindoDefinition, ctx: WindoRenderContext): unknown {
228
+ const dp = def.defaultProps
229
+ if (typeof dp !== 'function') return dp
230
+ resolving = true
231
+ try {
232
+ return (dp as (c: WindoRenderContext) => unknown)(ctx)
233
+ } catch {
234
+ return {}
235
+ } finally {
236
+ resolving = false
237
+ }
238
+ }
239
+
240
+ /** Resolve `placement` — a static value or a `ctx => placement` function — under `resolving`, degrading to `center` if it throws. */
241
+ function safePlacement(def: WindoDefinition, ctx: WindoRenderContext): WindoPlacement {
242
+ const p = def.placement
243
+ if (typeof p !== 'function') return p ?? 'center'
244
+ resolving = true
245
+ try {
246
+ return p(ctx)
247
+ } catch {
248
+ return 'center'
249
+ } finally {
250
+ resolving = false
251
+ }
252
+ }
253
+
254
+ /** Resolve the gallery variants — a static array or a `ctx => array` function — under `resolving` so it stays a pure read. */
255
+ function safeVariants(def: WindoDefinition): WindoVariant<unknown>[] {
256
+ const v = def.variants
257
+ if (typeof v !== 'function') return v ?? []
258
+ resolving = true
259
+ try {
260
+ return v(makeCtx()) ?? []
261
+ } catch {
262
+ return []
263
+ } finally {
264
+ resolving = false
139
265
  }
140
266
  }
141
267
 
@@ -145,10 +271,12 @@ function postManifest() {
145
271
  dir: 'preview',
146
272
  type: 'manifest',
147
273
  title: config.title ?? 'windo',
274
+ faviconUrl: config.faviconUrl ?? null,
148
275
  entries,
149
276
  groups: [...config.groups],
150
277
  tags: config.tags ? [...config.tags] : [],
151
278
  contexts: buildContextMeta(contexts),
279
+ ctxState: cloneableCtxState(),
152
280
  })
153
281
  }
154
282
 
@@ -156,14 +284,14 @@ function postDescribe(id: string, def: WindoDefinition, schema: z.ZodType | unde
156
284
  const defaults = computeDefaults(schema)
157
285
  // variant patches and defaults may carry ReactNodes/functions that postMessage
158
286
  // cannot clone — reduce them to JSON-safe forms before they cross the boundary.
159
- const variants: WindoVariantMeta[] = (def.variants ?? []).map(v => ({ label: v.label, props: toCloneable(v.props) as Record<string, unknown> }))
287
+ const variants: WindoVariantMeta[] = safeVariants(def).map(v => ({ label: v.label, props: toCloneable(v.props) as Record<string, unknown> }))
160
288
  postToParent({
161
289
  source: WINDO_MSG,
162
290
  dir: 'preview',
163
291
  type: 'describe',
164
292
  id,
165
293
  descriptor: describeSchema(schema),
166
- props: def.props ?? [],
294
+ props: safeProps(def),
167
295
  variants,
168
296
  defaults: toCloneable(defaults),
169
297
  code: safeCode(def, defaults),
@@ -174,12 +302,18 @@ function render() {
174
302
  if (!currentDef || !root) return
175
303
  const ctx = makeCtx()
176
304
  const id = currentId ?? ''
305
+ // Resolve the full-ctx fields here, OUTSIDE the React render cycle, so a function
306
+ // field that touches a mutating callback can't re-enter render() synchronously.
307
+ const defaultProps = safeDefaultProps(currentDef, ctx)
308
+ const placement = safePlacement(currentDef, ctx)
177
309
  root.render(
178
310
  createElement(PreviewRoot, {
179
311
  def: currentDef,
180
312
  ctx,
181
313
  values: currentValues,
182
314
  contexts,
315
+ defaultProps,
316
+ placement,
183
317
  onStage: dispatchStage,
184
318
  onError: (message: string, stack?: string) => {
185
319
  postToParent({ source: WINDO_MSG, dir: 'preview', type: 'render-error', id, message, ...(stack === undefined ? {} : { stack }) })
@@ -195,8 +329,11 @@ function handleSelect(id: string) {
195
329
  currentDef = match.def
196
330
  currentSchema = match.def.configurableProps
197
331
  currentValues = computeDefaults(currentSchema)
198
- // Reset component-local state to the windo's declared initial state.
199
- currentState = { ...((match.def.state as Record<string, unknown>) ?? {}) }
332
+ // Reset component-local state to the windo's declared initial state. Resolve it
333
+ // ONCE here — a `state` function runs against the init ctx (no live state/setState)
334
+ // under `resolving`, degrading to `{}` on throw. It is never re-resolved in
335
+ // render/makeCtx/set-env/set-ctx-state, so the author cannot drive a resolution loop.
336
+ currentState = safeState(currentDef)
200
337
  render()
201
338
  postState()
202
339
  postDescribe(id, currentDef, currentSchema)
@@ -249,6 +386,12 @@ function handleMessage(msg: WindoHostMessage) {
249
386
  env = msg.env
250
387
  render()
251
388
  break
389
+ case 'set-ctx-state':
390
+ // Chrome is the source of this update (an editor edit or a reload re-sync),
391
+ // so adopt it and re-render — but don't echo it back, to avoid a ping-pong.
392
+ ctxState = { ...msg.state }
393
+ render()
394
+ break
252
395
  case 'invoke-action':
253
396
  invokeAction(msg.actionId)
254
397
  break
@@ -45,15 +45,18 @@ export interface PreviewRootProps {
45
45
  ctx: WindoRenderContext
46
46
  values: unknown
47
47
  contexts: WindoContextMap
48
+ /** `defaultProps` resolved outside the render cycle by `index.ts`, so a function field can't re-enter render here. */
49
+ defaultProps: unknown
50
+ /** `placement` resolved outside the render cycle by `index.ts`, for the same reason. */
51
+ placement: WindoPlacement
48
52
  onError: (message: string, stack?: string) => void
49
53
  /** Fires the windo's pointer-bound actions (`enter`/`exit`/`hover`) as the pointer crosses the stage. */
50
54
  onStage: (phase: 'enter' | 'leave') => void
51
55
  }
52
56
 
53
57
  export function PreviewRoot(props: PreviewRootProps) {
54
- const { def, ctx, values, contexts, onError, onStage } = props
58
+ const { def, ctx, values, contexts, defaultProps, placement, onError, onStage } = props
55
59
 
56
- const defaultProps = typeof def.defaultProps === 'function' ? (def.defaultProps as (c: WindoRenderContext) => unknown)(ctx) : def.defaultProps
57
60
  const finalProps = { ...(defaultProps as object), ...((values as object) ?? {}) }
58
61
 
59
62
  let tree: ReactNode = def.component(finalProps, ctx)
@@ -77,7 +80,6 @@ export function PreviewRoot(props: PreviewRootProps) {
77
80
  )
78
81
  }
79
82
 
80
- const placement: WindoPlacement = def.placement ?? 'center'
81
83
  // Placements render flush; the `-padding` suffix opts into frame padding.
82
84
  const padded = placement.endsWith('-padding')
83
85
  const align = padded ? placement.slice(0, -'-padding'.length) : placement
package/src/protocol.ts CHANGED
@@ -13,6 +13,7 @@ export type WindoHostMessage =
13
13
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'select'; id: string }
14
14
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-props'; id: string; json: string }
15
15
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-env'; env: WindoEnvState }
16
+ | { source: typeof WINDO_MSG; dir: 'host'; type: 'set-ctx-state'; state: Record<string, unknown> }
16
17
  | { source: typeof WINDO_MSG; dir: 'host'; type: 'invoke-action'; id: string; actionId: string }
17
18
 
18
19
  /** iframe -> chrome */
@@ -23,10 +24,14 @@ export type WindoPreviewMessage =
23
24
  dir: 'preview'
24
25
  type: 'manifest'
25
26
  title: string
27
+ /** Config `faviconUrl` — drives the chrome logo + browser favicon. `null` falls back to the built-in `wn` mark. */
28
+ faviconUrl: string | null
26
29
  entries: WindoManifestEntry[]
27
30
  groups: WindoGroup[]
28
31
  tags: string[]
29
32
  contexts: WindoContextMeta[]
33
+ /** Initial shared state from the config — seeds the chrome's editable strip. */
34
+ ctxState: Record<string, unknown>
30
35
  }
31
36
  | {
32
37
  source: typeof WINDO_MSG
@@ -43,6 +48,8 @@ export type WindoPreviewMessage =
43
48
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'parse-error'; id: string; errors: WindoFieldError[] }
44
49
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'log'; entry: WindoLogEntry }
45
50
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'state'; id: string; state: Record<string, unknown>; actions: { id: string; disabled: boolean }[] }
51
+ | { source: typeof WINDO_MSG; dir: 'preview'; type: 'ctx-state'; state: Record<string, unknown> }
52
+ | { source: typeof WINDO_MSG; dir: 'preview'; type: 'color-scheme'; colorScheme: 'light' | 'dark' }
46
53
  | { source: typeof WINDO_MSG; dir: 'preview'; type: 'render-error'; id: string; message: string; stack?: string }
47
54
 
48
55
  export type WindoMessage = WindoHostMessage | WindoPreviewMessage