bireactive 0.2.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.
Files changed (225) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +81 -0
  3. package/dist/animation/anim.d.ts +57 -0
  4. package/dist/animation/anim.js +318 -0
  5. package/dist/animation/combinators.d.ts +39 -0
  6. package/dist/animation/combinators.js +113 -0
  7. package/dist/animation/easings.d.ts +5 -0
  8. package/dist/animation/easings.js +5 -0
  9. package/dist/animation/index.d.ts +3 -0
  10. package/dist/animation/index.js +3 -0
  11. package/dist/assert/algebra.d.ts +20 -0
  12. package/dist/assert/algebra.js +79 -0
  13. package/dist/assert/claim.d.ts +40 -0
  14. package/dist/assert/claim.js +129 -0
  15. package/dist/assert/index.d.ts +7 -0
  16. package/dist/assert/index.js +19 -0
  17. package/dist/assert/predicates.d.ts +18 -0
  18. package/dist/assert/predicates.js +43 -0
  19. package/dist/assert/record.d.ts +20 -0
  20. package/dist/assert/record.js +78 -0
  21. package/dist/assert/scope.d.ts +42 -0
  22. package/dist/assert/scope.js +233 -0
  23. package/dist/assert/span.d.ts +37 -0
  24. package/dist/assert/span.js +68 -0
  25. package/dist/assert/tree.d.ts +22 -0
  26. package/dist/assert/tree.js +65 -0
  27. package/dist/code/code.d.ts +70 -0
  28. package/dist/code/code.js +361 -0
  29. package/dist/code/index.d.ts +2 -0
  30. package/dist/code/index.js +9 -0
  31. package/dist/code/morph.d.ts +5 -0
  32. package/dist/code/morph.js +194 -0
  33. package/dist/code/tokenize.d.ts +8 -0
  34. package/dist/code/tokenize.js +51 -0
  35. package/dist/constraints/cluster.d.ts +83 -0
  36. package/dist/constraints/cluster.js +213 -0
  37. package/dist/constraints/drivers.d.ts +15 -0
  38. package/dist/constraints/drivers.js +40 -0
  39. package/dist/constraints/factories.d.ts +73 -0
  40. package/dist/constraints/factories.js +248 -0
  41. package/dist/constraints/index.d.ts +11 -0
  42. package/dist/constraints/index.js +39 -0
  43. package/dist/constraints/interaction.d.ts +21 -0
  44. package/dist/constraints/interaction.js +148 -0
  45. package/dist/constraints/linalg.d.ts +18 -0
  46. package/dist/constraints/linalg.js +141 -0
  47. package/dist/constraints/phases.d.ts +21 -0
  48. package/dist/constraints/phases.js +60 -0
  49. package/dist/constraints/physics.d.ts +34 -0
  50. package/dist/constraints/physics.js +128 -0
  51. package/dist/constraints/rigid.d.ts +210 -0
  52. package/dist/constraints/rigid.js +835 -0
  53. package/dist/constraints/solver.d.ts +107 -0
  54. package/dist/constraints/solver.js +510 -0
  55. package/dist/constraints/term.d.ts +50 -0
  56. package/dist/constraints/term.js +80 -0
  57. package/dist/constraints/terms.d.ts +80 -0
  58. package/dist/constraints/terms.js +302 -0
  59. package/dist/constraints/world.d.ts +31 -0
  60. package/dist/constraints/world.js +245 -0
  61. package/dist/core/aggregates.d.ts +64 -0
  62. package/dist/core/aggregates.js +198 -0
  63. package/dist/core/anim.d.ts +84 -0
  64. package/dist/core/anim.js +301 -0
  65. package/dist/core/index.d.ts +38 -0
  66. package/dist/core/index.js +38 -0
  67. package/dist/core/introspect.d.ts +5 -0
  68. package/dist/core/introspect.js +31 -0
  69. package/dist/core/lenses/closed-form-policies.d.ts +64 -0
  70. package/dist/core/lenses/closed-form-policies.js +452 -0
  71. package/dist/core/lenses/domain-aggregates.d.ts +54 -0
  72. package/dist/core/lenses/domain-aggregates.js +259 -0
  73. package/dist/core/lenses/factor-lens.d.ts +42 -0
  74. package/dist/core/lenses/factor-lens.js +419 -0
  75. package/dist/core/lenses/index.d.ts +5 -0
  76. package/dist/core/lenses/index.js +16 -0
  77. package/dist/core/lenses/memory.d.ts +47 -0
  78. package/dist/core/lenses/memory.js +102 -0
  79. package/dist/core/lenses/typed-factor.d.ts +45 -0
  80. package/dist/core/lenses/typed-factor.js +376 -0
  81. package/dist/core/network-utils.d.ts +14 -0
  82. package/dist/core/network-utils.js +62 -0
  83. package/dist/core/new-primitives.d.ts +33 -0
  84. package/dist/core/new-primitives.js +113 -0
  85. package/dist/core/signal.d.ts +254 -0
  86. package/dist/core/signal.js +1349 -0
  87. package/dist/core/traits.d.ts +61 -0
  88. package/dist/core/traits.js +56 -0
  89. package/dist/core/tree.d.ts +23 -0
  90. package/dist/core/tree.js +62 -0
  91. package/dist/core/values/anchor.d.ts +23 -0
  92. package/dist/core/values/anchor.js +23 -0
  93. package/dist/core/values/audio.d.ts +33 -0
  94. package/dist/core/values/audio.js +107 -0
  95. package/dist/core/values/bool.d.ts +37 -0
  96. package/dist/core/values/bool.js +75 -0
  97. package/dist/core/values/box.d.ts +77 -0
  98. package/dist/core/values/box.js +211 -0
  99. package/dist/core/values/canvas.d.ts +71 -0
  100. package/dist/core/values/canvas.js +495 -0
  101. package/dist/core/values/color.d.ts +49 -0
  102. package/dist/core/values/color.js +106 -0
  103. package/dist/core/values/flags.d.ts +18 -0
  104. package/dist/core/values/flags.js +50 -0
  105. package/dist/core/values/gpu.d.ts +74 -0
  106. package/dist/core/values/gpu.js +426 -0
  107. package/dist/core/values/matrix.d.ts +53 -0
  108. package/dist/core/values/matrix.js +140 -0
  109. package/dist/core/values/num.d.ts +62 -0
  110. package/dist/core/values/num.js +166 -0
  111. package/dist/core/values/pose.d.ts +31 -0
  112. package/dist/core/values/pose.js +83 -0
  113. package/dist/core/values/range.d.ts +83 -0
  114. package/dist/core/values/range.js +167 -0
  115. package/dist/core/values/str.d.ts +76 -0
  116. package/dist/core/values/str.js +346 -0
  117. package/dist/core/values/template.d.ts +49 -0
  118. package/dist/core/values/template.js +148 -0
  119. package/dist/core/values/transform.d.ts +49 -0
  120. package/dist/core/values/transform.js +115 -0
  121. package/dist/core/values/tri.d.ts +31 -0
  122. package/dist/core/values/tri.js +95 -0
  123. package/dist/core/values/vec.d.ts +72 -0
  124. package/dist/core/values/vec.js +219 -0
  125. package/dist/core/writable.d.ts +15 -0
  126. package/dist/core/writable.js +29 -0
  127. package/dist/ext/events.d.ts +10 -0
  128. package/dist/ext/events.js +31 -0
  129. package/dist/ext/index.d.ts +4 -0
  130. package/dist/ext/index.js +4 -0
  131. package/dist/ext/snapshot.d.ts +8 -0
  132. package/dist/ext/snapshot.js +29 -0
  133. package/dist/ext/timeline.d.ts +56 -0
  134. package/dist/ext/timeline.js +94 -0
  135. package/dist/ext/waapi.d.ts +25 -0
  136. package/dist/ext/waapi.js +198 -0
  137. package/dist/index.d.ts +8 -0
  138. package/dist/index.js +10 -0
  139. package/dist/propagators/index.d.ts +6 -0
  140. package/dist/propagators/index.js +6 -0
  141. package/dist/propagators/layout.d.ts +68 -0
  142. package/dist/propagators/layout.js +336 -0
  143. package/dist/propagators/network.d.ts +52 -0
  144. package/dist/propagators/network.js +185 -0
  145. package/dist/propagators/propagator.d.ts +12 -0
  146. package/dist/propagators/propagator.js +16 -0
  147. package/dist/propagators/range.d.ts +45 -0
  148. package/dist/propagators/range.js +147 -0
  149. package/dist/propagators/relations.d.ts +60 -0
  150. package/dist/propagators/relations.js +343 -0
  151. package/dist/shapes/annular-sector.d.ts +15 -0
  152. package/dist/shapes/annular-sector.js +64 -0
  153. package/dist/shapes/button.d.ts +14 -0
  154. package/dist/shapes/button.js +31 -0
  155. package/dist/shapes/choreographers.d.ts +22 -0
  156. package/dist/shapes/choreographers.js +69 -0
  157. package/dist/shapes/circle.d.ts +17 -0
  158. package/dist/shapes/circle.js +57 -0
  159. package/dist/shapes/clip.d.ts +5 -0
  160. package/dist/shapes/clip.js +31 -0
  161. package/dist/shapes/connect.d.ts +16 -0
  162. package/dist/shapes/connect.js +70 -0
  163. package/dist/shapes/curve.d.ts +60 -0
  164. package/dist/shapes/curve.js +285 -0
  165. package/dist/shapes/dashed.d.ts +16 -0
  166. package/dist/shapes/dashed.js +142 -0
  167. package/dist/shapes/debug.d.ts +43 -0
  168. package/dist/shapes/debug.js +97 -0
  169. package/dist/shapes/group.d.ts +5 -0
  170. package/dist/shapes/group.js +10 -0
  171. package/dist/shapes/handle.d.ts +32 -0
  172. package/dist/shapes/handle.js +88 -0
  173. package/dist/shapes/index.d.ts +23 -0
  174. package/dist/shapes/index.js +23 -0
  175. package/dist/shapes/interaction.d.ts +32 -0
  176. package/dist/shapes/interaction.js +187 -0
  177. package/dist/shapes/label.d.ts +20 -0
  178. package/dist/shapes/label.js +42 -0
  179. package/dist/shapes/layout.d.ts +29 -0
  180. package/dist/shapes/layout.js +74 -0
  181. package/dist/shapes/line.d.ts +21 -0
  182. package/dist/shapes/line.js +79 -0
  183. package/dist/shapes/list.d.ts +18 -0
  184. package/dist/shapes/list.js +51 -0
  185. package/dist/shapes/mount.d.ts +7 -0
  186. package/dist/shapes/mount.js +10 -0
  187. package/dist/shapes/path.d.ts +77 -0
  188. package/dist/shapes/path.js +227 -0
  189. package/dist/shapes/rect.d.ts +30 -0
  190. package/dist/shapes/rect.js +131 -0
  191. package/dist/shapes/shape.d.ts +132 -0
  192. package/dist/shapes/shape.js +306 -0
  193. package/dist/shapes/text.d.ts +24 -0
  194. package/dist/shapes/text.js +53 -0
  195. package/dist/shapes/tokens.d.ts +28 -0
  196. package/dist/shapes/tokens.js +27 -0
  197. package/dist/shapes/transitions.d.ts +23 -0
  198. package/dist/shapes/transitions.js +62 -0
  199. package/dist/tex/decorations.d.ts +26 -0
  200. package/dist/tex/decorations.js +116 -0
  201. package/dist/tex/index.d.ts +5 -0
  202. package/dist/tex/index.js +5 -0
  203. package/dist/tex/marker.d.ts +17 -0
  204. package/dist/tex/marker.js +63 -0
  205. package/dist/tex/motion.d.ts +43 -0
  206. package/dist/tex/motion.js +290 -0
  207. package/dist/tex/parts.d.ts +65 -0
  208. package/dist/tex/parts.js +149 -0
  209. package/dist/tex/tex.d.ts +45 -0
  210. package/dist/tex/tex.js +244 -0
  211. package/dist/web/attr.d.ts +16 -0
  212. package/dist/web/attr.js +98 -0
  213. package/dist/web/diagram.d.ts +49 -0
  214. package/dist/web/diagram.js +260 -0
  215. package/dist/web/index.d.ts +6 -0
  216. package/dist/web/index.js +6 -0
  217. package/dist/web/md-marker.d.ts +6 -0
  218. package/dist/web/md-marker.js +39 -0
  219. package/dist/web/md-tex.d.ts +6 -0
  220. package/dist/web/md-tex.js +61 -0
  221. package/dist/web/raf.d.ts +6 -0
  222. package/dist/web/raf.js +24 -0
  223. package/dist/web/viewport.d.ts +7 -0
  224. package/dist/web/viewport.js +13 -0
  225. package/package.json +87 -0
