lecodes-sdk 2.5.0 → 2.6.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
@@ -43,7 +43,7 @@
43
43
  "marcidb-embedded": "^0.15.0",
44
44
  "sharp": "^0.35.2"
45
45
  },
46
- "version": "2.5.0",
46
+ "version": "2.6.0",
47
47
  "files": [
48
48
  "src",
49
49
  "dist",
@@ -58,6 +58,9 @@ declare global {
58
58
  stopVR(): void
59
59
  addEntityToScene(sceneId: u32, entityId: u32): void
60
60
  removeEntityFromScene(sceneId: u32, entityId: u32): void
61
+ /** The renderables the scene holds right now (Filament's Scene::getRenderableCount): a destroyed subtree must take
62
+ * its own back out — checks/owned asserts it. Optional: a host without it leaves the method absent. */
63
+ sceneRenderableCount?(sceneId: u32): u32
61
64
  /** @custom */
62
65
  warmRender(sceneId: u32, onComplete: () => void): void
63
66
  /** Compile every shader variant the scene's materials can need — sun / shadows / fog / skinning
@@ -852,7 +855,7 @@ declare global {
852
855
  foliageSetLeaf?(shiver: f32, translucency: f32, leafShadow: f32, skyAo: f32): void
853
856
  /** The look of the cards: coverage = alpha × (1 + coverage × mip level) so a distant crown keeps its density as its
854
857
  * atlas mips away. */
855
- foliageSetLook?(coverage: f32): void
858
+ foliageSetLook?(coverage: f32, skyThrough: f32): void
856
859
  /** An entity whose live world position bends the vegetation within `radius` metres; <= 0 removes it. The host
857
860
  * writes the 8 benders nearest the camera to the shader each frame. */
858
861
  foliageSetBender?(entityId: u32, radius: f32): void
package/src/chisel.ts CHANGED
@@ -128,7 +128,7 @@ export const chiselVocab: ChiselVocab = {
128
128
  "style", "class", "named", "theme",
129
129
  "onClick", "onTouchStart", "onLongPress", "onMouseEnter", "onLayout", "onOpen", "onClose", "onBack",
130
130
  "onScroll", "onScrollRelease", "onOverscroll", "onRefresh", "onSubmit",
131
- "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent",
131
+ "onSelect", "onChange", "onFocus", "onBlur", "onOverlayTap", "onDetent", "onDismiss", "bindShow",
132
132
  "onEndReached", "onStartReached",
133
133
  "append", "insert", "remove", "setContent", "widgets", "keepAlive", "url",
134
134
  "detents", "dismissible", "transition", "setSourceRect",
@@ -112,17 +112,22 @@ export const localAssetBase = (path: string): string => path.split(/[\\/]/).pop(
112
112
  * order, i.e. on nothing. (2026-09-17: a viewer project carried two asset packs that each ship
113
113
  * `SM_Sign_01..03.glb`; the school's floor-standing "CLEANING IN PROGRESS" A-frame was drawn inside
114
114
  * the shooting range's wall, where a flat 4 mm plate belonged.)
115
+ *
116
+ * SHARING IS CASE-BLIND: the staging directory lives on Windows / macOS file systems, where `Tire.glb`
117
+ * and `tire.glb` are one file. Compared by exact name both were "unique", staged into the same slot,
118
+ * and the second copy went THROUGH the first one's hard link into the project — lightcheck's Chernobyl
119
+ * `Tire.glb` became the yard's `props/tire.glb` on disk (2026-11-04).
115
120
  */
116
121
  export const localAssetNames = (entries: readonly { path: string, type: string }[]): Map<string, string> => {
117
122
  const seen = new Map<string, number>()
118
123
  for (const e of entries) if (e.type === "resource") {
119
- const b = localAssetBase(e.path)
124
+ const b = localAssetBase(e.path).toLowerCase()
120
125
  seen.set(b, (seen.get(b) ?? 0) + 1)
121
126
  }
122
127
  const names = new Map<string, string>()
123
128
  for (const e of entries) if (e.type === "resource") {
124
129
  const b = localAssetBase(e.path)
125
- names.set(e.path, (seen.get(b) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
130
+ names.set(e.path, (seen.get(b.toLowerCase()) ?? 0) > 1 ? e.path.replace(/^[./]+/, "").replace(/[\\/]/g, "__") : b)
126
131
  }
127
132
  return names
128
133
  }
package/src/gl/Foliage.ts CHANGED
@@ -64,6 +64,12 @@ export type FoliageOptions = {
64
64
  /** Coverage held over distance: the cards' alpha × (1 + coverage × mip level), so a far crown does not thin out as its
65
65
  * atlas mips away. Default 0.25, 0 = none; past ~0.5 a crown turns into a solid mass. */
66
66
  coverage?: number
67
+ /** 0..1: THE SKY THROUGH A THIN LEAF. A crown's cards are lit by the sky on their hull normal (out of the crown's centre),
68
+ * so a card below the crown's middle faces DOWN and reads the sky's lower hemisphere — the ground of the environment
69
+ * map, which under an overcast sky is a tenth of the zenith: the lower half of every plant went black (2026-11-04). A
70
+ * leaf is thin: this share of the sky on the OTHER side of the card is added, so the underside of a crown takes this
71
+ * fraction of what its top takes. Default 0.3, 0 = the hull alone. */
72
+ skyThrough?: number
67
73
  /** THE PLANTS' CASTER DISTANCE, metres: a plant casts the sun's shadow only this close to the camera (the LOD pass takes
68
74
  * the farther ones out of the shadow map; 3 m of hysteresis). Meant for a level with a BAKED ground, which keeps the
69
75
  * far shadows while the shadow map holds a fraction of the casters (the Forest's Radeon frame: 6 ms of 39 at 25 m).
@@ -83,6 +89,7 @@ const state: Required<Omit<FoliageOptions, "wind">> & { wind: Required<FoliageWi
83
89
  leafShadow: 0.6,
84
90
  skyAo: 0.7,
85
91
  coverage: 0.25,
92
+ skyThrough: 0.3,
86
93
  castDistance: 0,
87
94
  }
88
95
 
@@ -108,7 +115,10 @@ export const Foliage = {
108
115
  if (options.leafShadow !== undefined) { state.leafShadow = options.leafShadow; leaf = true }
109
116
  if (options.skyAo !== undefined) { state.skyAo = options.skyAo; leaf = true }
110
117
  if (leaf) _creator.foliageSetLeaf?.(state.shiver, state.translucency, state.leafShadow, state.skyAo)
111
- if (options.coverage !== undefined) { state.coverage = options.coverage; _creator.foliageSetLook?.(state.coverage) }
118
+ let look = false
119
+ if (options.coverage !== undefined) { state.coverage = options.coverage; look = true }
120
+ if (options.skyThrough !== undefined) { state.skyThrough = options.skyThrough; look = true }
121
+ if (look) _creator.foliageSetLook?.(state.coverage, state.skyThrough)
112
122
  if (options.castDistance !== undefined) { state.castDistance = options.castDistance; _creator.foliageSetCast?.(state.castDistance) }
113
123
  },
114
124
 
@@ -0,0 +1,81 @@
1
+ Russian Trusted CA (the Ministry of Digital Development, Минцифры): what a server's fetch needs to reach
2
+ Russian services behind it — GigaChat (ngw.devices.sberbank.ru, gigachat.devices.sberbank.ru), Gosuslugi, Sber.
3
+ Source: https://gu-st.ru/content/Other/doc/russian_trusted_root_ca.cer + russian_trusted_sub_ca.cer (2026-11-03).
4
+ Root: CN=Russian Trusted Root CA, until 2032-02-27, sha256 D2:6D:2D:02:31:B7:C3:9F:92:CC:73:85:12:BA:54:10:35:19:E4:40:5D:68:B5:BD:70:3E:97:88:CA:8E:CF:31
5
+ Sub: CN=Russian Trusted Sub CA, until 2027-03-06, sha256 BB:BD:E2:10:3E:79:0B:99:9E:C6:2B:D0:3C:F6:25:A5:A2:E7:C3:16:E1:0A:FE:6A:49:0E:ED:EA:D8:B3:FD:9B
6
+ Loaded through NODE_EXTRA_CA_CERTS (src/server/trustedCa.ts): added to the defaults, never replacing them.
7
+
8
+ -----BEGIN CERTIFICATE-----
9
+ MIIFwjCCA6qgAwIBAgICEAAwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
10
+ PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
11
+ ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
12
+ Q0EwHhcNMjIwMzAxMjEwNDE1WhcNMzIwMjI3MjEwNDE1WjBwMQswCQYDVQQGEwJS
13
+ VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
14
+ YW5kIENvbW11bmljYXRpb25zMSAwHgYDVQQDDBdSdXNzaWFuIFRydXN0ZWQgUm9v
15
+ dCBDQTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAMfFOZ8pUAL3+r2n
16
+ qqE0Zp52selXsKGFYoG0GM5bwz1bSFtCt+AZQMhkWQheI3poZAToYJu69pHLKS6Q
17
+ XBiwBC1cvzYmUYKMYZC7jE5YhEU2bSL0mX7NaMxMDmH2/NwuOVRj8OImVa5s1F4U
18
+ zn4Kv3PFlDBjjSjXKVY9kmjUBsXQrIHeaqmUIsPIlNWUnimXS0I0abExqkbdrXbX
19
+ YwCOXhOO2pDUx3ckmJlCMUGacUTnylyQW2VsJIyIGA8V0xzdaeUXg0VZ6ZmNUr5Y
20
+ Ber/EAOLPb8NYpsAhJe2mXjMB/J9HNsoFMBFJ0lLOT/+dQvjbdRZoOT8eqJpWnVD
21
+ U+QL/qEZnz57N88OWM3rabJkRNdU/Z7x5SFIM9FrqtN8xewsiBWBI0K6XFuOBOTD
22
+ 4V08o4TzJ8+Ccq5XlCUW2L48pZNCYuBDfBh7FxkB7qDgGDiaftEkZZfApRg2E+M9
23
+ G8wkNKTPLDc4wH0FDTijhgxR3Y4PiS1HL2Zhw7bD3CbslmEGgfnnZojNkJtcLeBH
24
+ BLa52/dSwNU4WWLubaYSiAmA9IUMX1/RpfpxOxd4Ykmhz97oFbUaDJFipIggx5sX
25
+ ePAlkTdWnv+RWBxlJwMQ25oEHmRguNYf4Zr/Rxr9cS93Y+mdXIZaBEE0KS2iLRqa
26
+ OiWBki9IMQU4phqPOBAaG7A+eP8PAgMBAAGjZjBkMB0GA1UdDgQWBBTh0YHlzlpf
27
+ BKrS6badZrHF+qwshzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzAS
28
+ BgNVHRMBAf8ECDAGAQH/AgEEMA4GA1UdDwEB/wQEAwIBhjANBgkqhkiG9w0BAQsF
29
+ AAOCAgEAALIY1wkilt/urfEVM5vKzr6utOeDWCUczmWX/RX4ljpRdgF+5fAIS4vH
30
+ tmXkqpSCOVeWUrJV9QvZn6L227ZwuE15cWi8DCDal3Ue90WgAJJZMfTshN4OI8cq
31
+ W9E4EG9wglbEtMnObHlms8F3CHmrw3k6KmUkWGoa+/ENmcVl68u/cMRl1JbW2bM+
32
+ /3A+SAg2c6iPDlehczKx2oa95QW0SkPPWGuNA/CE8CpyANIhu9XFrj3RQ3EqeRcS
33
+ AQQod1RNuHpfETLU/A2gMmvn/w/sx7TB3W5BPs6rprOA37tutPq9u6FTZOcG1Oqj
34
+ C/B7yTqgI7rbyvox7DEXoX7rIiEqyNNUguTk/u3SZ4VXE2kmxdmSh3TQvybfbnXV
35
+ 4JbCZVaqiZraqc7oZMnRoWrXRG3ztbnbes/9qhRGI7PqXqeKJBztxRTEVj8ONs1d
36
+ WN5szTwaPIvhkhO3CO5ErU2rVdUr89wKpNXbBODFKRtgxUT70YpmJ46VVaqdAhOZ
37
+ D9EUUn4YaeLaS8AjSF/h7UkjOibNc4qVDiPP+rkehFWM66PVnP1Msh93tc+taIfC
38
+ EYVMxjh8zNbFuoc7fzvvrFILLe7ifvEIUqSVIC/AzplM/Jxw7buXFeGP1qVCBEHq
39
+ 391d/9RAfaZ12zkwFsl+IKwE/OZxW8AHa9i1p4GO0YSNuczzEm4=
40
+ -----END CERTIFICATE-----
41
+ -----BEGIN CERTIFICATE-----
42
+ MIIHQjCCBSqgAwIBAgICEAIwDQYJKoZIhvcNAQELBQAwcDELMAkGA1UEBhMCUlUx
43
+ PzA9BgNVBAoMNlRoZSBNaW5pc3RyeSBvZiBEaWdpdGFsIERldmVsb3BtZW50IGFu
44
+ ZCBDb21tdW5pY2F0aW9uczEgMB4GA1UEAwwXUnVzc2lhbiBUcnVzdGVkIFJvb3Qg
45
+ Q0EwHhcNMjIwMzAyMTEyNTE5WhcNMjcwMzA2MTEyNTE5WjBvMQswCQYDVQQGEwJS
46
+ VTE/MD0GA1UECgw2VGhlIE1pbmlzdHJ5IG9mIERpZ2l0YWwgRGV2ZWxvcG1lbnQg
47
+ YW5kIENvbW11bmljYXRpb25zMR8wHQYDVQQDDBZSdXNzaWFuIFRydXN0ZWQgU3Vi
48
+ IENBMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA9YPqBKOk19NFymrE
49
+ wehzrhBEgT2atLezpduB24mQ7CiOa/HVpFCDRZzdxqlh8drku408/tTmWzlNH/br
50
+ HuQhZ/miWKOf35lpKzjyBd6TPM23uAfJvEOQ2/dnKGGJbsUo1/udKSvxQwVHpVv3
51
+ S80OlluKfhWPDEXQpgyFqIzPoxIQTLZ0deirZwMVHarZ5u8HqHetRuAtmO2ZDGQn
52
+ vVOJYAjls+Hiueq7Lj7Oce7CQsTwVZeP+XQx28PAaEZ3y6sQEt6rL06ddpSdoTMp
53
+ BnCqTbxW+eWMyjkIn6t9GBtUV45yB1EkHNnj2Ex4GwCiN9T84QQjKSr+8f0psGrZ
54
+ vPbCbQAwNFJjisLixnjlGPLKa5vOmNwIh/LAyUW5DjpkCx004LPDuqPpFsKXNKpa
55
+ L2Dm6uc0x4Jo5m+gUTVORB6hOSzWnWDj2GWfomLzzyjG81DRGFBpco/O93zecsIN
56
+ 3SL2Ysjpq1zdoS01CMYxie//9zWvYwzI25/OZigtnpCIrcd2j1Y6dMUFQAzAtHE+
57
+ qsXflSL8HIS+IJEFIQobLlYhHkoE3avgNx5jlu+OLYe0dF0Ykx1PGNjbwqvTX37R
58
+ Cn32NMjlotW2QcGEZhDKj+3urZizp5xdTPZitA+aEjZM/Ni71VOdiOP0igbw6asZ
59
+ 2fxdozZ1TnSSYNYvNATwthNmZysCAwEAAaOCAeUwggHhMBIGA1UdEwEB/wQIMAYB
60
+ Af8CAQAwDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTR4XENCy2BTm6KSo9MI7NM
61
+ XqtpCzAfBgNVHSMEGDAWgBTh0YHlzlpfBKrS6badZrHF+qwshzCBxwYIKwYBBQUH
62
+ AQEEgbowgbcwOwYIKwYBBQUHMAKGL2h0dHA6Ly9yb3N0ZWxlY29tLnJ1L2NkcC9y
63
+ b290Y2Ffc3NsX3JzYTIwMjIuY3J0MDsGCCsGAQUFBzAChi9odHRwOi8vY29tcGFu
64
+ eS5ydC5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNydDA7BggrBgEFBQcwAoYv
65
+ aHR0cDovL3JlZXN0ci1wa2kucnUvY2RwL3Jvb3RjYV9zc2xfcnNhMjAyMi5jcnQw
66
+ gbAGA1UdHwSBqDCBpTA1oDOgMYYvaHR0cDovL3Jvc3RlbGVjb20ucnUvY2RwL3Jv
67
+ b3RjYV9zc2xfcnNhMjAyMi5jcmwwNaAzoDGGL2h0dHA6Ly9jb21wYW55LnJ0LnJ1
68
+ L2NkcC9yb290Y2Ffc3NsX3JzYTIwMjIuY3JsMDWgM6Axhi9odHRwOi8vcmVlc3Ry
69
+ LXBraS5ydS9jZHAvcm9vdGNhX3NzbF9yc2EyMDIyLmNybDANBgkqhkiG9w0BAQsF
70
+ AAOCAgEARBVzZls79AdiSCpar15dA5Hr/rrT4WbrOfzlpI+xrLeRPrUG6eUWIW4v
71
+ Sui1yx3iqGLCjPcKb+HOTwoRMbI6ytP/ndp3TlYua2advYBEhSvjs+4vDZNwXr/D
72
+ anbwIWdurZmViQRBDFebpkvnIvru/RpWud/5r624Wp8voZMRtj/cm6aI9LtvBfT9
73
+ cfzhOaexI/99c14dyiuk1+6QhdwKaCRTc1mdfNQmnfWNRbfWhWBlK3h4GGE9JK33
74
+ Gk8ZS8DMrkdAh0xby4xAQ/mSWAfWrBmfzlOqGyoB1U47WTOeqNbWkkoAP2ys94+s
75
+ Jg4NTkiDVtXRF6nr6fYi0bSOvOFg0IQrMXO2Y8gyg9ARdPJwKtvWX8VPADCYMiWH
76
+ h4n8bZokIrImVKLDQKHY4jCsND2HHdJfnrdL2YJw1qFskNO4cSNmZydw0Wkgjv9k
77
+ F+KxqrDKlB8MZu2Hclph6v/CZ0fQ9YuE8/lsHZ0Qc2HyiSMnvjgK5fDc3TD4fa8F
78
+ E8gMNurM+kV8PT8LNIM+4Zs+LKEV8nqRWBaxkIVJGekkVKO8xDBOG/aN62AZKHOe
79
+ GcyIdu7yNMMRihGVZCYr8rYiJoKiOzDqOkPkLOPdhtVlgnhowzHDxMHND/E2WA5p
80
+ ZHuNM/m0TXt2wTTPL7JH2YC0gPz/BvvSzjksgzU5rLbRyUKQkgU=
81
+ -----END CERTIFICATE-----
@@ -11,6 +11,7 @@ export {
11
11
  export type { LoadedServer, InvokeResult, InvokerOptions, ServerRegistration, SeedOutcome } from "./runtime"
12
12
  export type { RequestContext } from "./context"
13
13
  export { setEnv, EnvError, ENV_NAME, PUBLIC_ENV_PREFIX, isPublicEnvName } from "./env"
14
+ export { TRUSTED_CA_FILE, trustedCaEnv } from "./trustedCa"
14
15
  export type { Env } from "./env"
15
16
  export { createChannelHub } from "./channelHub"
16
17
  export type { ChannelHub, ChannelHubOptions, HubConnection, HubFanout } from "./channelHub"
@@ -0,0 +1,23 @@
1
+ /**
2
+ * The certificate authorities a project's server code can reach beyond the defaults: the Russian
3
+ * Trusted CA (the Ministry of Digital Development, Минцифры) — what GigaChat, Sber's and Gosuslugi's
4
+ * endpoints present (certs/russian-trusted-ca.pem, the root and the sub CA; their dates and
5
+ * fingerprints are in the file's header).
6
+ *
7
+ * Bun reads `NODE_EXTRA_CA_CERTS` once, at process start, and ADDS the file to its defaults (`tls.ca`
8
+ * on a fetch would replace them) — so the host that runs bundles sets the variable on the process
9
+ * before it starts: the runner's image (tools/runner/Dockerfile), the CLI's local backend (its child
10
+ * process, tools/cli/src/dev/localBackend.ts), Focus's check. Setting it from inside the process is
11
+ * too late. A variable already set is the operator's and stays: the variable names one file.
12
+ */
13
+
14
+ import { dirname, join } from "node:path"
15
+ import { fileURLToPath } from "node:url"
16
+
17
+ /** The PEM of the Russian Trusted CA, by its absolute path in this SDK. */
18
+ export const TRUSTED_CA_FILE = join(dirname(fileURLToPath(import.meta.url)), "certs", "russian-trusted-ca.pem")
19
+
20
+ /** The environment a bundle-running process is started with so its fetch trusts the Russian Trusted CA
21
+ * too: `NODE_EXTRA_CA_CERTS` — unless `env` already names a file. */
22
+ export const trustedCaEnv = (env: Record<string, string | undefined> = process.env): Record<string, string> =>
23
+ env.NODE_EXTRA_CA_CERTS ? {} : { NODE_EXTRA_CA_CERTS: TRUSTED_CA_FILE }
@@ -1,4 +1,4 @@
1
- import { ModalElement, type UIModalStyle } from "./UIModal"
1
+ import { ModalElement, type UIModalShow, type UIModalStyle } from "./UIModal"
2
2
  import type { UIWidget } from "./UIWidget"
3
3
  import { buildUI, type ChildrenFn, type UIChildArg, type UINodeChild } from "./UINode"
4
4
  import { TREE_FLAG_DETENT } from "./tree"
@@ -15,14 +15,18 @@ const SHEET_ANIM_MS = 250
15
15
  * node type — docs/bottom-sheet-plan.md): hosts without sheet support show a static bottom panel.
16
16
  *
17
17
  * With detents, content lays out at the HIGHEST detent's size; lower detents show the top
18
- * slice. The scrim, `onOpen`/`onClose`, back-button and scrim-tap dismissal all come from the
19
- * modal machinery.
18
+ * slice. The scrim, `bindShow` / `onDismiss`, `onOpen` / `onClose` all come from the modal
19
+ * machinery; the drag below the lowest detent is the sheet's third dismissal gesture, routed like
20
+ * the scrim tap. The host hears whether a drag may dismiss (`sheetDismissible`):
21
+ * `dismissible(false)`, or a bound sheet with no `onDismiss`, resists instead.
20
22
  *
21
23
  * ```ts
22
- * const actions = UIBottomSheet(rows).style({ bgColor: "#1c1c1e", borderRadius: 16 }) // content-sized
24
+ * const actions = UIBottomSheet(rows).style({ bgColor: "#1c1c1e", borderRadius: 16 }) // content-sized, show()
25
+ *
26
+ * const card = UIBottomSheet(place).bindShow(() => data.value.pick != null).onDismiss(on.unpick) // the data's
23
27
  *
24
28
  * const mapSheet = UIBottomSheet(results).style({ bgColor: "#1c1c1e", borderRadius: 16 })
25
- * .detents([0.3, 0.6, 1])
29
+ * .detents([0.3, 0.6, 1]).dismissible(false) // persistent: show() once placed
26
30
  * .onDetent(i => map.setPadding(...))
27
31
  * ```
28
32
  */
@@ -38,16 +42,21 @@ export interface UIBottomSheet extends UIWidget {
38
42
  /** Fires on every settle: a finger snap on native hosts, or a `setDetent()` call. */
39
43
  onDetent(callback: (index: number) => void): this,
40
44
 
41
- /** Mount and slide in to the current detent. No-op while already open. */
45
+ /** Bind the shown state, as on `UIModal`: a function of the data (`bindShow(() =>
46
+ * data.value.pick != null)`) or a signal. */
47
+ bindShow(value: UIModalShow): this,
48
+ /** Slide in to the current detent. No-op while already shown. Bound: as on `UIModal`. */
42
49
  show(): void,
43
- /** Slide out, fade the scrim, then unmount. No-op while closed. */
50
+ /** Slide out, fade the scrim. No-op while hidden. */
44
51
  hide(): void,
45
- /** True from `show()` until `hide()` starts. */
46
- readonly isOpen: boolean,
47
52
 
48
- /** Called when `show()` mounts the sheet. */
53
+ /** The user asked to close — a scrim tap, the back press, a drag below the lowest detent. The
54
+ * one hook for all three; a bound sheet needs it to be dismissible at all (it resists the drag
55
+ * otherwise). When the callback leaves the sheet open, it slides back to its detent. */
56
+ onDismiss(callback: () => void): this,
57
+ /** Called when the sheet opens. */
49
58
  onOpen(callback: () => void): this,
50
- /** Called when the sheet starts closing — scrim tap, back button, drag-dismiss, or `hide()`. */
59
+ /** Called when the sheet starts closing — a dismissal, `hide()`, the data. */
51
60
  onClose(callback: () => void): this,
52
61
  /** `dismissible(false)`: scrim taps, the back button and dragging below the lowest detent no
53
62
  * longer close the sheet (a drag collapses to the lowest detent instead). Default `true`. */
@@ -72,13 +81,23 @@ export class BottomSheetElement extends ModalElement {
72
81
  }
73
82
 
74
83
  /** @internal The host's GESTURE settles only (programmatic `setDetent` fires JS-side); `-1` =
75
- * dragged below the lowest detent while dismissible. */
84
+ * dragged below the lowest detent while dismissible — a dismissal like the scrim tap. */
76
85
  _emitDetent(index: number): void {
77
- if (index < 0) { this.hide(); return }
86
+ if (index < 0) { this._dismiss(); return }
78
87
  this._detent = index
79
88
  for (const callback of this._detentListeners) callback(index)
80
89
  }
81
90
 
91
+ /** @internal The dismiss callbacks left the sheet open (the data said so): the host slid it out
92
+ * on the drag, so put it back on its detent. */
93
+ override _afterDismiss(): void {
94
+ if (this._shown) this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
95
+ }
96
+
97
+ override _syncDismissible(): void {
98
+ this.style({ sheetDismissible: this._dismissAllowed() ? 1 : 0 } as UIBottomSheetStyle)
99
+ }
100
+
82
101
  detents(list: number[]): this {
83
102
  const sorted = [...list].filter(d => d > 0).sort((a, b) => a - b)
84
103
  if (sorted.length === 0) return this
@@ -108,23 +127,13 @@ export class BottomSheetElement extends ModalElement {
108
127
  return this
109
128
  }
110
129
 
111
- override show(): void {
112
- if (this._shown) return
113
- // Before the mount, so the built node carries the target and hosts play the entrance to it.
114
- this.style({ sheetDetent: this._detent } as UIBottomSheetStyle)
115
- super.show()
116
- }
117
-
118
- override hide(): void {
119
- if (!this._shown) return
120
- this.style({ sheetDetent: -1 } as UIBottomSheetStyle) // hosts slide out; the hide follows
121
- super.hide()
122
- }
123
-
124
- override dismissible(enabled: boolean): this {
125
- super.dismissible(enabled)
126
- this.style({ sheetDismissible: enabled ? 1 : 0 } as UIBottomSheetStyle)
127
- return this
130
+ /** @internal The display write is the moment the host enters or leaves: the detent rides along —
131
+ * written BEFORE the display, so the node carries the target when the host plays the entrance
132
+ * to it (`-1` on a hide: the host slides out, the hide follows). Every way of opening goes
133
+ * through here: show() / hide(), `open()`, the binding. */
134
+ override _writeDisplay(display: "flex" | "none"): void {
135
+ this.style({ sheetDetent: display === "flex" ? this._detent : -1 } as UIBottomSheetStyle)
136
+ super._writeDisplay(display)
128
137
  }
129
138
  }
130
139
 
package/src/ui/UIModal.ts CHANGED
@@ -3,6 +3,7 @@ import { buildUI, type BaseStyle, type ChildrenFn, type Color, type DrawableStyl
3
3
  import type { TransitionPose } from "./presentable"
4
4
  import { _widgetPoseId } from "./transitions"
5
5
  import { tree } from "./tree"
6
+ import { createBinding, disposeBinding, flushEffects, type Signal } from "../core/signals"
6
7
 
7
8
  export type UIModalStyle = UIWidgetStyle
8
9
 
@@ -15,27 +16,49 @@ export type UIModalTransition = DrawableStyle & BaseStyle & {
15
16
  duration?: number,
16
17
  }
17
18
 
19
+ /** What `bindShow()` takes: a function of the data, or a signal the modal also writes (`false` on
20
+ * a dismissal, `show()` / `hide()` set it). */
21
+ export type UIModalShow = (() => boolean) | Signal<boolean>
22
+
18
23
  const MODAL_ANIM_MS = 200
24
+ const SHOW_BINDING = "#show"
19
25
 
20
26
  /**
21
27
  * A dialog: a `UIWidget` with the modal boilerplate built in. Comes with a scrim
22
- * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), animates on `show()`/`hide()` and on
23
- * a state block flipping its `display` (a 200 ms fade by default — swap the pose via
24
- * `transition()`; the runtime plays it as tracks that never touch the style), and closes itself
25
- * on a scrim tap or the Android back button (disable via `dismissible(false)`). Everything else
26
- * is a plain widget: declare it where it belongs — among its screen's children, hidden until a
27
- * tap (`display: "none"`), or shown with `show()` from anywhere, which puts a parentless modal in
28
- * the app layer. A dialog bound to data (its display from a binding) is declared
29
- * `dismissible(false)` and routes `onOverlayTap` / `onBack` into the data's dismiss, so the data
30
- * stays what the screen shows.
28
+ * (`overlayColor: "rgba(0, 0, 0, 0.5)"` unless overridden), is HIDDEN until shown, animates on
29
+ * every show / hide (a 200 ms fade by default — swap the pose via `transition()`; the runtime
30
+ * plays it as tracks that never touch the style).
31
+ *
32
+ * One state, shown or not, driven one of two ways:
33
+ * - `show()` / `hide()` from code — an action sheet opened by a button;
34
+ * - `bindShow(…)`: a function of the data (`bindShow(() => data.value.confirm)` — the data is the
35
+ * truth, `show()` / `hide()` are refused) or a `Signal<boolean>` the modal reads AND writes.
36
+ *
37
+ * A dismissal — the scrim tap, the back press, a sheet dragged below its lowest detent — is ONE
38
+ * request, `onDismiss`. By default it hides an unbound modal and writes `false` into a bound
39
+ * signal; a bound function has nowhere to write, so a modal bound to one without `onDismiss`
40
+ * cannot be dismissed (a forced choice — the same as `dismissible(false)`). `display` is not a
41
+ * modal's switch: a `.style({ display })` on it throws (a bound display died on the first
42
+ * dismissal, which wrote a static `display: "none"` over the binding — the dialog never opened
43
+ * again).
31
44
  */
32
45
  export interface UIModal extends UIWidget {
33
- /** Show and play the entrance transition. No-op while already open. */
46
+ /**
47
+ * Bind the shown state. A function: the modal follows the data, and `show()` / `hide()` are
48
+ * errors — flip the data (`onDismiss` is where a dismissal goes). A signal: read and written —
49
+ * a dismissal sets it `false`, `show()` / `hide()` set it.
50
+ *
51
+ * ```ts
52
+ * UIModal(...).bindShow(() => data.value.confirm).onDismiss(on.cancel) // the data is the truth
53
+ * UIModal(...).bindShow(confirmOpen) // a Signal<boolean>
54
+ * ```
55
+ */
56
+ bindShow(value: UIModalShow): this,
57
+ /** Show and play the entrance transition. No-op while already shown. Bound to a signal: writes
58
+ * `true` into it; bound to a function: an error (flip the data). */
34
59
  show(): void,
35
- /** Play the exit transition, then hide. No-op while closed or already closing. */
60
+ /** Play the exit transition, then hide. No-op while hidden. Bound: as `show()`. */
36
61
  hide(): void,
37
- /** True from `show()` until `hide()` starts (already false during the exit animation). */
38
- readonly isOpen: boolean,
39
62
 
40
63
  /**
41
64
  * Replace the show/hide animation. `hidden` is the off-screen pose — `show()` animates from
@@ -49,13 +72,17 @@ export interface UIModal extends UIWidget {
49
72
  */
50
73
  transition(hidden: UIModalTransition): this,
51
74
 
52
- /** Called when the modal opens — `show()`, or a state block flipping its display. */
75
+ /** The user asked to close: a scrim tap, the back press, a sheet dragged below its lowest
76
+ * detent. Replaces the default (hide, or `false` into the signal): the callback decides —
77
+ * usually the action that flips the data the modal is bound to. */
78
+ onDismiss(callback: () => void): this,
79
+ /** Called when the modal is shown — whoever showed it. */
53
80
  onOpen(callback: () => void): this,
54
- /** Called when the modal starts closing — scrim tap, back button, a `hide()` call, or a state
55
- * block flipping its display. */
81
+ /** Called when the modal starts hiding — whoever hid it. */
56
82
  onClose(callback: () => void): this,
57
- /** `dismissible(false)` keeps scrim taps and the back button from closing the modal
58
- * (forced-choice dialogs, a dialog whose display is bound to data). Default `true`. */
83
+ /** `dismissible(false)`: the dismissal gestures are off — the scrim tap does nothing, the back
84
+ * press is swallowed, a sheet resists below its lowest detent (a forced choice, the persistent
85
+ * map sheet). Default `true`. */
59
86
  dismissible(enabled: boolean): this,
60
87
  }
61
88
 
@@ -64,16 +91,32 @@ export class ModalElement extends WidgetElement {
64
91
  _transition: UIModalTransition = { opacity: 0 }
65
92
  readonly _openListeners: (() => void)[] = []
66
93
  readonly _closeListeners: (() => void)[] = []
94
+ readonly _dismissListeners: (() => void)[] = []
95
+ /** @internal `bindShow` was called: show() / hide() are not the modal's to call (a signal's: they write it). */
96
+ _bound = false
97
+ /** @internal The signal `bindShow()` was given, which a dismissal writes. */
98
+ _showSignal?: Signal<boolean>
67
99
  private _poseObj?: TransitionPose
68
100
  private _poseKey = ""
69
101
 
70
102
  constructor(style: UIModalStyle, children: UINodeChild[] | ChildrenFn) {
71
- super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", ...style }, children)
72
- this.onOverlayTap(() => { if (this._dismissible) this.hide() })
73
- this.onBack(() => { if (this._dismissible) this.hide() })
103
+ // Hidden by default: a dialog is shown, never found open — show() / bindShow() flip it.
104
+ super("widget", { overlayColor: "rgba(0, 0, 0, 0.5)", display: "none", ...style }, children)
105
+ this.onOverlayTap(() => this._dismiss())
106
+ this.onBack(() => this._dismiss())
74
107
  this._syncPose()
75
108
  }
76
109
 
110
+ /** @internal `display` is not a modal's switch. Refused on every write of the app's — `.style({
111
+ * display })`, `el.style.display =` — and let through for the widget's own (`_ownWrite`). During
112
+ * the constructor the field is still undefined (it is initialised after `super()`), which lets
113
+ * the default `display: "none"` through. */
114
+ _mapStyle(style: any): void {
115
+ if ("display" in style && this._ownWrite === false) {
116
+ throw new Error("a modal's `display` is not its switch — a dialog, a sheet or a menu is shown with show() / hide(), or bound to the data with bindShow(…), never by its display")
117
+ }
118
+ }
119
+
77
120
  // The pose the runtime plays on a display flip: the hidden pose + its duration, plus the scrim
78
121
  // fade — only when the modal actually has a scrim (overlayColor: null opts out; a track on a
79
122
  // null scrim would create the intercepting layer) and the pose doesn't drive the scrim itself.
@@ -106,27 +149,80 @@ export class ModalElement extends WidgetElement {
106
149
  return this
107
150
  }
108
151
 
152
+ bindShow(value: UIModalShow): this {
153
+ if (this._fx !== undefined) disposeBinding(this, SHOW_BINDING)
154
+ this._bound = true
155
+ let read: () => boolean
156
+ if (typeof value === "function") { this._showSignal = undefined; read = value }
157
+ else { this._showSignal = value; read = () => value.value }
158
+ // The first value is the START state (bound before the modal is placed, it shows with its
159
+ // place); every later one is a flip, played like a show() / hide().
160
+ let initial = true
161
+ createBinding(this, SHOW_BINDING, () => {
162
+ const shown = !!read()
163
+ if (this._h.id !== 0) {
164
+ if (initial && tree().parent(this._h.id) === 0) this._writeDisplay(shown ? "flex" : "none")
165
+ else this._setOpen(shown)
166
+ }
167
+ initial = false
168
+ })
169
+ this._syncDismissible()
170
+ return this
171
+ }
172
+
109
173
  override show(): void {
110
- if (this._shown) return
111
- this._syncPose()
112
- super.show()
174
+ if (this._showSignal) { this._showSignal.value = true; return }
175
+ if (this._bound) { console.error("show() on a modal bound with bindShow(() => …) — flip the data it follows"); return }
176
+ this._setOpen(true)
113
177
  }
114
178
 
115
179
  override hide(): void {
116
- if (!this._shown) return
180
+ if (this._showSignal) { this._showSignal.value = false; return }
181
+ if (this._bound) { console.error("hide() on a modal bound with bindShow(() => …) — flip the data it follows"); return }
182
+ this._setOpen(false)
183
+ }
184
+
185
+ override _setOpen(open: boolean): void {
117
186
  this._syncPose()
118
- super.hide()
187
+ super._setOpen(open)
188
+ }
189
+
190
+ /** @internal A dismissal gesture — the scrim tap, the back press, a sheet dragged below its
191
+ * lowest detent — asks to close. The default hides an unbound modal and writes `false` into a
192
+ * bound signal; a bound function has no default (the data decides, through `onDismiss`). */
193
+ _dismiss(): void {
194
+ if (!this._dismissible) return
195
+ if (this._dismissListeners.length > 0) {
196
+ for (const callback of this._dismissListeners.slice()) callback()
197
+ flushEffects() // the data's flip lands now, so the sheet below knows whether it stays
198
+ this._afterDismiss()
199
+ return
200
+ }
201
+ if (this._showSignal) { this._showSignal.value = false; return }
202
+ if (!this._bound) this._setOpen(false)
119
203
  }
120
204
 
121
- get isOpen(): boolean {
122
- return this._shown
205
+ /** @internal After the dismiss callbacks: a sheet still shown puts itself back on its detent. */
206
+ _afterDismiss(): void {}
207
+
208
+ /** @internal Can a gesture close it? What the host is told (a sheet's drag resists otherwise). */
209
+ _dismissAllowed(): boolean {
210
+ return this._dismissible && (!this._bound || this._showSignal !== undefined || this._dismissListeners.length > 0)
123
211
  }
124
212
 
213
+ /** @internal The host's copy of `_dismissAllowed()` (a sheet writes `sheetDismissible`). */
214
+ _syncDismissible(): void {}
215
+
125
216
  /** @internal onOpen / onClose follow the intent, whoever flipped it. */
126
217
  override _shownChanged(shown: boolean): void {
127
218
  for (const callback of (shown ? this._openListeners : this._closeListeners).slice()) callback()
128
219
  }
129
220
 
221
+ onDismiss(callback: () => void): this {
222
+ this._dismissListeners.push(callback)
223
+ this._syncDismissible()
224
+ return this
225
+ }
130
226
  onOpen(callback: () => void): this {
131
227
  this._openListeners.push(callback)
132
228
  return this
@@ -137,15 +233,16 @@ export class ModalElement extends WidgetElement {
137
233
  }
138
234
  dismissible(enabled: boolean): this {
139
235
  this._dismissible = enabled
236
+ this._syncDismissible()
140
237
  return this
141
238
  }
142
239
  }
143
240
 
144
241
  const makeModal = (style: any, children: any) => new ModalElement(style, children)
145
242
 
146
- /** Create a modal dialog (see {@link UIModal}): declare it among its screen's children with
147
- * `display: "none"` and `show()` it from a tap, or `show()` a parentless one from anywhere.
148
- * Same argument forms as `UIColumn`. */
243
+ /** Create a modal dialog (see {@link UIModal}): give it to its screen (`.widgets(dialog)`) and
244
+ * `show()` it from a tap, bind it to the data with `bindShow(() => …)`, or `show()` a parentless
245
+ * one from anywhere. Same argument forms as `UIColumn`. */
149
246
  export function UIModal(...children: UIChildArg[]): UIModal;
150
247
  export function UIModal(children: UINodeChild[] | ChildrenFn): UIModal;
151
248
  export function UIModal(...args: any[]): UIModal {
@@ -73,15 +73,25 @@ export class WidgetElement extends ContainerElement<"widget"> {
73
73
  // factory) so subclasses like ModalElement inherit it.
74
74
  super(type, { position: "absolute", ...style }, children)
75
75
  }
76
- show(): void {
76
+ show(): void { this._setOpen(true) }
77
+ hide(): void { this._setOpen(false) }
78
+ /** @internal The body of show() / hide(): what every way of opening a widget runs — the calls,
79
+ * a modal's `open` binding. Subclasses wrap it (a modal syncs its pose, a sheet its detent). */
80
+ _setOpen(open: boolean): void {
77
81
  if (this._h.id === 0) return
82
+ if (!open) {
83
+ if (!this._shown) return
84
+ this._setShown(false)
85
+ this._writeDisplay("none")
86
+ return
87
+ }
78
88
  const parentId = tree().parent(this._h.id)
79
89
  if (parentId !== 0 && this._shown) return
80
90
  if (parentId === 0) {
81
91
  // No place of its own: the app layer takes it HIDDEN, and the flip below shows it — a show()
82
92
  // is a display flip wherever the widget is, so its pose plays (a modal's fade) exactly as on
83
93
  // a placed one. Appended shown, it would merely appear: a widget shows with its place, no pose.
84
- this.style({ display: "none" } as any)
94
+ this._writeDisplay("none")
85
95
  _appLayer().append(this as any) // pins it: _pinned reads the display
86
96
  } else {
87
97
  // Shown = on top of its root: a show() moves the widget to the TAIL of its parent's children
@@ -94,13 +104,16 @@ export class WidgetElement extends ContainerElement<"widget"> {
94
104
  tree().insert(parentId, this._h.id, siblings.length - 1)
95
105
  }
96
106
  }
97
- this.style({ display: "flex" } as any)
107
+ this._writeDisplay("flex")
98
108
  this._setShown(true)
99
109
  }
100
- hide(): void {
101
- if (this._h.id === 0 || !this._shown) return
102
- this._setShown(false)
103
- this.style({ display: "none" } as any)
110
+ /** @internal True while the widget writes its own display (show / hide / open): the one display
111
+ * write a modal lets through — the app's `display` on a modal is refused (ModalElement._mapStyle). */
112
+ _ownWrite = false
113
+ /** @internal The widget's own display write. */
114
+ _writeDisplay(display: "flex" | "none"): void {
115
+ this._ownWrite = true
116
+ try { this.style({ display } as any) } finally { this._ownWrite = false }
104
117
  }
105
118
  get isShown(): boolean {
106
119
  return this._shown
@@ -115,7 +128,7 @@ export class WidgetElement extends ContainerElement<"widget"> {
115
128
  }
116
129
  if (owner) {
117
130
  // Hidden first: a widget shows with its place, and the old contract showed it on show() only.
118
- this.style({ display: "none" } as any)
131
+ this._writeDisplay("none")
119
132
  owner.widgets.append(this as any)
120
133
  }
121
134
  return this
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.5.0"
7
+ export const SDK_VERSION = "2.6.0"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {