lecodes-cli 0.8.2 → 0.9.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lecodes-cli",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Clone, edit and push LeCodes projects from your machine.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,11 +30,11 @@
30
30
  "prepublishOnly": "bun run sync-peers && bun run build"
31
31
  },
32
32
  "dependencies": {
33
- "@letary/chisel": "^0.4.1"
33
+ "@letary/chisel": "^0.4.2"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "lecodes-design": "^0.3.2",
37
- "lecodes-renderer": "^0.6.2",
37
+ "lecodes-renderer": "^0.7.0",
38
38
  "lecodes-3d-editor": "^0.1.0"
39
39
  },
40
40
  "peerDependenciesMeta": {
@@ -54,7 +54,7 @@ export { Texture2D } from "./g2/Texture2D"
54
54
 
55
55
  // ---- UI (creator-ui) ----
56
56
  export {
57
- UIScreen, type UIScreenStyle, type UIScrollableScreen,
57
+ UIScreen, type UIScreenStyle,
58
58
  UIRow, UIColumn, UIBox, type UIContainerStyle,
59
59
  UIText, type UITextStyle,
60
60
  UIButton, type UIButtonStyle,
@@ -1,7 +1,7 @@
1
1
  // Renderer-side contract + per-renderer status (DOM / canvas-ui / desktop) for every component
2
2
  // below is tracked in packages/canvas-ui/COMPONENTS.md.
3
3
  export { UIButton, type UIButtonStyle } from './UIButton'
4
- export { UIScreen, type UIScreenStyle, type UIScrollableScreen } from './UIScreen'
4
+ export { UIScreen, type UIScreenStyle } from './UIScreen'
5
5
  export { UISpacer, type UISpacerStyle } from './UISpacer'
6
6
  export { UIText, type UITextStyle } from './UIText'
7
7
  export { registerFont } from './fonts'
@@ -68,11 +68,6 @@ export type ContainerStyle = {
68
68
  flexWrap?: "nowrap" | "wrap" | "wrap-reverse"
69
69
  }
70
70
 
71
- export type ScrollStyle = {
72
- scrollX?: "auto" | "all" | "none",
73
- scrollY?: "auto" | "all" | "none"
74
- }
75
-
76
71
  type UIValue = number |
77
72
  `${number}px` |
78
73
  `${number}vw` |
@@ -130,6 +125,8 @@ export type PositionStyle = {
130
125
  left?: UIValue | `${number}%` | 'safe-left' | 'comfort-left',
131
126
  bottom?: UIValue | `${number}%` | 'safe-bottom' | 'comfort-bottom',
132
127
  right?: UIValue | `${number}%` | 'safe-right' | 'comfort-right',
128
+ /** Shorthand for all four edges at one value. A longhand (`top`, `left`, …) overrides it. */
129
+ inset?: UIValue | `${number}%` | "auto",
133
130
  width?: UIValue | `${number}%` | "auto",
134
131
  height?: UIValue | `${number}%` | "auto",
135
132
  maxWidth?: UIValue | `${number}%` | "auto",
@@ -158,7 +155,11 @@ export type TextStyle = {
158
155
  fontStyle?: "normal" | "italic",
159
156
  color?: Color,
160
157
  textDecoration?: "underline" | "line-through" | "none",
161
- letterSpacing?: UIValue
158
+ letterSpacing?: UIValue,
159
+ /** Cap the text at N lines; the box is measured to the clamped height. 0 / omitted = unbounded. */
160
+ lineClamp?: number,
161
+ /** How a clamped line ends. Only meaningful together with `lineClamp`. Default "ellipsis". */
162
+ textOverflow?: "ellipsis" | "clip"
162
163
  }
163
164
 
164
165
  // Pulls function-valued props out of `style` (mutating it — they must never reach the host bridge)
@@ -1,4 +1,5 @@
1
- import { ContainerElement, type AnimateStyle, type BaseStyle, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style } from "./UINode"
1
+ import { ContainerElement, type AnimateStyle, type BaseStyle, type ChildrenFn, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type Style, type UINodeChild } from "./UINode"
2
+ import { disposeBinding } from "../core/signals"
2
3
  import type { UIScreen } from "./UIScreen"
3
4
 
4
5
  // UIPager — the system's ONE screen-navigation element (docs/pager-component-plan.md): sibling
@@ -46,6 +47,11 @@ export interface UIPager {
46
47
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>,
47
48
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>,
48
49
 
50
+ /** Replace the pager's tabs — one screen per tab, in order. Every tab becomes a fresh
51
+ * one-screen stack (pushed screens are dropped) and the selection is clamped into range;
52
+ * screens that were already tabs keep their state. Pass a function for reactive tabs. */
53
+ setContent(tabs: UIScreen[] | (() => UIScreen[])): this,
54
+
49
55
  /** Switch to tab `index`. **Instant by default** — the tab-bar convention (tabs are parallel
50
56
  * modes, not neighbors); pass `animated: true` for the direction-aware slide of top-tab /
51
57
  * pager UIs. Out-of-range and same-index calls are no-ops. Tabs keep their stacks: selecting
@@ -182,6 +188,54 @@ export class PagerElement extends ContainerElement<"pager"> {
182
188
  return this
183
189
  }
184
190
 
191
+ // ── Children ARE the tabs ──────────────────────────────────────────────────────────────────
192
+ // Every inherited container mutation would send a generic setContent/insertNode/removeNode wire
193
+ // op, and a host must never run those on a pager: its children are page ROOTS the host owns
194
+ // (each laid out independently at the pager's box), not yoga children — the generic path frees
195
+ // the live pages and rebuilds them as ordinary children stacked on the first tab. So each one is
196
+ // overridden to mutate the element first (children, `_lens`, `_index`) and then notify over the
197
+ // same command channel every other pager op uses, where the host reconciles against the node.
198
+ //
199
+ // Content set this way is always one screen per tab: a per-tab STACK only ever grows through
200
+ // push/replace, so `_lens` resets to all-ones here.
201
+ private _rebuild(children: any[]): this {
202
+ const tabs = children.filter(Boolean)
203
+ const prevIndex = this._index
204
+ this.children.length = 0
205
+ this.children.push(...tabs)
206
+ this._lens = tabs.map(() => 1)
207
+ if (this._index >= tabs.length) this._index = Math.max(0, tabs.length - 1)
208
+ for (const s of tabs) this._track(s)
209
+ if (this._id !== 0) _creatorUI.command(this, "content")
210
+ if (this._index !== prevIndex) this._emitSelect()
211
+ this._emitChange()
212
+ return this
213
+ }
214
+
215
+ setContent(children: UINodeChild[] | ChildrenFn): this {
216
+ if (typeof children === "function") {
217
+ this._bindChildren(children) // reactive children land in _reconcile below
218
+ return this
219
+ }
220
+ disposeBinding(this, "#children") // static content overrides a reactive binding
221
+ return this._rebuild(children as any[])
222
+ }
223
+ append(...nodes: UINodeChild[]): this {
224
+ return this._rebuild([...this.children, ...nodes])
225
+ }
226
+ insert(index: number, ...nodes: UINodeChild[]): this {
227
+ const next = this.children.slice()
228
+ next.splice(index, 0, ...(nodes as any[]))
229
+ return this._rebuild(next)
230
+ }
231
+ remove(...nodesToDelete: UIScreen[]): this {
232
+ const drop = new Set<any>(nodesToDelete)
233
+ return this._rebuild(this.children.filter((c: any) => !drop.has(c)))
234
+ }
235
+ _reconcile(next: UINodeChild[]): void {
236
+ this._rebuild(next as any[])
237
+ }
238
+
185
239
  // Host-driven tab change (committed swipe): the native pager already moved — sync the logical
186
240
  // index + fire onSelect. Named `_*` so chisel keeps it (hosts call it).
187
241
  _hostDidSelect(index: number): void {
@@ -214,6 +268,7 @@ export class PagerElement extends ContainerElement<"pager"> {
214
268
  // top, this pager is active. The hook reads screen._pagerOwner LIVE (not a captured `this`), so
215
269
  // re-homing a screen into another pager activates the right one.
216
270
  private _track(screen: any): void {
271
+ console.log("_track()")
217
272
  screen._pagerOwner = this
218
273
  if (!screen._pagerHooked) {
219
274
  screen._pagerHooked = true
@@ -222,13 +277,18 @@ export class PagerElement extends ContainerElement<"pager"> {
222
277
  }
223
278
  }
224
279
  _activate(): void {
225
- if (++this._openCount === 1 && _activeStack.indexOf(this) < 0) _activeStack.push(this)
280
+ console.log("openCount: " + this._openCount)
281
+ if (++this._openCount === 1 && _activeStack.indexOf(this) < 0) {
282
+ console.log("activate")
283
+ _activeStack.push(this)
284
+ }
226
285
  }
227
286
  _deactivate(): void {
228
287
  if (--this._openCount <= 0) {
229
288
  this._openCount = 0
230
289
  const i = _activeStack.indexOf(this)
231
290
  if (i >= 0) _activeStack.splice(i, 1)
291
+ console.log(`Deactivate. Curent stack is ${_activeStack.length}`)
232
292
  }
233
293
  }
234
294
  }
@@ -275,7 +335,7 @@ function pagerFactory(...args: any[]): UIPager {
275
335
  * tabs, a single screen becomes a plain stack. Navigate within it via `pager.push/pop/replace`
276
336
  * (per-tab) and `pager.select(i)` (between tabs), or from any child screen via the ambient
277
337
  * `UIPager.push` / `UIPager.pop` / `UIPager.current`. Pages are full `UIScreen`s (own
278
- * `onOpen`/`onClose`, `makeScrollable()`); give the pager `flexGrow: 1` or a size so it doesn't
338
+ * `onOpen`/`onClose`); give the pager `flexGrow: 1` or a size so it doesn't
279
339
  * collapse. `Router` stays the way to open a destination ON TOP of the pager.
280
340
  */
281
341
  export const UIPager = pagerFactory as unknown as UIPagerConstructor
@@ -289,6 +349,14 @@ const _resolveCurrent = (): UIPager | null => {
289
349
  return cur as unknown as UIPager
290
350
  }
291
351
 
352
+ // The ambient statics. These are top-level statements in a `sideEffects: false` module, so they only
353
+ // survive bundling because chisel treats a statement that *mutates a binding this module declares*
354
+ // as part of that binding's declaration (`ItemKind::AttachedTo`, chisel >= 0.4.2) — kept iff
355
+ // `UIPager` is reached, shaken away with it otherwise. Under chisel <= 0.4.1 they were dropped
356
+ // outright and `UIPager.push` arrived as `undefined` at runtime, in bundled builds only; don't lower
357
+ // the pinned `@letary/chisel` back past 0.4.2. Covered by sdk/tests/compile.test.ts.
358
+ // `Object.defineProperty` for `current`, not `Object.assign({ get current() {} })` — Object.assign
359
+ // *invokes* the getter and copies the resulting value, freezing it at null forever.
292
360
  Object.defineProperty(UIPager, "current", {
293
361
  get: () => (_activeStack[_activeStack.length - 1] ?? null) as unknown as UIPager | null,
294
362
  })
@@ -1,22 +1,16 @@
1
1
  import { TouchStartEvent } from "../runtime/touch"
2
2
  import { _bumpNavEpoch, _navSupported, _setCurrent, Presentable, type PresentOptions } from "./presentable"
3
- import { ContainerElement, type AnimateStyle, type BaseStyle, type Color, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type ScrollStyle, type Style, type UINodeChild, type ChildrenFn, } from "./UINode"
3
+ import { ContainerElement, type AnimateStyle, type BaseStyle, type ContainerStyle, type DrawableStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Style, type UINodeChild, type ChildrenFn, } from "./UINode"
4
4
 
5
- export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & ScrollStyle & BaseStyle & ScrollStyle & { refreshControlColor?: Color }
5
+ export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & BaseStyle
6
6
 
7
- // A screen — the root the router/host mounts, sized to its slot. Two flavours share one contract: a
8
- // plain `UIScreen`, and a `UIScrollableScreen` (a screen that also scrolls). `makeScrollable()`
9
- // upgrades a plain screen to the scrollable one, where `onScroll`/`onOverscroll`/`onRefresh` live —
10
- // they mean nothing without scrolling. `UIScrollableScreen extends UIScreen`, so anywhere a
11
- // `UIScreen` is accepted (router pages, `UIPager`), a scrollable one works too.
12
- //
13
- // This is a subtype split, NOT a `UIScreen<Scrollable>` generic: a generic can't work while `style`
14
- // is `Style<this,…>`, because `this` is then contravariant and the type becomes invariant in its own
15
- // params — that was the old `UIScreen<false>`-not-assignable-to-`UIScreen<boolean>` bug. See the StyleFn
16
- // note in UINode.ts; the contract is pinned by tests/ui-types.test.ts.
7
+ // A screen — the root the router/host mounts, sized to its slot. A screen NEVER scrolls: it is
8
+ // fixed chrome (header, tab bar) plus, when the content overflows, one `UIScrollable` body child
9
+ // (`flexGrow: 1`). Scroll events and pull-to-refresh live on the scroll containers
10
+ // (`UIScrollable`, `UIVirtualizedList`) — see docs/scroll-forms-plan.md (the former
11
+ // `makeScrollable()` screen mode was removed 2026-07-22).
17
12
  export interface UIScreen {
18
13
  readonly type: "screen",
19
- readonly scrollable: boolean,
20
14
  style: Style<this, UIScreenStyle>,
21
15
  animateTo: AnimateStyle<this, DrawableStyle & BaseStyle>
22
16
  animateFrom: AnimateStyle<this, DrawableStyle & BaseStyle>
@@ -33,8 +27,6 @@ export interface UIScreen {
33
27
 
34
28
  readonly children: UINodeChild[],
35
29
 
36
- /** Upgrade this screen into a scroll view, unlocking `onScroll`/`onOverscroll`/`onRefresh`. */
37
- makeScrollable(): UIScrollableScreen,
38
30
  onBackPressed(callback: () => void): this
39
31
 
40
32
  onOpen(callback: () => void): this
@@ -62,25 +54,7 @@ export interface UIScreen {
62
54
  onTouchStart(callback: (ev: TouchStartEvent<UIScreen>) => void): this,
63
55
  }
64
56
 
65
- /**
66
- * A `UIScreen` in scroll mode — what `makeScrollable()` returns. Adds the scroll lifecycle that a
67
- * plain screen doesn't carry. A subtype of `UIScreen`, so it's accepted anywhere a screen is
68
- * (router pages, `UIPager`).
69
- */
70
- export interface UIScrollableScreen extends UIScreen {
71
- readonly scrollable: true,
72
- /** Fires as the content scrolls; the offset is logical px from the start edge. Chainable. */
73
- onScroll(callback: (scrollPosition: number) => void): this,
74
- /** Fires while the user drags past an edge (rubber-band). Chainable. */
75
- onOverscroll(callback: (scrollPosition: number) => void): this,
76
- /** Pull-to-refresh: the spinner stays until the returned promise resolves. Chainable. */
77
- onRefresh(callback: () => void | Promise<void>): this,
78
- /** Already scrollable — returns itself. */
79
- makeScrollable(): UIScrollableScreen,
80
- }
81
-
82
57
  export class ScreenElement extends ContainerElement<"screen"> {
83
- scrollable = false
84
58
  open(options?: PresentOptions): void {
85
59
  _bumpNavEpoch()
86
60
  if (_navSupported()) {
@@ -140,35 +114,11 @@ export class ScreenElement extends ContainerElement<"screen"> {
140
114
  }
141
115
  return null
142
116
  }
143
- makeScrollable(): UIScrollableScreen {
144
- this.scrollable = true
145
-
146
- Object.assign(this, {
147
- sl: [] as any[],
148
- osl: [] as any[],
149
- onScroll(callback: any) {
150
- this.sl.push(callback)
151
- return this
152
- },
153
- onOverscroll(callback: any) {
154
- this.osl.push(callback)
155
- return this
156
- },
157
- onRefresh(callback: any) {
158
- (this as any)._refreshCallback = callback
159
- return this
160
- }
161
- })
162
-
163
- return this as any as UIScrollableScreen
164
- }
165
117
  _backButtonCallback?: () => void
166
118
  onBackPressed(callback: any): this {
167
119
  this._backButtonCallback = callback
168
120
  return this
169
121
  }
170
- // Set by the onRefresh method that makeScrollable() grafts on; read by the host at mount.
171
- declare _refreshCallback: any
172
122
  }
173
123
 
174
124
  export function UIScreen(): UIScreen;
@@ -1,7 +1,8 @@
1
1
  import type { UIContainerStyle } from "./UIContainer"
2
- import { type Style, type AnimateStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, ContainerElement } from "./UINode"
2
+ import { type Style, type AnimateStyle, type UINodeChild, type ChildrenFn, type OnLayoutCallback, type BoundingClientRect, type Color, ContainerElement } from "./UINode"
3
3
 
4
- export type ScrollStyle = { showScrollbar?: boolean, overscrollMode?: "none" | "absorb" | "default" }
4
+ // refreshControlColor tints the native pull-to-refresh spinner (pairs with onRefresh).
5
+ export type ScrollStyle = { showScrollbar?: boolean, overscrollMode?: "none" | "absorb" | "default", refreshControlColor?: Color }
5
6
 
6
7
  export type UIScrollableStyle = UIContainerStyle & ScrollStyle & { scrollDirection?: "horizontal" | "vertical" }
7
8
 
@@ -27,6 +28,9 @@ export interface UIScrollable {
27
28
  onScroll(callback: (scrollPosition: number) => void): UIScrollable,
28
29
  onScrollRelease(callback: () => void): UIScrollable,
29
30
  onOverscroll(callback: (delta: number) => void): UIScrollable
31
+ /** Pull-to-refresh: the spinner stays until the returned promise settles. Attach BEFORE the
32
+ * node mounts (hosts read it at creation). Vertical only; native hosts — no-op on web. */
33
+ onRefresh(callback: () => void | Promise<void>): UIScrollable
30
34
 
31
35
  readonly children: UINodeChild[]
32
36
  }
@@ -55,6 +59,13 @@ class ScrollableElement extends ContainerElement<"scrollable"> {
55
59
  this.osl.push(callback)
56
60
  return this
57
61
  }
62
+
63
+ // Read by the host at mount (same contract the scrollable screen had before 2026-07-22).
64
+ declare _refreshCallback: any
65
+ onRefresh (callback: any): this {
66
+ this._refreshCallback = callback
67
+ return this
68
+ }
58
69
  }
59
70
 
60
71
  export function UIScrollable(): UIScrollable
@@ -62,15 +73,17 @@ export function UIScrollable(children: UINodeChild[] | ChildrenFn): UIScrollable
62
73
  export function UIScrollable(style: UIScrollableStyle): UIScrollable
63
74
  export function UIScrollable(style: UIScrollableStyle, children: UINodeChild[]): UIScrollable
64
75
  export function UIScrollable(...args: [] | [UINodeChild[] | ChildrenFn | UIScrollableStyle] | [UIScrollableStyle, children: UINodeChild[] | ChildrenFn]): UIScrollable {
76
+ // flexShrink: 1 default (SDK-owned, like UIRow's flexDirection): without it a scrollable in a
77
+ // flex parent grows to its content height and overflows instead of scrolling.
65
78
  if (args.length === 0) {
66
- return new ScrollableElement("scrollable", {}, [])
79
+ return new ScrollableElement("scrollable", { flexShrink: 1 }, [])
67
80
  } else if (args.length === 1) {
68
81
  if (Array.isArray(args[0]) || typeof args[0] === "function") {
69
- return new ScrollableElement("scrollable", {}, args[0])
82
+ return new ScrollableElement("scrollable", { flexShrink: 1 }, args[0])
70
83
  } else {
71
- return new ScrollableElement("scrollable", args[0], [])
84
+ return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, [])
72
85
  }
73
86
  } else {
74
- return new ScrollableElement("scrollable", args[0], args[1])
87
+ return new ScrollableElement("scrollable", { flexShrink: 1, ...args[0] }, args[1])
75
88
  }
76
89
  }
@@ -1,6 +1,7 @@
1
- import { Element, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Style, type UINodeChild } from "./UINode"
1
+ import { Element, type Color, type DrawableStyle, type ElementStyle, type OnLayoutCallback, type BoundingClientRect, type PaddingStyle, type Style, type UINodeChild } from "./UINode"
2
2
 
3
- export type UIVirtualizedListStyle = DrawableStyle & ElementStyle
3
+ // refreshControlColor tints the native pull-to-refresh spinner (pairs with onRefresh).
4
+ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & { refreshControlColor?: Color }
4
5
 
5
6
  export interface VListConfig<T> {
6
7
  /** Стабильный уникальный ключ айтема */
@@ -40,6 +41,9 @@ export interface UIVirtualizedList<T = unknown> {
40
41
  scrollToEnd(animated?: boolean): void
41
42
 
42
43
  onScroll(callback: (scrollPosition: number) => void): this
44
+ /** Pull-to-refresh: спиннер держится, пока не разрешится промис. Навесить ДО маунта
45
+ * (нативка читает при создании ноды). Только native-хосты; на вебе no-op. */
46
+ onRefresh(callback: () => void | Promise<void>): this
43
47
  /** Порог в px от нижнего края контента. Срабатывает один раз до ухода от края (защёлка в нативке) */
44
48
  onEndReached(thresholdPx: number, callback: () => void): this
45
49
  /** Порог от верхнего края — подгрузка истории в чате */
@@ -191,6 +195,12 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
191
195
  this.sl.push(callback)
192
196
  return this
193
197
  }
198
+ // Читается нативкой при маунте (тот же контракт, что у UIScrollable).
199
+ declare _refreshCallback: any
200
+ onRefresh(callback: () => void | Promise<void>): this {
201
+ this._refreshCallback = callback
202
+ return this
203
+ }
194
204
  onEndReached(thresholdPx: number, callback: () => void): this {
195
205
  this._endThreshold = thresholdPx
196
206
  this.erl.push(callback)
@@ -49,7 +49,8 @@ export type Transition = TransitionName | TransitionSpec
49
49
 
50
50
  export type PresentOptions = {
51
51
  /** Transition to play while this destination replaces the current one (default "none" for a
52
- * direct open(); the Router applies its own defaults — push/fade/pop). */
52
+ * direct open(); the Router applies its own defaults — push/pop, and the theme's
53
+ * `replaceTransition` for replace). */
53
54
  transition?: Transition
54
55
  }
55
56
 
@@ -1,11 +1,13 @@
1
1
  import { _bumpNavEpoch, _descOf, _navSupported, _setCurrent, Presentable, type Transition } from "./presentable"
2
+ import { _navDefaults } from "./theme"
2
3
 
3
4
  type RouterOptions = {
4
5
  showDefaultBackButton?: boolean
5
6
  }
6
7
 
7
8
  type NavigateOptions = {
8
- /** Transition to play. Defaults: `"push"` for push, `"fade"` for replace, `"pop"` for pop. */
9
+ /** Transition to play. Defaults: `"push"` for push, `"pop"` for pop; replace follows the
10
+ * theme's `replaceTransition` (`"none"` out of the box). */
9
11
  transition?: Transition
10
12
  }
11
13
 
@@ -74,7 +76,7 @@ export const Router: Router = /*#__PURE__*/ Object.freeze({
74
76
  const desc = wireDesc(page)
75
77
  const go = () => {
76
78
  _bumpNavEpoch()
77
- _creatorUI.routerReplace(desc, opts?.transition ?? "fade")
79
+ _creatorUI.routerReplace(desc, opts?.transition ?? _navDefaults.replace)
78
80
  _setCurrent(page) // optimistic — host onChange confirms (async on native hosts)
79
81
  }
80
82
  const prep = page._prepare?.()
@@ -20,30 +20,57 @@
20
20
  // numbers are LENGTHS (px). `null` removes a key (comfort knobs reset to their defaults:
21
21
  // 12 vertical, 16 horizontal — the page gutter). Env names (safe-*, vw/vh/vmin/vmax) are
22
22
  // host-owned facts, never theme keys — they're skipped with a warning.
23
+ //
24
+ // One key is not a style var: `replaceTransition` — the app-wide default for
25
+ // `Router.replace` (any Transition, name or custom spec; `null` resets to "none"). The
26
+ // router resolves it in the SDK, so it's consumed here and never sent to the host table.
27
+
28
+ import type { Transition } from "./presentable"
23
29
 
24
30
  export type ThemeValues = Record<string, string | number | null>
25
31
 
26
- export type ThemeAccessors<T extends ThemeValues> = {
32
+ /** @internal Navigation defaults the theme owns; the router reads them at call time.
33
+ * (SDK-side state, deliberately NOT in the host var table — `var(--replaceTransition)`
34
+ * is not a style value.) */
35
+ export const _navDefaults: { replace: Transition } = { replace: "none" }
36
+
37
+ // (Unconstrained: it also maps inputs whose `replaceTransition` key was carved out.)
38
+ export type ThemeAccessors<T> = {
27
39
  readonly [K in keyof T]: K extends string ? `var(--${K})` : never
28
40
  }
29
41
 
30
42
  const RESERVED = ["safe-top", "safe-bottom", "safe-left", "safe-right", "vw", "vh", "vmin", "vmax"]
31
43
 
32
- const setTheme = <T extends ThemeValues>(values: T): ThemeAccessors<T> => {
44
+ /** Merge app theme variables and get typed `var(--key)` accessors back. Strings/numbers are
45
+ * style values (numbers = px); `null` removes a key. One special key: `replaceTransition` —
46
+ * the app-wide default transition for `Router.replace` (any transition name or custom spec;
47
+ * out of the box `"none"`). */
48
+ const setTheme = <T extends Record<string, string | number | Transition | null>>(
49
+ // Only `replaceTransition` may carry a Transition — every other key is a style value.
50
+ values: T & { [K in Exclude<keyof T, "replaceTransition">]: string | number | null }
51
+ ): ThemeAccessors<Omit<T, "replaceTransition">> => {
33
52
  const accessors: Record<string, string> = {}
34
- let send: ThemeValues = values
53
+ let send = values as ThemeValues
54
+ const strip = (key: string) => {
55
+ if (send === values) send = { ...values } as ThemeValues
56
+ delete send[key]
57
+ }
35
58
  for (const key of Object.keys(values)) {
59
+ if (key === "replaceTransition") {
60
+ _navDefaults.replace = (values[key] ?? "none") as Transition
61
+ strip(key) // SDK-consumed, no accessor: it isn't a style var
62
+ continue
63
+ }
36
64
  if (RESERVED.includes(key)) {
37
65
  console.warn(`theme(): "${key}" is an environment value (host-owned) — ignored`)
38
- if (send === values) send = { ...values }
39
- delete send[key]
66
+ strip(key)
40
67
  continue
41
68
  }
42
69
  accessors[key] = `var(--${key})`
43
70
  }
44
71
  // Optional during rollout: hosts without the method simply don't theme (values still type).
45
- _creatorUI.setTheme?.(send)
46
- return accessors as ThemeAccessors<T>
72
+ if (Object.keys(send).length > 0) _creatorUI.setTheme?.(send)
73
+ return accessors as ThemeAccessors<Omit<T, "replaceTransition">>
47
74
  }
48
75
 
49
76
  export const theme = Object.assign(setTheme, {