@@ -0,0 +1,167 @@
1
+ // range.ts — reactive numeric interval `[lo, hi]`.
2
+ //
3
+ // Home for sliders, scrollbars, and timeline clip spans. Field lenses
4
+ // give start-knob (`.lo`) and end-knob (`.hi`) drag; `.start` is the
5
+ // body-drag (shifts both, preserving width); `.slider(t)` is the
6
+ // bidirectional `t ↔ lo + t·(hi - lo)` iso. Traits: `linear`, `lerp`,
7
+ // `equals`, `pack` (scalar-only).
8
+ import { tween } from "../anim.js";
9
+ import { Cell, isComputed, reader, readNow, } from "../signal.js";
10
+ import { derived, field } from "../writable.js";
11
+ import { Bool } from "./bool.js";
12
+ import { Num, num } from "./num.js";
13
+ export const add = (a, b) => ({ lo: a.lo + b.lo, hi: a.hi + b.hi });
14
+ export const sub = (a, b) => ({ lo: a.lo - b.lo, hi: a.hi - b.hi });
15
+ export const scale = (a, k) => ({ lo: a.lo * k, hi: a.hi * k });
16
+ export const lerp = (a, b, t) => ({
17
+ lo: a.lo + (b.lo - a.lo) * t,
18
+ hi: a.hi + (b.hi - a.hi) * t,
19
+ });
20
+ export const equals = (a, b) => a === b || (a.lo === b.lo && a.hi === b.hi);
21
+ /** L2 distance over (lo, hi). Treats a range as a point in 2-space. */
22
+ export const metric = (a, b) => Math.hypot(a.lo - b.lo, a.hi - b.hi);
23
+ export const width = (r) => r.hi - r.lo;
24
+ export const center = (r) => (r.lo + r.hi) / 2;
25
+ export const contains = (r, v) => v >= r.lo && v <= r.hi;
26
+ export const clamp = (r, v) => (v < r.lo ? r.lo : v > r.hi ? r.hi : v);
27
+ /** Closest value STRICTLY outside `[lo, hi]`, displaced past the
28
+ * nearest endpoint by `eps`. Used by `Range#contains` as the bwd's
29
+ * false-side policy. */
30
+ export const eject = (r, v, eps = 1e-6) => {
31
+ if (!contains(r, v))
32
+ return v;
33
+ return v - r.lo <= r.hi - v ? r.lo - eps : r.hi + eps;
34
+ };
35
+ /** Sample at parameter `t`: `lo + t·(hi - lo)`. `t ∈ [0, 1]` stays
36
+ * inside the range; values outside extrapolate linearly. */
37
+ export const sample = (r, t) => r.lo + t * (r.hi - r.lo);
38
+ /** Inverse of `sample`: given a value, recover the `t` that would
39
+ * produce it. Degenerate (zero-width) ranges return 0. */
40
+ export const paramOf = (r, v) => {
41
+ const w = r.hi - r.lo;
42
+ return w === 0 ? 0 : (v - r.lo) / w;
43
+ };
44
+ const linearImpl = { add, sub, scale };
45
+ const packImpl = {
46
+ dim: 2,
47
+ read: (v, a, o) => {
48
+ a[o] = v.lo;
49
+ a[o + 1] = v.hi;
50
+ },
51
+ write: (a, o) => ({ lo: a[o], hi: a[o + 1] }),
52
+ };
53
+ export class Range extends Cell {
54
+ static traits = {
55
+ linear: linearImpl,
56
+ lerp,
57
+ metric,
58
+ equals,
59
+ pack: packImpl,
60
+ };
61
+ constructor(v = { lo: 0, hi: 1 }) {
62
+ super(v, { equals });
63
+ }
64
+ /** Start endpoint. Writes preserve `hi` (start-knob semantics). */
65
+ get lo() {
66
+ return field(this, "lo", Num);
67
+ }
68
+ /** End endpoint. Writes preserve `lo` (end-knob semantics). */
69
+ get hi() {
70
+ return field(this, "hi", Num);
71
+ }
72
+ get width() {
73
+ return derived(this, "width", Num, width);
74
+ }
75
+ get center() {
76
+ return derived(this, "center", Num, center);
77
+ }
78
+ /** Translate by `by`. Reads shift the interval; writes shift back. */
79
+ shift(by) {
80
+ const f = reader(by);
81
+ return this.lens(v => ({ lo: v.lo + f(), hi: v.hi + f() }), n => ({ lo: n.lo - f(), hi: n.hi - f() }));
82
+ }
83
+ /** Scale uniformly about the origin. Iso for `k ≠ 0`. */
84
+ scale(k) {
85
+ const kf = reader(k);
86
+ return this.lens(v => {
87
+ const k = kf();
88
+ return { lo: v.lo * k, hi: v.hi * k };
89
+ }, n => {
90
+ const k = kf();
91
+ return { lo: n.lo / k, hi: n.hi / k };
92
+ });
93
+ }
94
+ /** Body-drag handle: read returns `lo`; write shifts the range so `lo`
95
+ * matches (width preserved). For start-knob editing use `.lo`. */
96
+ get start() {
97
+ return Num.lens(this, v => v.lo, (newLo, src) => ({ lo: newLo, hi: newLo + (src.hi - src.lo) }));
98
+ }
99
+ /** RO sample at `t`. `t ∈ [0, 1]` stays inside; outside extrapolates. */
100
+ sample(t) {
101
+ return Num.derive(() => sample(this.value, readNow(t)));
102
+ }
103
+ /** Bidirectional `t ↔ value` slider. Read `lo + t·(hi - lo)`; write
104
+ * solves for `t` and updates `t` only, leaving `lo` / `hi` put. */
105
+ slider(t) {
106
+ // `this as Range` pins the tuple element type (polymorphic `this`
107
+ // defeats the mapped-tuple inference on `[this, t]`).
108
+ return Num.lens([this, t], ([r, tv]) => sample(r, tv), (v, [r]) => {
109
+ const w = r.hi - r.lo;
110
+ return [undefined, w === 0 ? 0 : (v - r.lo) / w];
111
+ });
112
+ }
113
+ /** Membership predicate. Conditional return type: a writable `Num`
114
+ * yields `Writable<Bool>` and flipping the view bumps the source
115
+ * (`true` clamps into `[lo, hi]`, `false` ejects past the nearest
116
+ * endpoint by `eps`). Literal / RO inputs yield a bare RO `Bool`. */
117
+ contains(v) {
118
+ if (v instanceof Num) {
119
+ // RO computed Num has no backward path → RO branch. Sources and
120
+ // writable lenses both accept write-back.
121
+ if (!isComputed(v)) {
122
+ return Bool.lens([this, v], (vals) => contains(vals[0], vals[1]), (target, vals) => {
123
+ const [r, n] = vals;
124
+ if (contains(r, n) === target)
125
+ return [undefined, undefined];
126
+ return [undefined, target ? clamp(r, n) : eject(r, n)];
127
+ });
128
+ }
129
+ }
130
+ return Bool.derive(() => contains(this.value, readNow(v)));
131
+ }
132
+ /** RO clamp: read `v` into `[lo, hi]`. For a writable clamping lens
133
+ * on a single Num, see `Num#clamp(lo, hi)`. */
134
+ clampedRead(v) {
135
+ return Num.derive(() => clamp(this.value, readNow(v)));
136
+ }
137
+ /** Inverse of `sample`: derive the `t` that would produce `v`. */
138
+ paramOf(v) {
139
+ return Num.derive(() => paramOf(this.value, readNow(v)));
140
+ }
141
+ /** Tween-builder; animates `{lo, hi}` jointly. */
142
+ to(target, dur, ease) {
143
+ return tween(this, target, dur, ease);
144
+ }
145
+ }
146
+ /** @internal — 2-input lens over two writable `Num`s; `range()` delegates
147
+ * here after lifting literals. */
148
+ function ends(lo, hi) {
149
+ return Range.lens([lo, hi], (vals) => ({ lo: vals[0], hi: vals[1] }), (target) => [target.lo, target.hi]);
150
+ }
151
+ /** Range over `[at, at + dur]`, parameterised by start + duration. The
152
+ * timeline-clip shape: `.lo` slides the start, `.hi` the end, `.start`
153
+ * body-drags (preserving width). Backed by the live `at` / `dur` Nums. */
154
+ export function span(at, dur) {
155
+ return Range.lens([at, dur], (vals) => ({ lo: vals[0], hi: vals[0] + vals[1] }), (target) => [target.lo, target.hi - target.lo]);
156
+ }
157
+ /** Writable `Range` over `[lo, hi]`. Each endpoint is a literal `number`
158
+ * (lifted to a fresh seed) or an existing `Writable<Num>` (identity
159
+ * passthrough). RO sources are rejected at the type level — use
160
+ * `Range.derive(...)` for reactive RO tracking, or `cell.value` to
161
+ * snapshot. Lock an endpoint with `Num.pin(c)`. */
162
+ export function range(lo = 0, hi = 1) {
163
+ if (typeof lo === "number" && typeof hi === "number") {
164
+ return new Range({ lo, hi });
165
+ }
166
+ return ends(num(lo), num(hi));
167
+ }
@@ -0,0 +1,76 @@
1
+ import { Cell, type Init, type Writable } from "../signal.js";
2
+ type V = string;
3
+ export declare const equals: (a: V, b: V) => boolean;
4
+ /** Reverse a string by Unicode code points. */
5
+ export declare const reverseStr: (s: V) => V;
6
+ /** ROT13 cipher. Involutive: `rot13(rot13(s)) === s`. */
7
+ export declare const rot13Str: (s: V) => V;
8
+ /** Per-character case mask: `U` upper letter, `L` lower letter,
9
+ * `" "` non-letter. Length matches the source. */
10
+ export declare function caseMaskOf(s: V): string;
11
+ /** Apply a case mask to `target`, position by position. Mask positions
12
+ * beyond `target.length` are ignored; target positions beyond the
13
+ * mask keep their native case (e.g. user appended a longer word). */
14
+ export declare function applyCaseMask(target: V, mask: string): V;
15
+ /** Apply the case pattern of a source word to a target word. Detects
16
+ * all-upper / all-lower / title case, else falls back to position-wise
17
+ * `applyCaseMask`. Non-letter target chars always pass through unchanged
18
+ * (title-casing "-gng" → "-Gng"); this letter-awareness is what makes
19
+ * GetPut hold when source words contain non-letters. */
20
+ export declare function applyCasePattern(target: V, mask: string): V;
21
+ /** Split `s` into words and separators. Returns:
22
+ *
23
+ * words[i] — the i-th run of word characters
24
+ * seps[0] — leading non-word characters (possibly empty)
25
+ * seps[i] — for 1 ≤ i ≤ words.length-1, the separator BETWEEN
26
+ * `words[i-1]` and `words[i]`
27
+ * seps[words.length] — trailing non-word characters
28
+ *
29
+ * Always satisfies `seps.length === words.length + 1`. */
30
+ export declare function parseWords(s: V): {
31
+ words: V[];
32
+ seps: V[];
33
+ };
34
+ /** Inverse of `parseWords`. Interleaves words with `seps`; added words
35
+ * get `" "` gaps, removed words keep the original trailing separator.
36
+ * A zero-word original (`seps.length === 1`) treats its one entry as
37
+ * lead only, so words append after it without double-counting as trail. */
38
+ export declare function rebuildWords(words: V[], seps: V[]): V;
39
+ export declare class Str extends Cell<V> {
40
+ static traits: {
41
+ equals: (a: V, b: V) => boolean;
42
+ };
43
+ readonly _t: typeof Str.traits;
44
+ constructor(v?: V);
45
+ /** Reverse. Involution. */
46
+ reverse(): this;
47
+ /** ROT13. Involution. */
48
+ rot13(): this;
49
+ /** Trim edge whitespace; the complement restores the original padding
50
+ * on write. Edge whitespace in a write is stripped first (the view's
51
+ * contract is "no edge whitespace", else the complement grows). */
52
+ trim(): Writable<Str>;
53
+ /** Lowercase view with word-aware case recovery on write. Lookup
54
+ * priority: (1) content match — recover the source mask by word
55
+ * (FIFO across duplicates); (2) per-position fallback for new content;
56
+ * (3) native for content beyond the source structure. */
57
+ lowercase(): Writable<Str>;
58
+ /** Uppercase view. Dual of `lowercase`; same per-word case recovery. */
59
+ uppercase(): Writable<Str>;
60
+ /** Words view, one word per line. Read splits on non-word chars; the
61
+ * complement is the separator layout, restored on write (added words
62
+ * get single spaces). Non-word chars typed into a line are stripped —
63
+ * edit `Trimmed` / `Lowercased` / `Source` to add punctuation. */
64
+ words(): Writable<Str>;
65
+ /** Sorted, case-insensitively-unique words, one per line. The
66
+ * complement records each entry's source positions and original case,
67
+ * so editing one line broadcasts to every occurrence in the source —
68
+ * each rebuilt with its own original casing. */
69
+ sortedUnique(): Writable<Str>;
70
+ }
71
+ /** Writable `Str`. Strict factory: literal seeds a fresh cell;
72
+ * existing `Writable<Str>` passes through by identity. RO sources
73
+ * are rejected at the type level — use `Str.derive(...)` for
74
+ * reactive RO tracking, or `cell.value` to snapshot. */
75
+ export declare function str(v?: Init<Str>): Writable<Str>;
76
+ export {};
@@ -0,0 +1,346 @@
1
+ // str.ts — reactive string with a symmetric lens chain.
2
+ //
3
+ // String projections are the canonical use of the engine's stateful-lens
4
+ // primitive (`statefulLens`). Every useful view loses information the
5
+ // engine recovers on write via a per-cell `complement`, so editing through
6
+ // ANY view round-trips with case, whitespace, separators, and duplicate
7
+ // positions preserved (the Foster/Pierce case-preserving find-and-replace
8
+ // demo).
9
+ //
10
+ // `reverse()` and `rot13()` are involutions on the plain endo
11
+ // `.lens(fwd, bwd)` — no complement; they chain like any endo lens.
12
+ // Everything else is `Str.lens(parent, spec)` with a complement:
13
+ //
14
+ // trim — leading + trailing whitespace
15
+ // lowercase — per-character case mask of the source
16
+ // uppercase — dual of lowercase
17
+ // words — separator pattern between words
18
+ // sortedUnique — source positions + original case per unique word
19
+ import { Cell } from "../signal.js";
20
+ // Complement-carrying endo lens.
21
+ //
22
+ // The complement is state recorded forward from the source and consumed
23
+ // on write-back. It persists across the lens's own writes (so `trim`
24
+ // keeps its padding even when the view is emptied) and refreshes on
25
+ // external source changes — the engine's `external` flag drives `step`.
26
+ /** Endo lens backed by a complement recorded from the source. `record`
27
+ * rebuilds the complement (kept on the lens's own writes), `project`
28
+ * is the forward view, `reconstruct` is the backward source. */
29
+ function complementLens(parent, record, project, reconstruct) {
30
+ return Str.lens([parent], {
31
+ init: ([s]) => record(s),
32
+ step: ([s], c, external) => (external ? record(s) : c),
33
+ fwd: ([s]) => project(s),
34
+ bwd: (target, _s, c) => ({ updates: [reconstruct(target, c)], complement: c }),
35
+ });
36
+ }
37
+ export const equals = (a, b) => a === b;
38
+ /** Reverse a string by Unicode code points. */
39
+ export const reverseStr = (s) => [...s].reverse().join("");
40
+ /** ROT13 cipher. Involutive: `rot13(rot13(s)) === s`. */
41
+ export const rot13Str = (s) => s.replace(/[a-zA-Z]/g, c => {
42
+ const code = c.charCodeAt(0);
43
+ const base = code >= 97 ? 97 : 65;
44
+ return String.fromCharCode(((code - base + 13) % 26) + base);
45
+ });
46
+ /** Per-character case mask: `U` upper letter, `L` lower letter,
47
+ * `" "` non-letter. Length matches the source. */
48
+ export function caseMaskOf(s) {
49
+ let mask = "";
50
+ for (let i = 0; i < s.length; i++) {
51
+ const c = s[i];
52
+ if (c >= "A" && c <= "Z")
53
+ mask += "U";
54
+ else if (c >= "a" && c <= "z")
55
+ mask += "L";
56
+ else
57
+ mask += " ";
58
+ }
59
+ return mask;
60
+ }
61
+ /** Apply a case mask to `target`, position by position. Mask positions
62
+ * beyond `target.length` are ignored; target positions beyond the
63
+ * mask keep their native case (e.g. user appended a longer word). */
64
+ export function applyCaseMask(target, mask) {
65
+ let out = "";
66
+ for (let i = 0; i < target.length; i++) {
67
+ const c = target[i];
68
+ const m = i < mask.length ? mask[i] : " ";
69
+ if (m === "U")
70
+ out += c.toUpperCase();
71
+ else if (m === "L")
72
+ out += c.toLowerCase();
73
+ else
74
+ out += c;
75
+ }
76
+ return out;
77
+ }
78
+ const ASCII_LETTER = (c) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z");
79
+ /** Apply the case pattern of a source word to a target word. Detects
80
+ * all-upper / all-lower / title case, else falls back to position-wise
81
+ * `applyCaseMask`. Non-letter target chars always pass through unchanged
82
+ * (title-casing "-gng" → "-Gng"); this letter-awareness is what makes
83
+ * GetPut hold when source words contain non-letters. */
84
+ export function applyCasePattern(target, mask) {
85
+ if (target.length === 0 || mask.length === 0)
86
+ return target;
87
+ const letters = [...mask].filter(c => c === "U" || c === "L");
88
+ if (letters.length === 0)
89
+ return target;
90
+ if (letters.every(c => c === "U"))
91
+ return target.toUpperCase();
92
+ if (letters.every(c => c === "L"))
93
+ return target.toLowerCase();
94
+ if (letters[0] === "U" && letters.slice(1).every(c => c === "L")) {
95
+ // Title case: uppercase the first letter (skipping leading
96
+ // non-letters), lowercase the rest, pass non-letters through.
97
+ let out = "";
98
+ let firstLetterDone = false;
99
+ for (let i = 0; i < target.length; i++) {
100
+ const c = target[i];
101
+ if (ASCII_LETTER(c)) {
102
+ out += firstLetterDone ? c.toLowerCase() : c.toUpperCase();
103
+ firstLetterDone = true;
104
+ }
105
+ else {
106
+ out += c;
107
+ }
108
+ }
109
+ return out;
110
+ }
111
+ return applyCaseMask(target, mask);
112
+ }
113
+ /** A "word" character: letters, digits, underscore, apostrophe, hyphen
114
+ * (handles "don't", "co-op"). Everything else is a separator. */
115
+ const WORD_CHAR = /[\p{L}\p{N}_'-]/u;
116
+ /** Strip every non-word character. Keeps user-typed punctuation in the
117
+ * `words` / `sortedUnique` views from leaking into the source via the
118
+ * separator complement (where it would accumulate across edits). */
119
+ const stripNonWord = (s) => s.replace(/[^\p{L}\p{N}_'-]/gu, "");
120
+ /** Split `s` into words and separators. Returns:
121
+ *
122
+ * words[i] — the i-th run of word characters
123
+ * seps[0] — leading non-word characters (possibly empty)
124
+ * seps[i] — for 1 ≤ i ≤ words.length-1, the separator BETWEEN
125
+ * `words[i-1]` and `words[i]`
126
+ * seps[words.length] — trailing non-word characters
127
+ *
128
+ * Always satisfies `seps.length === words.length + 1`. */
129
+ export function parseWords(s) {
130
+ const words = [];
131
+ const seps = [];
132
+ let cur = "";
133
+ let inWord = false;
134
+ for (let i = 0; i < s.length; i++) {
135
+ const c = s[i];
136
+ if (WORD_CHAR.test(c)) {
137
+ if (!inWord) {
138
+ seps.push(cur);
139
+ cur = "";
140
+ inWord = true;
141
+ }
142
+ cur += c;
143
+ }
144
+ else {
145
+ if (inWord) {
146
+ words.push(cur);
147
+ cur = "";
148
+ inWord = false;
149
+ }
150
+ cur += c;
151
+ }
152
+ }
153
+ if (inWord) {
154
+ words.push(cur);
155
+ seps.push("");
156
+ }
157
+ else {
158
+ seps.push(cur);
159
+ }
160
+ return { words, seps };
161
+ }
162
+ /** Inverse of `parseWords`. Interleaves words with `seps`; added words
163
+ * get `" "` gaps, removed words keep the original trailing separator.
164
+ * A zero-word original (`seps.length === 1`) treats its one entry as
165
+ * lead only, so words append after it without double-counting as trail. */
166
+ export function rebuildWords(words, seps) {
167
+ const n = words.length;
168
+ if (n === 0)
169
+ return seps[0] ?? "";
170
+ const lead = seps[0] ?? "";
171
+ const trail = seps.length > 1 ? (seps[seps.length - 1] ?? "") : "";
172
+ let out = lead;
173
+ for (let i = 0; i < n; i++) {
174
+ out += words[i];
175
+ if (i < n - 1) {
176
+ const idx = i + 1;
177
+ // Interior separators only; the final `seps` entry is the trail.
178
+ const sep = idx < seps.length - 1 ? seps[idx] : undefined;
179
+ out += sep !== undefined ? sep : " ";
180
+ }
181
+ else {
182
+ out += trail;
183
+ }
184
+ }
185
+ return out;
186
+ }
187
+ /** (Re)build the case complement: positional `wordMasks` and content-keyed
188
+ * `byContent` in one pass. `byContent` lists stay in source order for FIFO
189
+ * consumption in `putl`. */
190
+ function refreshCaseComplement(s, c) {
191
+ const { words } = parseWords(s);
192
+ const wordMasks = words.map(caseMaskOf);
193
+ const byContent = new Map();
194
+ for (let i = 0; i < words.length; i++) {
195
+ const key = words[i].toLowerCase();
196
+ let arr = byContent.get(key);
197
+ if (arr === undefined) {
198
+ arr = [];
199
+ byContent.set(key, arr);
200
+ }
201
+ arr.push(wordMasks[i]);
202
+ }
203
+ c.wordMasks = wordMasks;
204
+ c.byContent = byContent;
205
+ }
206
+ /** Apply the case complement to a target string and rebuild. Each
207
+ * target word goes through three lookup tiers — content match
208
+ * (FIFO-consumed from a per-call clone), positional fallback, then
209
+ * native pass-through. */
210
+ function applyCaseComplement(target, c) {
211
+ const { words, seps } = parseWords(target);
212
+ // Per-call clone: consume FIFO without mutating the stored map, so
213
+ // repeated `putl` calls start from the same state.
214
+ const remaining = new Map();
215
+ for (const [k, arr] of c.byContent)
216
+ remaining.set(k, arr.slice());
217
+ const cased = words.map((w, i) => {
218
+ const key = w.toLowerCase();
219
+ const matches = remaining.get(key);
220
+ if (matches !== undefined && matches.length > 0) {
221
+ return applyCasePattern(w, matches.shift());
222
+ }
223
+ const mask = i < c.wordMasks.length ? c.wordMasks[i] : "";
224
+ return mask.length === 0 ? w : applyCasePattern(w, mask);
225
+ });
226
+ return rebuildWords(cased, seps);
227
+ }
228
+ /** Build a fresh case complement from a source string. */
229
+ function buildCaseComplement(s) {
230
+ const c = { wordMasks: [], byContent: new Map() };
231
+ refreshCaseComplement(s, c);
232
+ return c;
233
+ }
234
+ export class Str extends Cell {
235
+ static traits = { equals };
236
+ constructor(v = "") {
237
+ super(v, { equals });
238
+ }
239
+ /** Reverse. Involution. */
240
+ reverse() {
241
+ return this.lens(reverseStr, reverseStr);
242
+ }
243
+ /** ROT13. Involution. */
244
+ rot13() {
245
+ return this.lens(rot13Str, rot13Str);
246
+ }
247
+ /** Trim edge whitespace; the complement restores the original padding
248
+ * on write. Edge whitespace in a write is stripped first (the view's
249
+ * contract is "no edge whitespace", else the complement grows). */
250
+ trim() {
251
+ return complementLens(this, s => {
252
+ const lead = /^\s*/.exec(s)?.[0] ?? "";
253
+ // Slice lead off first so trail can't overlap it on all-whitespace.
254
+ const remain = s.slice(lead.length);
255
+ const trail = /\s*$/.exec(remain)?.[0] ?? "";
256
+ return { lead, trail };
257
+ }, s => {
258
+ const lead = /^\s*/.exec(s)?.[0] ?? "";
259
+ const remain = s.slice(lead.length);
260
+ const trail = /\s*$/.exec(remain)?.[0] ?? "";
261
+ return remain.slice(0, remain.length - trail.length);
262
+ },
263
+ // Edge whitespace in the write is dropped; complement restores pad.
264
+ (target, c) => c.lead + target.replace(/^\s+/, "").replace(/\s+$/, "") + c.trail);
265
+ }
266
+ /** Lowercase view with word-aware case recovery on write. Lookup
267
+ * priority: (1) content match — recover the source mask by word
268
+ * (FIFO across duplicates); (2) per-position fallback for new content;
269
+ * (3) native for content beyond the source structure. */
270
+ lowercase() {
271
+ return complementLens(this, s => buildCaseComplement(s), s => s.toLowerCase(), (target, c) => applyCaseComplement(target, c));
272
+ }
273
+ /** Uppercase view. Dual of `lowercase`; same per-word case recovery. */
274
+ uppercase() {
275
+ return complementLens(this, s => buildCaseComplement(s), s => s.toUpperCase(), (target, c) => applyCaseComplement(target, c));
276
+ }
277
+ /** Words view, one word per line. Read splits on non-word chars; the
278
+ * complement is the separator layout, restored on write (added words
279
+ * get single spaces). Non-word chars typed into a line are stripped —
280
+ * edit `Trimmed` / `Lowercased` / `Source` to add punctuation. */
281
+ words() {
282
+ return complementLens(this, s => ({ separators: parseWords(s).seps }), s => parseWords(s).words.join("\n"), (target, c) => {
283
+ const words = target
284
+ .split(/\n/)
285
+ .map(stripNonWord)
286
+ .filter(w => w.length > 0);
287
+ return rebuildWords(words, c.separators);
288
+ });
289
+ }
290
+ /** Sorted, case-insensitively-unique words, one per line. The
291
+ * complement records each entry's source positions and original case,
292
+ * so editing one line broadcasts to every occurrence in the source —
293
+ * each rebuilt with its own original casing. */
294
+ sortedUnique() {
295
+ return complementLens(this, s => {
296
+ const { words, seps } = parseWords(s);
297
+ const buckets = new Map();
298
+ for (let i = 0; i < words.length; i++) {
299
+ const w = words[i];
300
+ const key = w.toLowerCase();
301
+ let arr = buckets.get(key);
302
+ if (arr === undefined) {
303
+ arr = [];
304
+ buckets.set(key, arr);
305
+ }
306
+ arr.push({ index: i, sourceCase: w });
307
+ }
308
+ const unique = [...buckets.keys()].sort();
309
+ return {
310
+ unique,
311
+ positions: unique.map(k => buckets.get(k)),
312
+ separators: seps,
313
+ sourceWords: words,
314
+ };
315
+ }, s => {
316
+ const { words } = parseWords(s);
317
+ return [...new Set(words.map(w => w.toLowerCase()))].sort().join("\n");
318
+ }, (target, c) => {
319
+ // Strip non-word chars typed into the view, same as `words`.
320
+ const edited = target
321
+ .split(/\n/)
322
+ .map(stripNonWord)
323
+ .filter(w => w.length > 0);
324
+ const sourceWords = c.sourceWords.slice();
325
+ const n = Math.min(edited.length, c.unique.length);
326
+ for (let i = 0; i < n; i++) {
327
+ const newWord = edited[i];
328
+ for (const { index, sourceCase } of c.positions[i]) {
329
+ if (index >= sourceWords.length)
330
+ continue;
331
+ sourceWords[index] = applyCasePattern(newWord, caseMaskOf(sourceCase));
332
+ }
333
+ }
334
+ return rebuildWords(sourceWords, c.separators);
335
+ });
336
+ }
337
+ }
338
+ /** Writable `Str`. Strict factory: literal seeds a fresh cell;
339
+ * existing `Writable<Str>` passes through by identity. RO sources
340
+ * are rejected at the type level — use `Str.derive(...)` for
341
+ * reactive RO tracking, or `cell.value` to snapshot. */
342
+ export function str(v = "") {
343
+ if (v instanceof Str)
344
+ return v;
345
+ return new Str(v);
346
+ }
@@ -0,0 +1,49 @@
1
+ import type { Read, Writable } from "../signal.js";
2
+ import { type Num } from "./num.js";
3
+ import { Str } from "./str.js";
4
+ /** Textual codec for a slot value. `parse` returns `undefined` to reject. */
5
+ export interface Codec<T> {
6
+ format(v: T): string;
7
+ parse(s: string): T | undefined;
8
+ }
9
+ /** Identity codec for string slots. */
10
+ export declare const strCodec: Codec<string>;
11
+ /** Numeric codec; `int` rounds on both read and write. Rejects non-finite. */
12
+ export declare const numCodec: (int?: boolean) => Codec<number>;
13
+ /** Enumerated string codec: accepts only members of `options`. */
14
+ export declare const enumCodec: (options: readonly string[]) => Codec<string>;
15
+ /** A typed hole: the parent cell plus its `string ⇄ T` codec. */
16
+ export interface Slot<T> {
17
+ name: string;
18
+ cell: Writable<Read<T>>;
19
+ codec: Codec<T>;
20
+ }
21
+ /** Slot constructors that infer the codec from the cell's value class. */
22
+ export declare const slot: {
23
+ str: (cell: Writable<Str>, name?: string) => Slot<string>;
24
+ num: (cell: Writable<Num>, name?: string) => Slot<number>;
25
+ int: (cell: Writable<Num>, name?: string) => Slot<number>;
26
+ pick: (cell: Writable<Str>, options: readonly string[], name?: string) => Slot<string>;
27
+ };
28
+ /** Core builder: `literals.length === slots.length + 1`. Returns the
29
+ * rendered text as a `Writable<Str>` that parses back into the slots. */
30
+ export declare function template(literals: readonly string[], slots: readonly Slot<unknown>[]): Writable<Str>;
31
+ /** Tagged template; interpolate `slot.*` holes. Bring your own cells:
32
+ *
33
+ * tpl`Hello ${slot.str(name)}, ×${slot.int(count)}` */
34
+ export declare function tpl(strings: TemplateStringsArray, ...slots: Slot<unknown>[]): Writable<Str>;
35
+ type ParamKind = "str" | "int" | "num";
36
+ type ParamCell<K extends ParamKind> = K extends "str" ? Writable<Str> : Writable<Num>;
37
+ /** Parse a `:name` pattern against a kind schema, owning fresh slot cells.
38
+ * Returns the rendered URL `text` and the typed `params` cells; edit
39
+ * either side and the other stays in sync.
40
+ *
41
+ * const { text, params } = route("/users/:id/posts/:slug",
42
+ * { id: "int", slug: "str" }); */
43
+ export declare function route<S extends Record<string, ParamKind>>(pattern: string, schema: S): {
44
+ text: Writable<Str>;
45
+ params: {
46
+ [K in keyof S]: ParamCell<S[K]>;
47
+ };
48
+ };
49
+ export {};