@glissade/scene 0.34.0 → 0.35.0-pre.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/dist/describe.js CHANGED
@@ -22,7 +22,7 @@ import { easings, listValueTypes } from "@glissade/core";
22
22
  * never pulled onto the base embed path — a scene that never calls `describe()`
23
23
  * pays zero bytes for it.
24
24
  */
25
- const RAW_VERSION = "0.34.0";
25
+ const RAW_VERSION = "0.35.0-pre.0";
26
26
  const PACKAGE_VERSION = RAW_VERSION.includes("GLISSADE_".concat("VERSION")) ? "0.0.0-dev" : RAW_VERSION;
27
27
  /** Arity of a value type's numeric repr: vec2/vec2-arc → 2, number → 1; others (color/paint/path/string/boolean) carry no scalar arity. */
28
28
  function arityOf(type) {
@@ -76,6 +76,7 @@ const CONSTRUCTION_PROP_META = {
76
76
  fontWeight: { type: "number" },
77
77
  fontStyle: { type: "'normal'|'italic'" },
78
78
  align: { type: "'left'|'center'|'right'" },
79
+ box: { type: "{ valign: 'center'|'top'|'bottom', h? }" },
79
80
  lineHeight: { type: "number" },
80
81
  letterSpacing: { type: "number" },
81
82
  fontVariationSettings: { type: "string" }
@@ -397,6 +398,18 @@ const HELPERS = [
397
398
  import: "@glissade/scene/type",
398
399
  usage: "splitText(text: Text | TextProps, opts?: { by?: 'word'|'line'|'grapheme', id?: string, measurer?: TextMeasurer }): { node: Group, children: Text[], parts: SplitPart[], targets(prop): string[] }"
399
400
  },
401
+ {
402
+ name: "fitText",
403
+ summary: "Shrink-to-fit: set a Text's fontSize to the largest that wraps within maxW to <= maxLines / <= maxH (a build-time binary search over the measurer, like Grid/splitText). Fails loud if it can't fit even at minPx (or pass onOverflow:'clamp'). Pass { measurer } for exact fit. Tree-shaken off the base scene index.",
404
+ import: "@glissade/scene/type",
405
+ usage: "fitText(text: Text, opts: { maxW: number, maxH?, maxLines?, minPx?, onOverflow?: 'throw'|'clamp', measurer? }): Text"
406
+ },
407
+ {
408
+ name: "fitTextGroup",
409
+ summary: "Fit several Texts to ONE shared fontSize (the largest at which every one fits its box) so a row/list of labels renders uniformly — kills the ragged 'same list, three sizes' bug. Returns the shared size. On the @glissade/scene/type subpath.",
410
+ import: "@glissade/scene/type",
411
+ usage: "fitTextGroup(texts: Text[], opts: { maxW: number, minPx?, measurer? }): number"
412
+ },
400
413
  {
401
414
  name: "Grid",
402
415
  summary: "Build-time CSS-grid-style track resolver: position plain children into a column grid (fr/px tracks + gaps), returning a Group. Pure fan-out (no Yoga, no new target) — the goldens hold by construction. Tree-shaken off the base scene index.",
package/dist/nodes.d.ts CHANGED
@@ -745,6 +745,19 @@ interface TextProps extends NodeProps {
745
745
  fontAxes?: PropInit<FontAxes>;
746
746
  /** Horizontal alignment about the node position; default 'left'. */
747
747
  align?: 'left' | 'center' | 'right';
748
+ /**
749
+ * VERTICAL anchoring in a box (0.35). Text is baseline-anchored by default;
750
+ * `box: { valign: 'center' }` instead centers the text's real INK (ascent +
751
+ * descent from the measurer, single- AND multi-line) on the node position —
752
+ * killing the `fontSize * 0.35` fudge every boxed-text component hand-rolls.
753
+ * `'top'`/`'bottom'` frame the ink at the top/bottom of an `h`-tall box
754
+ * centered on the position (pass `h`). OMITTED ⇒ baseline (byte-identical
755
+ * default). Construction-only; needs the scene measurer (like wrapping).
756
+ */
757
+ box?: {
758
+ valign: 'center' | 'top' | 'bottom';
759
+ h?: number;
760
+ };
748
761
  /** Wrap width in px; unset = no wrapping (explicit \n still breaks). */
749
762
  width?: PropInit<number>;
750
763
  /** Line height as a multiple of fontSize; default 1.25. */
@@ -793,6 +806,11 @@ declare class Text extends Node {
793
806
  * When non-empty, overrides {@link fontVariationSettings} in the FontSpec. */
794
807
  readonly fontAxes: BindableSignal<FontAxes>;
795
808
  readonly align: 'left' | 'center' | 'right';
809
+ /** vertical box anchoring (0.35); undefined = baseline-anchored (default). */
810
+ readonly box?: {
811
+ valign: 'center' | 'top' | 'bottom';
812
+ h?: number;
813
+ };
796
814
  readonly width: BindableSignal<number>;
797
815
  readonly lineHeight: number;
798
816
  /** Static letter-spacing (tracking) in px; undefined = none. */
@@ -831,6 +849,14 @@ declare class Text extends Node {
831
849
  x: number;
832
850
  y: number;
833
851
  };
852
+ /**
853
+ * 0.35 box-valign: the y offset added to the fillText line grid so the text's
854
+ * real INK (ascent+descent, single- OR multi-line) anchors vertically per
855
+ * `box.valign` — the ink-metric answer to the `fontSize * 0.35` fudge. 0 when
856
+ * no `box` is set, so the default draw is byte-identical. Shared by draw() and
857
+ * lineBoxes() so highlights/reveals follow the shifted text.
858
+ */
859
+ private valignOffset;
834
860
  /**
835
861
  * The wrapped box {w, h}, measured with the scene's active measurer — the
836
862
  * same numbers Layout flows with, public so bindings never hand-calculate
package/dist/nodes.js CHANGED
@@ -480,7 +480,8 @@ const NODE_CONSTRUCTION_PROP_NAMES = {
480
480
  "align",
481
481
  "lineHeight",
482
482
  "fontVariationSettings",
483
- "letterSpacing"
483
+ "letterSpacing",
484
+ "box"
484
485
  ],
485
486
  Image: ["assetId"],
486
487
  Video: [
@@ -1904,6 +1905,8 @@ var Text = class Text extends Node {
1904
1905
  * When non-empty, overrides {@link fontVariationSettings} in the FontSpec. */
1905
1906
  fontAxes;
1906
1907
  align;
1908
+ /** vertical box anchoring (0.35); undefined = baseline-anchored (default). */
1909
+ box;
1907
1910
  width;
1908
1911
  lineHeight;
1909
1912
  /** Static letter-spacing (tracking) in px; undefined = none. */
@@ -1926,6 +1929,7 @@ var Text = class Text extends Node {
1926
1929
  this.fontVariationSettings = props.fontVariationSettings;
1927
1930
  this.fontAxes = initProp(signal({}), props.fontAxes);
1928
1931
  this.align = props.align ?? "left";
1932
+ if (props.box !== void 0) this.box = props.box;
1929
1933
  this.width = initProp(signal(0), props.width);
1930
1934
  this.lineHeight = props.lineHeight ?? 1.25;
1931
1935
  this.letterSpacing = props.letterSpacing;
@@ -2001,6 +2005,33 @@ var Text = class Text extends Node {
2001
2005
  };
2002
2006
  }
2003
2007
  /**
2008
+ * 0.35 box-valign: the y offset added to the fillText line grid so the text's
2009
+ * real INK (ascent+descent, single- OR multi-line) anchors vertically per
2010
+ * `box.valign` — the ink-metric answer to the `fontSize * 0.35` fudge. 0 when
2011
+ * no `box` is set, so the default draw is byte-identical. Shared by draw() and
2012
+ * lineBoxes() so highlights/reveals follow the shifted text.
2013
+ */
2014
+ valignOffset(m) {
2015
+ if (this.box === void 0) return 0;
2016
+ const font = this.fontSpec();
2017
+ const lines = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, m);
2018
+ const step = quantize(font.size * this.lineHeight);
2019
+ let inkTop = Infinity;
2020
+ let inkBottom = -Infinity;
2021
+ for (let i = 0; i < lines.length; i++) {
2022
+ const line = lines[i];
2023
+ if (!line) continue;
2024
+ const met = m.measureText(line, font);
2025
+ inkTop = Math.min(inkTop, i * step - met.ascent);
2026
+ inkBottom = Math.max(inkBottom, i * step + met.descent);
2027
+ }
2028
+ if (!Number.isFinite(inkTop)) return 0;
2029
+ const h = this.box.h ?? inkBottom - inkTop;
2030
+ if (this.box.valign === "center") return -(inkTop + inkBottom) / 2;
2031
+ if (this.box.valign === "top") return -h / 2 - inkTop;
2032
+ return h / 2 - inkBottom;
2033
+ }
2034
+ /**
2004
2035
  * The wrapped box {w, h}, measured with the scene's active measurer — the
2005
2036
  * same numbers Layout flows with, public so bindings never hand-calculate
2006
2037
  * text dimensions (e.g. underline width = () => title.measuredSize().w).
@@ -2024,6 +2055,7 @@ var Text = class Text extends Node {
2024
2055
  const maxWidth = this.width();
2025
2056
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, m);
2026
2057
  const step = quantize(font.size * this.lineHeight);
2058
+ const vy = this.valignOffset(m);
2027
2059
  const boxes = [];
2028
2060
  for (let i = 0; i < lines.length; i++) {
2029
2061
  const line = lines[i];
@@ -2034,7 +2066,7 @@ var Text = class Text extends Node {
2034
2066
  boxes.push({
2035
2067
  text: line,
2036
2068
  x,
2037
- y: i * step - met.ascent,
2069
+ y: i * step - met.ascent + vy,
2038
2070
  w,
2039
2071
  h: met.ascent + met.descent
2040
2072
  });
@@ -2214,6 +2246,7 @@ var Text = class Text extends Node {
2214
2246
  const maxWidth = this.width();
2215
2247
  const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
2216
2248
  const step = quantize(font.size * this.lineHeight);
2249
+ const vy = this.valignOffset(ctx.measurer);
2217
2250
  const revealRaw = this.effectiveReveal(ctx.measurer);
2218
2251
  const masked = Number.isFinite(revealRaw);
2219
2252
  let remaining = masked ? Math.max(0, Math.floor(revealRaw)) : 0;
@@ -2230,7 +2263,7 @@ var Text = class Text extends Node {
2230
2263
  color: this.fill()
2231
2264
  },
2232
2265
  x: 0,
2233
- y: i * step,
2266
+ y: i * step + vy,
2234
2267
  ...this.align !== "left" ? { align: this.align } : {}
2235
2268
  });
2236
2269
  continue;
@@ -2248,7 +2281,7 @@ var Text = class Text extends Node {
2248
2281
  color: this.fill()
2249
2282
  },
2250
2283
  x: 0,
2251
- y: i * step,
2284
+ y: i * step + vy,
2252
2285
  ...this.align !== "left" ? { align: this.align } : {}
2253
2286
  });
2254
2287
  else {
@@ -2263,7 +2296,7 @@ var Text = class Text extends Node {
2263
2296
  color: this.fill()
2264
2297
  },
2265
2298
  x: lineX,
2266
- y: i * step
2299
+ y: i * step + vy
2267
2300
  });
2268
2301
  }
2269
2302
  }
package/dist/type.d.ts CHANGED
@@ -73,5 +73,41 @@ declare class SplitTextError extends Error {
73
73
  * part geometry — see the dev-warning footgun below.
74
74
  */
75
75
  declare function splitText(source: Text | TextProps, opts?: SplitTextOpts): SplitTextResult;
76
+ interface FitTextOpts {
77
+ /** wrap/measure width (px). Required — text wraps to this and never exceeds it. */
78
+ maxW: number;
79
+ /** cap the wrapped height (px). Optional — combine with maxLines. */
80
+ maxH?: number;
81
+ /** cap the number of wrapped lines. Optional. */
82
+ maxLines?: number;
83
+ /** never shrink below this (px). Default 6. Below it, fitText throws (fail loud). */
84
+ minPx?: number;
85
+ /** if the text can't fit even at minPx: 'throw' (default) or 'clamp' to minPx. */
86
+ onOverflow?: 'throw' | 'clamp';
87
+ /** measurer for exact fit — pass one (or call setTextMeasurer first), else the
88
+ * estimating fallback is used with a one-time dev warning (the splitText footgun). */
89
+ measurer?: TextMeasurer;
90
+ }
91
+ /**
92
+ * The largest integer-px fontSize ≤ the text's current size at which it fits the
93
+ * box — via a binary search over `measureWrappedText` (pure, no runtime state).
94
+ * The build-time answer to "shrink this to fit its container" the hand-rolled
95
+ * shrink loops re-implemented per component.
96
+ */
97
+ declare function fitTextSize(text: Text, opts: FitTextOpts): number;
98
+ /**
99
+ * Shrink `text` to fit its box: sets its `fontSize` to `fitTextSize(...)` and
100
+ * returns it (a plain `signal.set`, so a later explicit bind still wins — the
101
+ * Grid()/splitText() mutate-and-return convention). Also sets `width` to maxW so
102
+ * the node wraps to the same box it was fitted against.
103
+ */
104
+ declare function fitText(text: Text, opts: FitTextOpts): Text;
105
+ /**
106
+ * Fit several texts to ONE shared size — the largest px at which EVERY text fits
107
+ * its box — so a row/list of labels renders uniformly (kills the "same list, three
108
+ * different sizes" ragged-headers bug). Each text may carry its own maxW; a single
109
+ * `maxW` applies to all. Returns the shared size.
110
+ */
111
+ declare function fitTextGroup(texts: readonly Text[], opts: FitTextOpts): number;
76
112
  //#endregion
77
- export { type GraphemeBox, type LineBox, SplitBy, SplitPart, SplitTextError, SplitTextOpts, SplitTextResult, type WordBox, splitText };
113
+ export { FitTextOpts, type GraphemeBox, type LineBox, SplitBy, SplitPart, SplitTextError, SplitTextOpts, SplitTextResult, type WordBox, fitText, fitTextGroup, fitTextSize, splitText };
package/dist/type.js CHANGED
@@ -1,4 +1,4 @@
1
- import { L as quantize, P as fallbackMeasurer, V as warnIfEstimating, r as Group, s as Text } from "./nodes.js";
1
+ import { I as measureWrappedText, L as quantize, P as fallbackMeasurer, V as warnIfEstimating, r as Group, s as Text } from "./nodes.js";
2
2
  //#region src/type.ts
3
3
  /**
4
4
  * `@glissade/scene/type` — `splitText()`: build-time split-text sub-targets
@@ -131,5 +131,82 @@ function splitText(source, opts = {}) {
131
131
  targets
132
132
  };
133
133
  }
134
+ /** Build a measurement FontSpec for `text` at a candidate size (public fields only). */
135
+ function fontAt(text, size) {
136
+ return {
137
+ family: text.fontFamily,
138
+ size,
139
+ weight: text.fontWeight,
140
+ ...text.fontStyle === "italic" ? { style: "italic" } : {},
141
+ ...text.letterSpacing !== void 0 ? { letterSpacing: text.letterSpacing } : {}
142
+ };
143
+ }
144
+ /** True if `text` wraps within maxW to ≤ maxLines and ≤ maxH at this fontSize. */
145
+ function fits(text, size, opts, m) {
146
+ const font = fontAt(text, size);
147
+ const met = measureWrappedText(text.text(), font, opts.maxW, text.lineHeight, m);
148
+ if (opts.maxLines !== void 0 && met.lines.length > opts.maxLines) return false;
149
+ if (opts.maxH !== void 0 && met.height > opts.maxH) return false;
150
+ for (const line of met.lines) if (m.measureText(line, font).width > opts.maxW + .5) return false;
151
+ return true;
152
+ }
153
+ /**
154
+ * The largest integer-px fontSize ≤ the text's current size at which it fits the
155
+ * box — via a binary search over `measureWrappedText` (pure, no runtime state).
156
+ * The build-time answer to "shrink this to fit its container" the hand-rolled
157
+ * shrink loops re-implemented per component.
158
+ */
159
+ function fitTextSize(text, opts) {
160
+ const m = opts.measurer ?? text.measurerSource?.() ?? fallbackMeasurer();
161
+ warnIfEstimating(m, "fitText");
162
+ const minPx = opts.minPx ?? 6;
163
+ const hi = Math.max(minPx, Math.floor(text.fontSize()));
164
+ if (fits(text, hi, opts, m)) return hi;
165
+ if (!fits(text, minPx, opts, m)) {
166
+ if (opts.onOverflow === "clamp") return minPx;
167
+ throw new Error(`fitText: '${text.text().slice(0, 40)}${text.text().length > 40 ? "…" : ""}' does not fit ${opts.maxW}px${opts.maxLines !== void 0 ? ` in ${opts.maxLines} line(s)` : ""} even at minPx=${minPx} — raise maxW/maxLines/maxH, lower minPx, or pass { onOverflow: 'clamp' }`);
168
+ }
169
+ let lo = minPx;
170
+ let best = minPx;
171
+ let hiN = hi;
172
+ while (lo <= hiN) {
173
+ const mid = lo + hiN >> 1;
174
+ if (fits(text, mid, opts, m)) {
175
+ best = mid;
176
+ lo = mid + 1;
177
+ } else hiN = mid - 1;
178
+ }
179
+ return best;
180
+ }
181
+ /**
182
+ * Shrink `text` to fit its box: sets its `fontSize` to `fitTextSize(...)` and
183
+ * returns it (a plain `signal.set`, so a later explicit bind still wins — the
184
+ * Grid()/splitText() mutate-and-return convention). Also sets `width` to maxW so
185
+ * the node wraps to the same box it was fitted against.
186
+ */
187
+ function fitText(text, opts) {
188
+ const size = fitTextSize(text, opts);
189
+ text.fontSize.set(size);
190
+ text.width.set(opts.maxW);
191
+ return text;
192
+ }
193
+ /**
194
+ * Fit several texts to ONE shared size — the largest px at which EVERY text fits
195
+ * its box — so a row/list of labels renders uniformly (kills the "same list, three
196
+ * different sizes" ragged-headers bug). Each text may carry its own maxW; a single
197
+ * `maxW` applies to all. Returns the shared size.
198
+ */
199
+ function fitTextGroup(texts, opts) {
200
+ if (texts.length === 0) throw new Error("fitTextGroup needs at least one text");
201
+ const shared = Math.min(...texts.map((t) => fitTextSize(t, {
202
+ ...opts,
203
+ onOverflow: "clamp"
204
+ })));
205
+ for (const t of texts) {
206
+ t.fontSize.set(shared);
207
+ t.width.set(opts.maxW);
208
+ }
209
+ return shared;
210
+ }
134
211
  //#endregion
135
- export { SplitTextError, splitText };
212
+ export { SplitTextError, fitText, fitTextGroup, fitTextSize, splitText };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glissade/scene",
3
- "version": "0.34.0",
3
+ "version": "0.35.0-pre.0",
4
4
  "description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -69,7 +69,7 @@
69
69
  ],
70
70
  "dependencies": {
71
71
  "yoga-layout": "^3.2.1",
72
- "@glissade/core": "0.34.0"
72
+ "@glissade/core": "0.35.0-pre.0"
73
73
  },
74
74
  "repository": {
75
75
  "type": "git",