@juspay/svelte-ui-components 4.33.2 → 4.34.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 (50) hide show
  1. package/dist/Accordion/Accordion.svelte +26 -2
  2. package/dist/Accordion/properties.d.ts +7 -0
  3. package/dist/ArmedButton/ArmedButton.svelte +147 -0
  4. package/dist/ArmedButton/ArmedButton.svelte.d.ts +4 -0
  5. package/dist/ArmedButton/properties.d.ts +24 -0
  6. package/dist/ArmedButton/properties.js +1 -0
  7. package/dist/Banner/Banner.svelte +13 -8
  8. package/dist/Banner/properties.d.ts +8 -1
  9. package/dist/Button/Button.svelte +26 -0
  10. package/dist/Button/properties.d.ts +17 -0
  11. package/dist/ChatComposer/ChatComposer.svelte +314 -1
  12. package/dist/ChatComposer/properties.d.ts +42 -0
  13. package/dist/ChatMessageList/ChatMessageList.svelte +22 -2
  14. package/dist/ChatMessageList/ChatMessageList.svelte.d.ts +1 -1
  15. package/dist/CommandMenu/CommandMenu.svelte +53 -20
  16. package/dist/CommandMenu/properties.d.ts +24 -0
  17. package/dist/DiffViewer/DiffViewer.svelte +210 -0
  18. package/dist/DiffViewer/DiffViewer.svelte.d.ts +4 -0
  19. package/dist/DiffViewer/properties.d.ts +25 -0
  20. package/dist/DiffViewer/properties.js +1 -0
  21. package/dist/EmptyState/EmptyState.svelte +22 -3
  22. package/dist/EmptyState/properties.d.ts +9 -0
  23. package/dist/HITL/HITL.svelte +318 -49
  24. package/dist/HITL/properties.d.ts +113 -11
  25. package/dist/Input/Input.svelte +45 -1
  26. package/dist/Input/properties.d.ts +15 -0
  27. package/dist/KeyValue/KeyValue.svelte +16 -3
  28. package/dist/KeyValue/properties.d.ts +2 -0
  29. package/dist/Progress/Progress.svelte +96 -41
  30. package/dist/Progress/properties.d.ts +19 -0
  31. package/dist/Snippet/Snippet.svelte +45 -1
  32. package/dist/Snippet/properties.d.ts +8 -0
  33. package/dist/Tabs/Tabs.svelte +75 -16
  34. package/dist/Tabs/properties.d.ts +21 -0
  35. package/dist/ThinkingIndicator/ThinkingIndicator.svelte.d.ts +1 -1
  36. package/dist/ThinkingOrb/orb/modes/form.js +30 -3
  37. package/dist/ThinkingOrb/orb/modes/network.d.ts +4 -0
  38. package/dist/ThinkingOrb/orb/modes/network.js +33 -1
  39. package/dist/ThinkingOrb/orb/modes/sphere.js +69 -73
  40. package/dist/Toolbar/Toolbar.svelte +81 -3
  41. package/dist/Toolbar/properties.d.ts +37 -0
  42. package/dist/WindowedList/WindowedList.svelte +129 -0
  43. package/dist/WindowedList/WindowedList.svelte.d.ts +25 -0
  44. package/dist/WindowedList/properties.d.ts +38 -0
  45. package/dist/WindowedList/properties.js +1 -0
  46. package/dist/index.d.ts +6 -0
  47. package/dist/index.js +3 -0
  48. package/dist/styles/theme-dark.css +42 -0
  49. package/dist-wc/index.js +2714 -1708
  50. package/package.json +2 -1
@@ -11,7 +11,7 @@
11
11
  * split keeps "the shape's own motion" and "the camera's fixed angle on it"
12
12
  * from tangling together in the per-dot math.
13
13
  */
14
- import { easeInOutCubic, hashUnit, modeSizing, project, rotateX, rotateY, rotateZ } from '../space';
14
+ import { easeInOutCubic, hashUnit, modeSizing, project, rotateX, rotateY, rotateZ, sampleSphere } from '../space';
15
15
  const TWO_PI = Math.PI * 2;
16
16
  /** The shared "reads as a form inside a square" contract: radius as a share of `size`. */
17
17
  const FORM_RADIUS_RATIO = 0.4;
@@ -45,7 +45,11 @@ const EMPTY_STROKES = [];
45
45
  // --- composing ---------------------------------------------------------
46
46
  /** Dots along each thread at 64px; `countAlongPath` keeps their spacing as the canvas shrinks. */
47
47
  const RIBBON_PER_THREAD_BASE = 36;
48
- const RIBBON_BASE_DOT_RADIUS = 2.2;
48
+ /** Reference parity (BZ-6466 follow-up): upstream thinking-orbs' `ribbon` mode resolves to a
49
+ * ~0.9-1.1px base dot radius at 64px (its BASE_PROFILES rBase 1.1, x0.85 for this preset's `size`
50
+ * multiplier). This used to carry 2.2px, exactly the radius that fused composing's dots into a
51
+ * solid corrugated band instead of the reference's fine, individually-visible beads. */
52
+ const RIBBON_BASE_DOT_RADIUS = 1.2;
49
53
  /** Fewer, narrower threads than a full band: at 7 threads across 0.4 rad the 64px ribbon covered
50
54
  * most of the sphere and read as a filled ball rather than a strip of cloth. */
51
55
  const RIBBON_THREADS = { 64: 5, 32: 4, 20: 3 };
@@ -68,6 +72,16 @@ const RIBBON_CAMERA_TILT = 0.45;
68
72
  * combined ink/alpha/radius fade would collapse an edge row to a speck. */
69
73
  const RIBBON_EDGE_ALPHA_FLOOR = 0.5;
70
74
  const RIBBON_EDGE_INK_LIFT = 0.22;
75
+ /** Reference parity (BZ-6466 follow-up): upstream's `ribbon` mode fills the sphere behind the band
76
+ * with a faint, fully static "ghost" texture (150 dots at 64px, this preset's `spin: 0` freezing
77
+ * even the shared camera precession that would otherwise turn it). Ours had no such backing layer,
78
+ * so the ribbon read as a thin strip floating in empty space rather than cloth wrapped on a solid
79
+ * form -- the ribbon's own turn was the only motion on screen, with nothing to anchor it against. */
80
+ const RIBBON_GHOST_BASE_COUNT = 110;
81
+ const RIBBON_GHOST_RADIUS_PX = 0.5;
82
+ const RIBBON_GHOST_INK = 0.3;
83
+ const RIBBON_GHOST_ALPHA_BASE = 0.16;
84
+ const RIBBON_GHOST_ALPHA_DEPTH = 0.22;
71
85
  /**
72
86
  * A ribbon of dots looped once around the sphere on its own tilted great
73
87
  * circle -- distinct from the spin axis -- rippling along its length like a
@@ -80,7 +94,20 @@ export const composing = (t, ctx) => {
80
94
  const threads = RIBBON_THREADS[ctx.size];
81
95
  const perThread = sizing.countAlongPath(RIBBON_PER_THREAD_BASE);
82
96
  const total = threads * perThread;
83
- const dots = [];
97
+ const ghostCount = Math.max(20, sizing.count(RIBBON_GHOST_BASE_COUNT));
98
+ const dots = Array.from({ length: ghostCount }, (_unused, i) => {
99
+ const point = sampleSphere(i, ghostCount, worldRadius);
100
+ const tilted = rotateX(point, RIBBON_CAMERA_TILT);
101
+ const projected = project(tilted, originPx, worldRadius);
102
+ return {
103
+ x: projected.x,
104
+ y: projected.y,
105
+ depth: projected.depth,
106
+ radius: sizing.radius(RIBBON_GHOST_RADIUS_PX),
107
+ ink: RIBBON_GHOST_INK,
108
+ alpha: clamp01(RIBBON_GHOST_ALPHA_BASE + RIBBON_GHOST_ALPHA_DEPTH * projected.depth)
109
+ };
110
+ });
84
111
  for (let index = 0; index < total; index += 1) {
85
112
  const thread = index % threads;
86
113
  const step = Math.floor(index / threads);
@@ -6,7 +6,11 @@
6
6
  * projection setup below.
7
7
  */
8
8
  import type { ModeFn } from '../types';
9
+ import type { ModeSizing } from '../space';
9
10
  /** A few nodes on a sphere; lines to near neighbours carry small travelling pulses. */
10
11
  export declare const connecting: ModeFn;
12
+ /** Exported so tests that index `weaving`'s strand dots can reproduce and slice off this count,
13
+ * rather than duplicating the formula as a hardcoded literal. */
14
+ export declare const weavingGhostCount: (sizing: ModeSizing) => number;
11
15
  /** Three closed strands wrapped around a sphere, crossing over and under each other as they turn. */
12
16
  export declare const weaving: ModeFn;
@@ -144,6 +144,21 @@ const STRAND_DOT_SIZE_FACTOR = { 64: 1, 32: 0.85, 20: 0.7 };
144
144
  const STRAND_DEPTH_INK = 0.45;
145
145
  /** A slight per-strand shade; the paths and their over/under crossings are what separate them. */
146
146
  const STRAND_SHADE_STEP = 0.1;
147
+ /** Reference parity (BZ-6466 follow-up): upstream's `braid` mode fills the sphere behind the
148
+ * strands with a faint, static-in-object-space "ghost" texture (150 dots at 64px) that shares the
149
+ * strands' own turn, so the strands read as plaiting round a solid ball rather than floating loops
150
+ * in empty space. Ours had no such backing layer. Always appended after every strand's dots, so
151
+ * code and tests that index `dots` by strand can slice it off by a known, reproducible count. */
152
+ const STRAND_GHOST_BASE_COUNT = 110;
153
+ const STRAND_GHOST_RADIUS_PX = 0.5;
154
+ const STRAND_GHOST_INK = 0.3;
155
+ /** With STRAND_GHOST_INK 0.3, `inkAlpha` = (1-ink)*alpha = 0.7*alpha, so this needs to clear
156
+ * 0.1/0.7 for the "nothing paints below 10% opacity" contract even on a ghost dot's far side. */
157
+ const STRAND_GHOST_ALPHA_BASE = 0.16;
158
+ const STRAND_GHOST_ALPHA_DEPTH = 0.22;
159
+ /** Exported so tests that index `weaving`'s strand dots can reproduce and slice off this count,
160
+ * rather than duplicating the formula as a hardcoded literal. */
161
+ export const weavingGhostCount = (sizing) => Math.max(20, sizing.count(STRAND_GHOST_BASE_COUNT));
147
162
  const strandBaseAngle = (strand) => (strand / STRAND_COUNT) * Math.PI * 2;
148
163
  /**
149
164
  * The `pointIndex`-th of `pointsPerStrand` points around closed `strand`: a
@@ -192,5 +207,22 @@ export const weaving = (t, ctx) => {
192
207
  alpha: 0.6 + depth * 0.35
193
208
  };
194
209
  }));
195
- return { dots, strokes };
210
+ // Shares the strands' own spin+tilt (not their per-strand inclination/phase, which is strand
211
+ // geometry, not camera) so the backdrop turns together with what it is behind.
212
+ const ghostCount = weavingGhostCount(sizing);
213
+ const ghosts = Array.from({ length: ghostCount }, (_unused, i) => {
214
+ const point = sampleSphere(i, ghostCount, sphereRadius);
215
+ const spun = rotateY(point, t * STRAND_ROTATE_SPEED);
216
+ const tilted = rotateX(spun, STRAND_TILT);
217
+ const projected = project(tilted, originPx, sphereRadius);
218
+ return {
219
+ x: projected.x,
220
+ y: projected.y,
221
+ depth: projected.depth,
222
+ radius: sizing.radius(STRAND_GHOST_RADIUS_PX),
223
+ ink: STRAND_GHOST_INK,
224
+ alpha: clamp01(STRAND_GHOST_ALPHA_BASE + STRAND_GHOST_ALPHA_DEPTH * projected.depth)
225
+ };
226
+ });
227
+ return { dots: [...dots, ...ghosts], strokes };
196
228
  };
@@ -44,19 +44,33 @@ const toStroke = (from, to, width, ink, alpha) => ({
44
44
  /** Particles travel in short trailing arcs ("trains") rather than scattering
45
45
  * independently around their orbit -- a lone random phase per particle reads
46
46
  * as noise once density thins the count out at 32/20px, while a handful of
47
- * particles held close together always reads as motion along a path. */
48
- const WORKING_TRAIN_BASE_COUNT = 22;
47
+ * particles held close together always reads as motion along a path.
48
+ *
49
+ * Reference parity (BZ-6466 follow-up): the upstream thinking-orbs `orbits`
50
+ * mode this was ported from draws 12 orbits x 3 particles = 36 marks at
51
+ * 64px, radius ~1.2px, and reads as a sparse field of distinct particles.
52
+ * The previous tuning here (22 trains x 5-long, radius 2.2px = 110 marks)
53
+ * packed 3x the marks at ~1.7x the radius into the same arcs, so consecutive
54
+ * dots overlapped their own diameter and the whole thing fused into a
55
+ * blurred scribble instead of scattered points. */
56
+ const WORKING_TRAIN_BASE_COUNT = 12;
49
57
  /** Keeps at least this many short arcs on screen at 64px (32/20px use WORKING_SMALL_LAYOUT). The
50
58
  * area scaler alone already clears it at the default density; it only bites when density is low. */
51
59
  const WORKING_MIN_TRAINS_64 = 3;
52
- const WORKING_TRAIN_LENGTH = 5;
60
+ const WORKING_TRAIN_LENGTH = 3;
53
61
  /** How far apart, in radians, a train's particles trail behind its lead --
54
62
  * wide enough to read as a short curved arc, short enough to stay a cluster
55
63
  * rather than smearing back into the full-orbit scatter this replaces. */
56
64
  const WORKING_TRAIN_ARC_SPAN = 0.5;
57
- /** At 1.6, with a +/-25% jitter, 20px dots came out 0.6-1px across and the arcs blurred into a
58
- * smudge; this and the narrower jitter below keep a train a visible stroke at every size. */
59
- const WORKING_BASE_RADIUS_PX = 2.2;
65
+ /** Reference's `orbits` mode draws particles at ~1.2px on a 64px canvas but scales them back UP
66
+ * at smaller sizes (2.4x at 20px) rather than shrinking further, so they stay legible once there's
67
+ * less room for arc spacing to keep them apart. `sizing.radius`'s sub-linear shrink-with-a-floor
68
+ * goes the other way, so the two sizes need their own base rather than one shared constant. */
69
+ const WORKING_BASE_RADIUS_PX = 1.3;
70
+ /** 32/20px (WORKING_SMALL_LAYOUT): kept at this mode's original, pre-reference-parity radius --
71
+ * these sizes weren't the fused-blur regression 64px was, and shrinking to match 64px's new,
72
+ * smaller radius here would drop dots below a legible stroke width. */
73
+ const WORKING_SMALL_BASE_RADIUS_PX = 2.2;
60
74
  const WORKING_ORBIT_COUNT = 6;
61
75
  const WORKING_ANGULAR_SPEED = 1.1;
62
76
  /** How far an orbit's `weight` can push its dots' ink away from the shared
@@ -68,6 +82,17 @@ const WORKING_ORBIT_ALPHA_FLOOR = 0.65;
68
82
  /** A train's trailing-most particle never fades past this floor, so the arc's
69
83
  * tail stays legible instead of thinning away to nothing. */
70
84
  const WORKING_TRAIL_ALPHA_FLOOR = 0.35;
85
+ /** Reference parity (BZ-6466 follow-up): upstream's `orbits` mode traces each orbit's full circle
86
+ * as a faint, static "ghost" ring of many dots (40 per orbit at 64px) UNDER the few bright
87
+ * travelling particles -- about 13x as many ghost dots as particles, so the ring lattice anchors
88
+ * the silhouette and the particles read as a sparkle on top of it. Ours had no such layer: every
89
+ * visible dot was one of the sweeping particles, so with only 6-22 of them the whole silhouette
90
+ * reshaped every frame instead of holding a roughly stable shape -- the actual "fluidity" gap a
91
+ * static/moving frame comparison alone doesn't show. */
92
+ const WORKING_GHOST_DOTS_PER_ORBIT = 40;
93
+ const WORKING_GHOST_RADIUS_PX = 0.7;
94
+ const WORKING_GHOST_INK = 0.4;
95
+ const WORKING_GHOST_ALPHA = 0.5;
71
96
  /** Every particle's own orbital plane, radius, pace and shading weight, keyed by its orbit. */
72
97
  const workingOrbit = (orbit) => {
73
98
  const seed = orbit * 31 + 7;
@@ -84,8 +109,10 @@ const workingOrbit = (orbit) => {
84
109
  * the view axis (like an atom symbol) and give each train a long arc, so every train draws a
85
110
  * visible curve and the separate orbits stay readable. */
86
111
  const WORKING_SMALL_LAYOUT = {
87
- 32: { orbits: 4, trainLength: 6, arcSpan: 1 },
88
- 20: { orbits: 3, trainLength: 7, arcSpan: 1.25 }
112
+ // Shorter trains than before (was 6/7): at the smaller shared radius above, a longer train's
113
+ // dots sat closer together than their own diameter and fused the same way 64px did.
114
+ 32: { orbits: 4, trainLength: 4, arcSpan: 0.75 },
115
+ 20: { orbits: 3, trainLength: 4, arcSpan: 0.9 }
89
116
  };
90
117
  /** How far each small-size orbit tips out of edge-on. Its projection is an ellipse sin(tilt) as
91
118
  * tall as it is wide; near 1 rad every orbit projects to nearly the same circle and rolling it
@@ -147,10 +174,32 @@ export const working = (t, ctx) => {
147
174
  (orbit.weight - 0.5) * WORKING_ORBIT_INK_SPREAD;
148
175
  const alpha = (WORKING_TRAIL_ALPHA_FLOOR + (1 - WORKING_TRAIL_ALPHA_FLOOR) * trail) *
149
176
  (WORKING_ORBIT_ALPHA_FLOOR + (1 - WORKING_ORBIT_ALPHA_FLOOR) * orbit.weight);
150
- const radius = sizing.radius(WORKING_BASE_RADIUS_PX) * (0.9 + 0.25 * hashUnit(seed + 3));
177
+ const radiusBase = small ? WORKING_SMALL_BASE_RADIUS_PX : WORKING_BASE_RADIUS_PX;
178
+ const radius = sizing.radius(radiusBase) * (0.9 + 0.25 * hashUnit(seed + 3));
151
179
  dots.push(toDot(projected, radius, ink, alpha));
152
180
  }
153
181
  }
182
+ // 64px only: 32/20px already anchor their orbits with the faint stroke path below, drawn for
183
+ // every size in WORKING_SMALL_LAYOUT.
184
+ if (!small) {
185
+ const ghostDotsPerOrbit = Math.max(8, sizing.countAlongPath(WORKING_GHOST_DOTS_PER_ORBIT));
186
+ for (let orbitIndex = 0; orbitIndex < WORKING_ORBIT_COUNT; orbitIndex += 1) {
187
+ const orbit = workingOrbit(orbitIndex);
188
+ const orbitRadius = worldRadius * orbit.orbitRadiusFactor;
189
+ for (let k = 0; k < ghostDotsPerOrbit; k += 1) {
190
+ const angle = (k / ghostDotsPerOrbit) * TWO_PI;
191
+ const planar = {
192
+ x: Math.cos(angle) * orbitRadius,
193
+ y: 0,
194
+ z: Math.sin(angle) * orbitRadius
195
+ };
196
+ const tilted = rotateX(planar, orbit.tilt);
197
+ const point = rotateZ(tilted, orbit.roll);
198
+ const projected = project(point, origin, worldRadius);
199
+ dots.push(toDot(projected, sizing.radius(WORKING_GHOST_RADIUS_PX), WORKING_GHOST_INK, WORKING_GHOST_ALPHA * (0.4 + 0.6 * projected.depth)));
200
+ }
201
+ }
202
+ }
154
203
  const paths = small
155
204
  ? Array.from({ length: small.orbits }, (_unused, orbitIndex) => {
156
205
  const orbit = workingSmallOrbit(orbitIndex, small.orbits);
@@ -236,10 +285,16 @@ export const searching = (t, ctx) => {
236
285
  };
237
286
  // ---------------------------------------------------------------------------
238
287
  // solving -- tiers that snap a quarter turn out of alignment, hold scrambled,
239
- // then resolve back, on a loop. Landmark dots and fixed seam rings make the
240
- // misalignment legible: an otherwise-uniform, evenly-sampled dot texture
241
- // looks identical under any rotation about the spin axis, so turning a
242
- // "band" of it invisibly wouldn't read as scrambling at all.
288
+ // then resolve back, on a loop. Each tier's own texture dots rotate with it
289
+ // (reference parity, BZ-6466 follow-up: matching upstream thinking-orbs'
290
+ // `rubik`, which reads solved-vs-scrambled off this same per-band rotation
291
+ // alone), so the misalignment shows as a visible discontinuity in the dot
292
+ // pattern itself; oversized landmark dots add a brighter set of accent
293
+ // points per tier. An earlier tuning also drew fixed seam rings and
294
+ // per-landmark meridian strokes to make the misalignment legible, but next
295
+ // to the reference that wireframe read as a busy skeleton the upstream
296
+ // version doesn't have, so it's gone -- the texture's own rotation already
297
+ // carries the same information.
243
298
  // ---------------------------------------------------------------------------
244
299
  const SOLVING_BASE_COUNT = 130;
245
300
  const SOLVING_BASE_RADIUS_PX = 1.5;
@@ -260,23 +315,6 @@ const SOLVING_LANDMARK_PHASE = hashUnit(5) * TWO_PI;
260
315
  const SOLVING_LANDMARK_RADIUS_PX = 3.1;
261
316
  const SOLVING_LANDMARK_INK_BOOST = 0.45;
262
317
  const SOLVING_LANDMARK_ALPHA = 0.9;
263
- /** Each landmark also draws a short meridian stroke spanning its tier. Solved, the strokes of all
264
- * tiers join into three unbroken vertical lines; a turned tier's strokes jump sideways and break
265
- * the lines into a visible staircase. Held scrambled, loose landmark dots alone still formed tidy
266
- * columns of their own and read as just another solved pose. */
267
- const SOLVING_MERIDIAN_POINTS_PER_TIER = 3;
268
- const SOLVING_MERIDIAN_WIDTH_PX = 1.6;
269
- const SOLVING_MERIDIAN_INK_BOOST = 0.35;
270
- const SOLVING_MERIDIAN_ALPHA = 0.85;
271
- /** The meridians stop this far short of the poles, where all three would otherwise converge. */
272
- const SOLVING_MERIDIAN_Y_LIMIT = 0.9;
273
- /** A static ring of short strokes at each cut between tiers, unaffected by
274
- * any turn, so the "cut into bands" structure reads even while the puzzle
275
- * sits solved and nothing is moving. */
276
- const SOLVING_SEAM_SEGMENTS = 12;
277
- const SOLVING_SEAM_STROKE_RADIUS = 1.05;
278
- const SOLVING_SEAM_INK = 0.15;
279
- const SOLVING_SEAM_ALPHA = 0.5;
280
318
  const solvingTurnStart = (tier) => tier * SOLVING_TURN_GAP;
281
319
  const solvingTurnEnd = (tier) => solvingTurnStart(tier) + SOLVING_TURN_DURATION;
282
320
  const SOLVING_HOLD_START = solvingTurnEnd(SOLVING_TIER_COUNT - 1) + SOLVING_HOLD_GAP;
@@ -310,8 +348,6 @@ const solvingTierOf = (yUnit) => clamp(Math.floor(((1 - yUnit) / 2) * SOLVING_TI
310
348
  const solvingTierDirection = (tier) => (hashUnit(tier * 53 + 21) < 0.5 ? -1 : 1);
311
349
  /** The y-unit (-1..1) at the centre of a tier's latitude band. */
312
350
  const solvingTierCenterYUnit = (tier) => 1 - (2 * tier + 1) / SOLVING_TIER_COUNT;
313
- /** The y-unit (-1..1) of the fixed cut between one tier and the next. */
314
- const solvingSeamYUnit = (seam) => 1 - (2 * (seam + 1)) / SOLVING_TIER_COUNT;
315
351
  const solvingTurnAngle = (tier, cyclePos) => solvingTierTurn(tier, cyclePos) * solvingTierDirection(tier) * (Math.PI / 2);
316
352
  export const solving = (t, ctx) => {
317
353
  const sizing = modeSizing(ctx.size, ctx.density, ctx.dotScale);
@@ -349,47 +385,7 @@ export const solving = (t, ctx) => {
349
385
  const ink = shadeByDepth(projected.depth) - SOLVING_LANDMARK_INK_BOOST;
350
386
  return toDot(projected, sizing.radius(SOLVING_LANDMARK_RADIUS_PX), ink, SOLVING_LANDMARK_ALPHA);
351
387
  });
352
- const seamWidth = sizing.radius(SOLVING_SEAM_STROKE_RADIUS);
353
- const seams = Array.from({ length: SOLVING_TIER_COUNT - 1 }, (_unused, seam) => {
354
- const yUnit = solvingSeamYUnit(seam);
355
- const y = yUnit * worldRadius;
356
- const ringRadius = Math.sqrt(Math.max(0, 1 - yUnit * yUnit)) * worldRadius;
357
- const ringPoints = Array.from({ length: SOLVING_SEAM_SEGMENTS + 1 }, (_unusedPoint, j) => {
358
- const angle = (j / SOLVING_SEAM_SEGMENTS) * TWO_PI;
359
- const point = {
360
- x: Math.cos(angle) * ringRadius,
361
- y,
362
- z: Math.sin(angle) * ringRadius
363
- };
364
- return project(rotateX(point, SOLVING_TILT), origin, worldRadius);
365
- });
366
- return Array.from({ length: SOLVING_SEAM_SEGMENTS }, (_unusedSegment, j) => toStroke(ringPoints[j], ringPoints[j + 1], seamWidth, SOLVING_SEAM_INK, SOLVING_SEAM_ALPHA));
367
- }).flat();
368
- const meridianWidth = sizing.radius(SOLVING_MERIDIAN_WIDTH_PX);
369
- const meridians = Array.from({ length: SOLVING_TIER_COUNT * SOLVING_LANDMARKS_PER_TIER }, (_unused, i) => {
370
- const tier = Math.floor(i / SOLVING_LANDMARKS_PER_TIER);
371
- const slot = i % SOLVING_LANDMARKS_PER_TIER;
372
- const longitude = SOLVING_LANDMARK_PHASE + (slot / SOLVING_LANDMARKS_PER_TIER) * TWO_PI;
373
- const topYUnit = Math.min(SOLVING_MERIDIAN_Y_LIMIT, 1 - (2 * tier) / SOLVING_TIER_COUNT);
374
- const bottomYUnit = Math.max(-SOLVING_MERIDIAN_Y_LIMIT, 1 - (2 * (tier + 1)) / SOLVING_TIER_COUNT);
375
- const points = Array.from({ length: SOLVING_MERIDIAN_POINTS_PER_TIER }, (_unusedPoint, j) => {
376
- const yUnit = topYUnit + ((bottomYUnit - topYUnit) * j) / (SOLVING_MERIDIAN_POINTS_PER_TIER - 1);
377
- const ringRadius = Math.sqrt(Math.max(0, 1 - yUnit * yUnit)) * worldRadius;
378
- const base = {
379
- x: Math.cos(longitude) * ringRadius,
380
- y: yUnit * worldRadius,
381
- z: Math.sin(longitude) * ringRadius
382
- };
383
- const turned = rotateY(base, solvingTurnAngle(tier, cyclePos));
384
- return project(rotateX(turned, SOLVING_TILT), origin, worldRadius);
385
- });
386
- return points.slice(1).map((to, j) => {
387
- const from = points[j];
388
- const depth = (from.depth + to.depth) / 2;
389
- return toStroke(from, to, meridianWidth, shadeByDepth(depth) - SOLVING_MERIDIAN_INK_BOOST, SOLVING_MERIDIAN_ALPHA);
390
- });
391
- }).flat();
392
- return { dots: [...texture, ...landmarks], strokes: [...seams, ...meridians] };
388
+ return { dots: [...texture, ...landmarks], strokes: [] };
393
389
  };
394
390
  // ---------------------------------------------------------------------------
395
391
  // listening -- stacked horizontal rings with a wave rolling through them
@@ -4,6 +4,11 @@
4
4
  let {
5
5
  showBackButton = true,
6
6
  text,
7
+ title,
8
+ headingLevel,
9
+ subtitle,
10
+ rootTag,
11
+ variant = 'chrome',
7
12
  backIcon,
8
13
  backLabel = 'Back',
9
14
  backHref,
@@ -18,6 +23,19 @@
18
23
  headingTestId
19
24
  }: ToolbarProperties = $props();
20
25
 
26
+ // Same validation `Sheet`'s `headingLevel` already applies: through a web
27
+ // component, an untyped attribute reaches this as anything, and a getter
28
+ // can throw, so an unusable level degrades to the plain-tag fallback rather
29
+ // than emitting an invalid `<h7>`/`<h0>`/`<hNaN>`.
30
+ const headingTag = $derived(
31
+ typeof headingLevel === 'number' &&
32
+ Number.isInteger(headingLevel) &&
33
+ headingLevel >= 1 &&
34
+ headingLevel <= 6
35
+ ? `h${headingLevel}`
36
+ : null
37
+ );
38
+
21
39
  // `backIcon={null}` (or '') has always meant "render no back control at all", and consumers
22
40
  // rely on it; only the DEFAULT changes, from a CDN image to the inline icon.
23
41
  const showBackControl = $derived(showBackButton && backIcon !== null && backIcon !== '');
@@ -42,7 +60,13 @@
42
60
  };
43
61
  </script>
44
62
 
45
- <div class="toolbar {classes ?? ''}" data-pw={testId} testID={testId}>
63
+ <svelte:element
64
+ this={rootTag ?? 'div'}
65
+ class="toolbar {classes ?? ''}"
66
+ data-variant={variant === 'page-header' ? 'page-header' : null}
67
+ data-pw={testId}
68
+ testID={testId}
69
+ >
46
70
  <div
47
71
  class="content"
48
72
  data-pw={typeof testId === 'string' ? `${testId}-content` : null}
@@ -82,6 +106,17 @@
82
106
  <div class="center-content">
83
107
  {@render centerContent()}
84
108
  </div>
109
+ {:else if typeof title === 'string' && title.length > 0}
110
+ <div class="titles" data-pw={headingTestId} testID={headingTestId}>
111
+ {#if headingTag !== null}
112
+ <svelte:element this={headingTag}>{title}</svelte:element>
113
+ {:else}
114
+ <span>{title}</span>
115
+ {/if}
116
+ {#if typeof subtitle === 'string' && subtitle.length > 0}
117
+ <p class="subtitle">{subtitle}</p>
118
+ {/if}
119
+ </div>
85
120
  {:else if typeof text === 'string' && text.length > 0}
86
121
  <div class="text" data-pw={headingTestId} testID={headingTestId}>
87
122
  {text}
@@ -98,7 +133,7 @@
98
133
  {@render additionalContent()}
99
134
  {/if}
100
135
  </div>
101
- </div>
136
+ </svelte:element>
102
137
 
103
138
  <style>
104
139
  .toolbar {
@@ -117,6 +152,25 @@
117
152
  border-radius: var(--toolbar-border-radius, 0px);
118
153
  }
119
154
 
155
+ /* In the page's flow rather than pinned over it: only the fallbacks change,
156
+ so a consumer's own --toolbar-* values still win. */
157
+ /* Literal values, not the chrome's variables with other fallbacks: an app
158
+ that themes its bars at :root (--toolbar-background: white,
159
+ --toolbar-position: fixed) would otherwise paint and pin every page
160
+ header too. These six properties ARE the variant; everything else still
161
+ reads the shared variables. */
162
+ .toolbar[data-variant='page-header'] {
163
+ position: static;
164
+ width: 100%;
165
+ background: transparent;
166
+ box-shadow: none;
167
+ z-index: auto;
168
+ }
169
+
170
+ .toolbar[data-variant='page-header'] > .content {
171
+ flex-wrap: wrap;
172
+ }
173
+
120
174
  .content {
121
175
  display: flex;
122
176
  flex-direction: row;
@@ -160,7 +214,7 @@
160
214
  /* A native button so Enter/Space and the accessible name come for free; the reset keeps
161
215
  the box identical to the div it replaces. */
162
216
  appearance: none;
163
- background: none;
217
+ background: var(--toolbar-back-button-background, none);
164
218
  border: 0;
165
219
  margin: 0;
166
220
  box-sizing: content-box;
@@ -214,4 +268,28 @@
214
268
  color: var(--toolbar-text-color, inherit);
215
269
  flex: var(--toolbar-text-flex, 1);
216
270
  }
271
+
272
+ /* `title`/`subtitle`'s heading tag is deliberately unstyled here: a consumer's
273
+ own global heading rules (size, weight, tracking) already reach it the
274
+ same way they reach any other heading on the page, and a component-level
275
+ font-size/font-weight here — even with an `inherit` fallback — would
276
+ outrank a bare `h1`/`h2` element selector by specificity and silently
277
+ break that inheritance for every consumer that has one. Only layout and
278
+ the subtitle's own (independent) presentation get hooks. Both rules are written down the
279
+ structure they live in (.content > .titles > .subtitle), so a `titles` or `subtitle`
280
+ class a consumer passes through `classes`, which lands on the root, cannot match them,
281
+ and the variant is a data attribute rather than a class for the same reason: a class
282
+ directive would strip a consumer's own `page-header` class (the one the docs recipe
283
+ uses) from `classes`. */
284
+ .content > .titles {
285
+ display: grid;
286
+ gap: var(--toolbar-titles-gap, 2px);
287
+ min-width: var(--toolbar-titles-min-width, 0);
288
+ flex: var(--toolbar-titles-flex, 1);
289
+ }
290
+
291
+ .titles > .subtitle {
292
+ color: var(--toolbar-subtitle-color, inherit);
293
+ text-wrap: var(--toolbar-subtitle-text-wrap, pretty);
294
+ }
217
295
  </style>
@@ -2,6 +2,42 @@ import type { Snippet } from 'svelte';
2
2
  export type ToolbarProperties = ToolbarEventProperties & {
3
3
  showBackButton?: boolean;
4
4
  text?: string | null;
5
+ /**
6
+ * A real heading, separate from `text` — which has always rendered a plain,
7
+ * non-heading div and keeps doing so for existing consumers. Renders as a
8
+ * heading only once `headingLevel` also resolves to a real level 1-6;
9
+ * otherwise renders in a plain, non-heading tag instead, same as `text`.
10
+ */
11
+ title?: string;
12
+ /**
13
+ * The tag `title` renders as (`<svelte:element this={`h${headingLevel}`}>`).
14
+ * Only 1-6 resolve to a real heading; anything else (including omitting it)
15
+ * renders `title` in a plain, non-heading tag instead — the same reasoning
16
+ * `Sheet`'s `headingLevel` already uses, so a stray "7"/"0"/`NaN` reaching
17
+ * through a web component's untyped attribute degrades the same way there
18
+ * too, rather than emitting an invalid `<h7>`/`<h0>`/`<hNaN>`.
19
+ */
20
+ headingLevel?: 1 | 2 | 3 | 4 | 5 | 6;
21
+ /** Rendered as `<p class="subtitle">` under `title`. Ignored without `title`. */
22
+ subtitle?: string;
23
+ /**
24
+ * The root element's tag, via `<svelte:element>`. Defaults to `div`, i.e.
25
+ * unconditionally unchanged from every version before this. Set it to give
26
+ * the toolbar its own landmark (`header`, `nav`, `aside`, `footer`) instead
27
+ * of a consumer wrapping it in one purely to supply that semantics.
28
+ */
29
+ rootTag?: 'div' | 'header' | 'nav' | 'aside' | 'footer';
30
+ /**
31
+ * `'chrome'` (the default) is the fixed, full-viewport-width app bar every
32
+ * version before this rendered. `'page-header'` is the same bar in the page's
33
+ * flow: position static, width 100%, transparent background, no shadow,
34
+ * z-index auto, and a content row that wraps (actions drop under the title
35
+ * when they don't fit). Those six are set directly, not through the chrome's
36
+ * `--toolbar-position`/`-width`/`-background`/`-box-shadow`/`-z-index`/
37
+ * `--toolbar-content-flex-wrap`, so a bar themed at :root never leaks into a
38
+ * page header. Every other `--toolbar-*` variable applies to both.
39
+ */
40
+ variant?: ToolbarVariant;
5
41
  backIcon?: string | null;
6
42
  backLabel?: string;
7
43
  /**
@@ -27,3 +63,4 @@ export type ToolbarEventProperties = {
27
63
  onbackclick?: () => void;
28
64
  onkeydown?: (event: KeyboardEvent) => void;
29
65
  };
66
+ export type ToolbarVariant = 'chrome' | 'page-header';
@@ -0,0 +1,129 @@
1
+ <script lang="ts" generics="T">
2
+ import { tick } from 'svelte';
3
+ import Button from '../Button/Button.svelte';
4
+ import type { WindowedListProperties } from './properties';
5
+
6
+ let {
7
+ items,
8
+ getKey,
9
+ row,
10
+ initialCount = 200,
11
+ step = 200,
12
+ earlier,
13
+ earlierLabel = (next: number, hidden: number) => `Show ${next} earlier (${hidden} hidden)`,
14
+ classes,
15
+ testId
16
+ }: WindowedListProperties<T> = $props();
17
+
18
+ /* The window is anchored by KEY, not index. An index would slide with every
19
+ append (the newest N), pushing rows out from under a reader who scrolled up;
20
+ WebKit has no scroll anchoring to hide that jump. A key stays put while the
21
+ list grows at the end. If the anchor's key disappears (the list was replaced
22
+ or truncated), the window re-anchors to the newest `initialCount` -- whichever
23
+ anchor it was, so a reveal that has since been truncated away cannot fall back
24
+ to an older, stale one, and the new anchor then holds through later appends.
25
+
26
+ `anchorKey` and `appliedReveal` are plain latches, not $state: they are
27
+ written only inside the derived below, while it recomputes because `items` or
28
+ `reveal` changed, and nothing else reads them. `reveal` is state: only the
29
+ "show earlier" handler writes it, as a fresh object per click so a repeated
30
+ key still reads as a new request. */
31
+ let anchorKey: string | null = null;
32
+ let appliedReveal: { key: string | null } | null = null;
33
+ let reveal = $state.raw<{ key: string | null } | null>(null);
34
+
35
+ function indexOfKey(key: string | null): number {
36
+ return key === null ? -1 : items.findIndex((item) => getKey(item) === key);
37
+ }
38
+
39
+ function keyAt(index: number): string | null {
40
+ return items.slice(index, index + 1).map(getKey)[0] ?? null;
41
+ }
42
+
43
+ const start = $derived.by(() => {
44
+ if (reveal !== appliedReveal) {
45
+ appliedReveal = reveal;
46
+ anchorKey = reveal?.key ?? null;
47
+ }
48
+ const anchored = indexOfKey(anchorKey);
49
+ if (anchored >= 0) {
50
+ return anchored;
51
+ }
52
+ const fresh = Math.max(0, items.length - initialCount);
53
+ anchorKey = keyAt(fresh);
54
+ return fresh;
55
+ });
56
+ const shown = $derived(items.slice(start));
57
+ const next = $derived(Math.min(step, start));
58
+
59
+ let controlEl: HTMLElement | null = $state(null);
60
+
61
+ function scrollParent(node: HTMLElement): HTMLElement | null {
62
+ for (let el = node.parentElement; el !== null; el = el.parentElement) {
63
+ if (/(auto|scroll|overlay)/.test(getComputedStyle(el).overflowY)) {
64
+ return el;
65
+ }
66
+ }
67
+ return document.scrollingElement instanceof HTMLElement ? document.scrollingElement : null;
68
+ }
69
+
70
+ /* Reveals `next` more above the window. The first row that was already
71
+ rendered is measured before and after, and the scroller moves by the
72
+ difference, so what the reader was looking at stays where it was. Browser
73
+ scroll anchoring may already have done this (the difference is then ~0);
74
+ WebKit has none, and there it is the whole fix. */
75
+ async function showEarlier(): Promise<void> {
76
+ if (start === 0) {
77
+ return;
78
+ }
79
+ const firstRow = controlEl?.nextElementSibling ?? null;
80
+ const before = firstRow instanceof HTMLElement ? firstRow.getBoundingClientRect().top : null;
81
+ reveal = { key: keyAt(Math.max(0, start - step)) };
82
+ await tick();
83
+ if (firstRow instanceof HTMLElement && before !== null && firstRow.isConnected) {
84
+ const scroller = scrollParent(firstRow);
85
+ const delta = firstRow.getBoundingClientRect().top - before;
86
+ if (scroller !== null && delta !== 0) {
87
+ scroller.scrollTop += delta;
88
+ }
89
+ }
90
+ }
91
+ </script>
92
+
93
+ <div
94
+ class="windowed-list {classes ?? ''}"
95
+ data-pw={typeof testId === 'string' ? testId : null}
96
+ testID={typeof testId === 'string' ? testId : null}
97
+ >
98
+ {#if start > 0}
99
+ <div class="windowed-list-earlier" bind:this={controlEl}>
100
+ {#if typeof earlier === 'function'}
101
+ {@render earlier({ hidden: start, next, showEarlier })}
102
+ {:else}
103
+ <Button text={earlierLabel(next, start)} onclick={() => void showEarlier()} />
104
+ {/if}
105
+ </div>
106
+ {/if}
107
+ {#each shown as item, i (getKey(item))}
108
+ {@render row({ item, index: start + i, isFirst: i === 0 })}
109
+ {/each}
110
+ </div>
111
+
112
+ <style>
113
+ /* `contents` by default: the rows stay children of the consumer's own layout
114
+ (a flex column's gap, a grid's tracks), exactly as if they were rendered
115
+ without this wrapper. Set `--windowed-list-display` to give the list a box
116
+ of its own. */
117
+ .windowed-list {
118
+ display: var(--windowed-list-display, contents);
119
+ }
120
+
121
+ .windowed-list-earlier {
122
+ display: flex;
123
+ justify-content: var(--windowed-list-earlier-justify, center);
124
+ padding: var(--windowed-list-earlier-padding, 8px 0);
125
+ /* The control must never be what the browser anchors on: it is about to
126
+ have rows inserted directly under it. */
127
+ overflow-anchor: none;
128
+ }
129
+ </style>