cursedbelt 5.76.0 → 5.78.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 (62) hide show
  1. package/dist/react/canvas-hud/CanvasToolbar.d.ts +1 -1
  2. package/dist/react/components/BuildFreshness.d.ts +2 -1
  3. package/dist/react/components/BuildFreshness.d.ts.map +1 -1
  4. package/dist/react/components/BuildFreshness.js +11 -4
  5. package/dist/react/components/BuildFreshness.js.map +1 -1
  6. package/dist/react/components/DataTable/TableFilterBuilder.d.ts.map +1 -1
  7. package/dist/react/components/DataTable/TableFilterBuilder.js +2 -2
  8. package/dist/react/components/DataTable/TableFilterBuilder.js.map +1 -1
  9. package/dist/react/components/ListToolbar.d.ts +2 -2
  10. package/dist/react/components/ListToolbar.d.ts.map +1 -1
  11. package/dist/react/components/ListToolbar.js +1 -1
  12. package/dist/react/hooks/useOutsidePress.d.ts.map +1 -1
  13. package/dist/react/hooks/useOutsidePress.js +18 -3
  14. package/dist/react/hooks/useOutsidePress.js.map +1 -1
  15. package/dist/react/hooks/useScrollMemory.d.ts +4 -3
  16. package/dist/react/hooks/useScrollMemory.d.ts.map +1 -1
  17. package/dist/react/hooks/useScrollMemory.js +16 -2
  18. package/dist/react/hooks/useScrollMemory.js.map +1 -1
  19. package/dist/react/media/FocusViewer.d.ts +8 -1
  20. package/dist/react/media/FocusViewer.d.ts.map +1 -1
  21. package/dist/react/media/FocusViewer.js +174 -10
  22. package/dist/react/media/FocusViewer.js.map +1 -1
  23. package/dist/react/media/focusViewer.css +31 -0
  24. package/dist/react/media/gifClip.d.ts +46 -0
  25. package/dist/react/media/gifClip.d.ts.map +1 -0
  26. package/dist/react/media/gifClip.js +74 -0
  27. package/dist/react/media/gifClip.js.map +1 -0
  28. package/dist/react/media/gifEncode.d.ts +46 -0
  29. package/dist/react/media/gifEncode.d.ts.map +1 -0
  30. package/dist/react/media/gifEncode.js +288 -0
  31. package/dist/react/media/gifEncode.js.map +1 -0
  32. package/dist/react/media/gifRecord.d.ts +26 -0
  33. package/dist/react/media/gifRecord.d.ts.map +1 -0
  34. package/dist/react/media/gifRecord.js +93 -0
  35. package/dist/react/media/gifRecord.js.map +1 -0
  36. package/dist/scripts/guardrailsEnforce.d.ts.map +1 -1
  37. package/dist/styles-areas/analytics.css +9 -9
  38. package/dist/styles-areas/data-table.css +10 -10
  39. package/dist/styles-areas/media.css +2 -2
  40. package/package.json +3 -3
  41. package/scripts/guardrailsEnforce.spec.ts +10 -0
  42. package/scripts/guardrailsEnforce.ts +4 -1
  43. package/scripts/styleAreas.ts +3 -1
  44. package/src/react/canvas-hud/CanvasToolbar.tsx +1 -1
  45. package/src/react/components/BuildFreshness.spec.tsx +10 -0
  46. package/src/react/components/BuildFreshness.tsx +13 -4
  47. package/src/react/components/DataTable/TableFilterBuilder.tsx +9 -13
  48. package/src/react/components/DataTable/tableSystem.spec.tsx +15 -7
  49. package/src/react/components/ListToolbar.tsx +2 -2
  50. package/src/react/hooks/useOutsidePress.spec.tsx +20 -0
  51. package/src/react/hooks/useOutsidePress.ts +17 -2
  52. package/src/react/hooks/useScrollMemory.spec.tsx +45 -2
  53. package/src/react/hooks/useScrollMemory.ts +20 -5
  54. package/src/react/media/FocusViewer.tsx +322 -25
  55. package/src/react/media/focusViewer.css +31 -0
  56. package/src/react/media/gifClip.ts +90 -0
  57. package/src/react/media/gifEncode.spec.ts +213 -0
  58. package/src/react/media/gifEncode.ts +298 -0
  59. package/src/react/media/gifRecord.ts +103 -0
  60. package/src/styles-areas/analytics.css +9 -9
  61. package/src/styles-areas/data-table.css +10 -10
  62. package/src/styles-areas/media.css +2 -2
@@ -62,6 +62,16 @@ describe('BuildFreshnessNotice', () => {
62
62
  expect(screen.getByRole('button', { name: /Reload/i })).toBeDefined();
63
63
  });
64
64
 
65
+ test('🔴 speaks off a CLIENT build stamp too — `/build.json`\'s top-level `{ commit }`', async () => {
66
+ // `forge-client stamp` writes the client's own commit at the top of `dist/build.json`. roms
67
+ // probes it (its `/healthz` is its Mac's), and a TV tab kept the old build for 84 minutes
68
+ // after a fast-lane ship because nothing read it twice (forge task 3022, 2026-10-10).
69
+ const stamp = (commit: string) => ({ commit, dirty: false });
70
+ healthzServing([stamp('a'.repeat(40)), stamp('b'.repeat(40))]);
71
+ render(<BuildFreshnessNotice intervalMs={5} probeUrl='/build.json' appLabel='ROMs' />);
72
+ await waitFor(() => expect(screen.queryByText(/ROMs was updated/i)).not.toBeNull(), { timeout: 2_000 });
73
+ });
74
+
65
75
  test('stays silent when the probe fails — offline, or a 502 mid-deploy', async () => {
66
76
  healthzServing([build('aaaaaaaaaaaa'), new TypeError('Failed to fetch')]);
67
77
  render(<BuildFreshnessNotice intervalMs={5} />);
@@ -52,7 +52,8 @@ const MIN_GAP_MS = 30_000;
52
52
  export interface UseBuildFreshnessOptions {
53
53
  /**
54
54
  * Where the serving build identifies itself. Any JSON answering
55
- * `{ build: { commitFull } }` — every satellite's health route does.
55
+ * `{ build: { commitFull } }` — every satellite's health route does — or a client build
56
+ * stamp's `{ commit }` (`/build.json`, `forge-client stamp`).
56
57
  */
57
58
  probeUrl?: string;
58
59
  /** Poll interval for a visible tab, in ms. */
@@ -70,11 +71,19 @@ export interface BuildFreshness {
70
71
  servingCommit: string | null;
71
72
  }
72
73
 
73
- /** The one shape read out of a health payload, defensively. */
74
+ /**
75
+ * The commit out of a health payload or a client build stamp, defensively.
76
+ *
77
+ * Two shapes, one identity: a satellite's `/healthz` nests it (`{ build: { commitFull } }`), and
78
+ * the CLIENT's own stamp — `forge-client stamp`'s `dist/build.json` (cursedops `stagedClient.ts`)
79
+ * — carries it at the top (`{ commit }`). The stamp is the better probe for an app whose health
80
+ * route answers for a server that ships on its own clock (roms' `/healthz` is its Mac's): it is a
81
+ * static file of the very build this tab loaded (forge 2026-10-10, task 3022).
82
+ */
74
83
  function commitOf(payload: unknown): string | null {
75
84
  if (typeof payload !== 'object' || payload === null) return null;
76
- const build = (payload as { build?: unknown }).build;
77
- if (typeof build !== 'object' || build === null) return null;
85
+ const nested = (payload as { build?: unknown }).build;
86
+ const build = typeof nested === 'object' && nested !== null ? nested : payload;
78
87
  const full = (build as { commitFull?: unknown }).commitFull;
79
88
  const short = (build as { commit?: unknown }).commit;
80
89
  const value = typeof full === 'string' && full ? full : typeof short === 'string' ? short : '';
@@ -3,8 +3,8 @@ import { useState } from 'react';
3
3
  import { cn } from '../../lib/cn.js';
4
4
  import { Button } from '../Button.js';
5
5
  import { Input } from '../Input.js';
6
- import { NativeSelect } from '../NativeSelect.js';
7
6
  import { Popover } from '../Popover.js';
7
+ import { SelectField } from '../SelectField.js';
8
8
  import {
9
9
  countActiveConditions,
10
10
  describeFilterQuery,
@@ -181,31 +181,27 @@ export function TableFilterBuilder({
181
181
  <span className='w-12 shrink-0 text-right text-caption font-semibold uppercase tracking-wide text-muted-foreground'>
182
182
  {conditionIndex === 0 ? 'Where' : 'and'}
183
183
  </span>
184
- <NativeSelect
184
+ <SelectField
185
185
  size='sm'
186
186
  aria-label='Column'
187
- className='min-w-0 flex-1'
188
- wrapperClassName='min-w-0 flex-1 max-sm:min-w-24'
187
+ className='min-w-0 flex-1 max-sm:min-w-24'
189
188
  value={condition.column}
190
189
  options={columns.map((c) => ({ value: c.key, label: c.label }))}
191
- onChange={(e) =>
190
+ onValueChange={(column) =>
192
191
  patchGroup(group.id, (list) =>
193
- list.map((c) => (c.id === condition.id ? { ...c, column: e.target.value } : c)),
192
+ list.map((c) => (c.id === condition.id ? { ...c, column } : c)),
194
193
  )
195
194
  }
196
195
  />
197
- <NativeSelect
196
+ <SelectField
198
197
  size='sm'
199
198
  aria-label='Condition'
200
- className='min-w-0'
201
- wrapperClassName='w-40 shrink-0 max-sm:w-auto max-sm:min-w-32 max-sm:flex-1'
199
+ className='w-40 min-w-0 shrink-0 max-sm:w-auto max-sm:min-w-32 max-sm:flex-1'
202
200
  value={condition.op}
203
201
  options={OP_OPTIONS}
204
- onChange={(e) =>
202
+ onValueChange={(op) =>
205
203
  patchGroup(group.id, (list) =>
206
- list.map((c) =>
207
- c.id === condition.id ? { ...c, op: e.target.value as FilterOp } : c,
208
- ),
204
+ list.map((c) => (c.id === condition.id ? { ...c, op: op as FilterOp } : c)),
209
205
  )
210
206
  }
211
207
  />
@@ -238,6 +238,14 @@ describe('search AND the filter builder AND the sort stack compose', () => {
238
238
  });
239
239
 
240
240
  describe('the filter builder, driven the way a person drives it', () => {
241
+ /** Picks `label` in one of the builder's `SelectField`s as a keyboard does: open the trigger,
242
+ * then take the option. Its Column and Condition were OS menus until forge task 3063. */
243
+ const choose = (trigger: HTMLElement, label: string) => {
244
+ expect(trigger.getAttribute('role')).toBe('combobox');
245
+ fireEvent.keyDown(trigger, { key: 'Enter' });
246
+ fireEvent.click(screen.getByRole('option', { name: label }));
247
+ };
248
+
241
249
  /**
242
250
  * 🔴 The acceptance test for item 3 is the owner's own last clause — *"easy
243
251
  * enough that I don't give up on trying it"* — so this walks the WHOLE
@@ -255,22 +263,22 @@ describe('the filter builder, driven the way a person drives it', () => {
255
263
  // A condition with no value yet must NOT empty the table.
256
264
  expect(bodyTitles()).toHaveLength(4);
257
265
 
258
- const columnSelects = () => screen.getAllByLabelText('Column') as HTMLSelectElement[];
259
- fireEvent.change(columnSelects()[0], { target: { value: 'artist' } });
260
- fireEvent.change(screen.getAllByLabelText('Condition')[0], { target: { value: 'equals' } });
266
+ const columnSelects = () => screen.getAllByLabelText('Column');
267
+ choose(columnSelects()[0], 'Artist');
268
+ choose(screen.getAllByLabelText('Condition')[0], 'is');
261
269
  fireEvent.change(screen.getAllByLabelText('Value')[0], { target: { value: 'alpha' } });
262
270
  expect(bodyTitles()).toEqual(['bravo', 'delta']);
263
271
 
264
272
  // AND: a second condition inside the same group.
265
273
  fireEvent.click(screen.getByRole('button', { name: /^And$/ }));
266
- fireEvent.change(columnSelects()[1], { target: { value: 'title' } });
274
+ choose(columnSelects()[1], 'Title');
267
275
  fireEvent.change(screen.getAllByLabelText('Value')[1], { target: { value: 'bra' } });
268
276
  expect(bodyTitles()).toEqual(['bravo']);
269
277
 
270
278
  // OR: a second GROUP.
271
279
  fireEvent.click(screen.getByRole('button', { name: /Or…/ }));
272
- fireEvent.change(columnSelects()[2], { target: { value: 'artist' } });
273
- fireEvent.change(screen.getAllByLabelText('Condition')[2], { target: { value: 'equals' } });
280
+ choose(columnSelects()[2], 'Artist');
281
+ choose(screen.getAllByLabelText('Condition')[2], 'is');
274
282
  fireEvent.change(screen.getAllByLabelText('Value')[2], { target: { value: 'beta' } });
275
283
  expect(bodyTitles()).toEqual(['alpha', 'bravo', 'charlie']);
276
284
 
@@ -297,7 +305,7 @@ describe('the filter builder, driven the way a person drives it', () => {
297
305
  render(<DataTable columns={COLUMNS} data={TRACKS} getRowId={(r) => r.id} hidePagination />);
298
306
  fireEvent.click(screen.getByRole('button', { name: /^Filter/ }));
299
307
  fireEvent.click(screen.getByRole('button', { name: /Add a condition/ }));
300
- fireEvent.change(screen.getAllByLabelText('Condition')[0], { target: { value: 'isEmpty' } });
308
+ choose(screen.getAllByLabelText('Condition')[0], 'is empty');
301
309
  expect(screen.queryAllByLabelText('Value')).toHaveLength(0);
302
310
  // Every fixture row has a title, so "title is empty" keeps nothing.
303
311
  expect(bodyTitles()).toHaveLength(0);
@@ -34,7 +34,7 @@ export interface ListToolbarProps {
34
34
  chipsAllLabel?: string;
35
35
  /** Any further FILTER controls — they sit left, after the chips. */
36
36
  filters?: ReactNode;
37
- /** The sort control (a `NativeSelect`, a `ChoiceMenu`, …). Right-hand cluster. */
37
+ /** The sort control (a `SelectField`, a `ChoiceMenu`, …). Right-hand cluster. */
38
38
  sort?: ReactNode;
39
39
  /** The density/view-mode control — normally a `TableViewToggle`. Right-hand cluster. */
40
40
  view?: ReactNode;
@@ -95,7 +95,7 @@ export interface ListToolbarProps {
95
95
  * chips={buckets}
96
96
  * chipValue={filter}
97
97
  * onChipChange={(v) => setFilter(typeof v === 'string' ? v : null)}
98
- * sort={<NativeSelect size='sm' aria-label='Sort the queue' … />}
98
+ * sort={<SelectField size='sm' aria-label='Sort the queue' … />}
99
99
  * view={<TableViewToggle value={view.mode} onChange={view.setMode} />}
100
100
  * />
101
101
  */
@@ -110,6 +110,26 @@ describe('useOutsidePress', () => {
110
110
  expect(isOpen()).toBe(true);
111
111
  });
112
112
 
113
+ /**
114
+ * 🔴 Forge task 3064: a Radix Select opens on the POINTERDOWN and makes the page inert, so the
115
+ * same press's pointerup — and the click it makes — land on `<html>`. Read as a press outside,
116
+ * that click shut the filter builder's popover the moment its Column picker opened (music's
117
+ * browser test, cursedbelt 5.77.1). A click is the end of the press that began it: one that
118
+ * began inside is inside, wherever the release fell.
119
+ */
120
+ test('🔴 a press that begins inside and is released outside keeps it open', async () => {
121
+ render(<Panel />);
122
+ await press(screen.getByRole('button', { name: 'folders' }));
123
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'open nested' }));
124
+ fireEvent.click(document.documentElement);
125
+ await act(() => new Promise((r) => setTimeout(r, 5)));
126
+ expect(isOpen()).toBe(true);
127
+ // The next press, begun outside, still closes it.
128
+ fireEvent.pointerDown(screen.getByRole('button', { name: 'row' }));
129
+ await press(screen.getByRole('button', { name: 'row' }));
130
+ expect(isOpen()).toBe(false);
131
+ });
132
+
113
133
  test('Escape closes it', async () => {
114
134
  render(<Panel />);
115
135
  await press(screen.getByRole('button', { name: 'folders' }));
@@ -125,8 +125,23 @@ export function useOutsidePress({
125
125
  if (outside(target)) swallowUntil = Date.now() + 1000;
126
126
  decideLater(target);
127
127
  };
128
+ // `click` mode: where the press BEGAN. A Radix Select opens on the pointerdown and makes the
129
+ // page inert, so that same press's release — and the click it makes — land on `<html>`; read
130
+ // as outside, it shut the filter builder the moment its Column picker opened (forge task
131
+ // 3064). A click whose press began inside (in the DOM, or this panel's React tree — the
132
+ // panel's `onPointerDownCapture` runs after this capture listener) is inside.
133
+ let pressBegan: Node | null = null;
134
+ const onPressBegins = (event: PointerEvent) => {
135
+ pressInsideRef.current = false;
136
+ pressBegan = event.target instanceof Node ? event.target : null;
137
+ };
128
138
  const onClick = (event: MouseEvent) => {
129
139
  const target = event.target instanceof Node ? event.target : null;
140
+ if (decideOn === 'click') {
141
+ const began = pressBegan;
142
+ pressBegan = null;
143
+ if (began && (pressInsideRef.current || isInside(began) || onAnotherLayer(began))) return;
144
+ }
130
145
  if (decideOn === 'pointerdown') {
131
146
  if (Date.now() < swallowUntil) {
132
147
  swallowUntil = 0;
@@ -149,11 +164,11 @@ export function useOutsidePress({
149
164
  };
150
165
 
151
166
  document.addEventListener('click', onClick, true);
152
- if (decideOn === 'pointerdown') document.addEventListener('pointerdown', onPointerDown, true);
167
+ document.addEventListener('pointerdown', decideOn === 'pointerdown' ? onPointerDown : onPressBegins, true);
153
168
  if (closeOnEscape) document.addEventListener('keydown', onKeyDown);
154
169
  return () => {
155
170
  document.removeEventListener('click', onClick, true);
156
- document.removeEventListener('pointerdown', onPointerDown, true);
171
+ document.removeEventListener('pointerdown', decideOn === 'pointerdown' ? onPointerDown : onPressBegins, true);
157
172
  document.removeEventListener('keydown', onKeyDown);
158
173
  for (const timer of timers) clearTimeout(timer);
159
174
  };
@@ -41,8 +41,8 @@ function fakeScroller(height: number, viewport = 800) {
41
41
  };
42
42
  }
43
43
 
44
- function Shell({ at, el, enabled }: { at: string; el: HTMLElement; enabled?: boolean }) {
45
- useScrollMemory(at, { scroller: () => el, enabled });
44
+ function Shell({ at, el, enabled, deadlineMs }: { at: string; el: HTMLElement; enabled?: boolean; deadlineMs?: number }) {
45
+ useScrollMemory(at, { scroller: () => el, enabled, deadlineMs });
46
46
  return null;
47
47
  }
48
48
 
@@ -103,6 +103,49 @@ describe('useScrollMemory', () => {
103
103
  expect(s.el.scrollTop).toBe(15_000);
104
104
  });
105
105
 
106
+ /**
107
+ * 🔴 forge task 3069: flix's phone grid, reloaded, came back to 29,731px instead of 30,000 — its
108
+ * pages were still arriving one round trip at a time when the 3s deadline, counted from the
109
+ * START, ran out. A page that is still growing is still being followed.
110
+ */
111
+ test('a list growing page by page is followed past the deadline, as long as each page lands within it', async () => {
112
+ const s = fakeScroller(20_000);
113
+ const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={60} />);
114
+ s.scrollTo(15_000);
115
+ const list = { state: window.history.state, path: '/' };
116
+ push('/item/7');
117
+ rerender(<Shell at='/item/7' el={s.el} deadlineMs={60} />);
118
+
119
+ s.grow(3_000);
120
+ back(list);
121
+ rerender(<Shell at='/' el={s.el} deadlineMs={60} />);
122
+ // Five pages, 40ms apart: 200ms in all — well past 60ms from the start, never 60ms without a page.
123
+ for (const height of [6_000, 9_000, 12_000, 15_000, 20_000]) {
124
+ await act(() => Bun.sleep(40));
125
+ s.grow(height);
126
+ }
127
+ await frames(2);
128
+ expect(s.el.scrollTop).toBe(15_000);
129
+ });
130
+
131
+ test('a list that STOPS growing is given up on once the deadline passes with no new page', async () => {
132
+ const s = fakeScroller(20_000);
133
+ const { rerender } = render(<Shell at='/' el={s.el} deadlineMs={60} />);
134
+ s.scrollTo(15_000);
135
+ const list = { state: window.history.state, path: '/' };
136
+ push('/item/7');
137
+ rerender(<Shell at='/item/7' el={s.el} deadlineMs={60} />);
138
+
139
+ s.grow(3_000);
140
+ back(list);
141
+ rerender(<Shell at='/' el={s.el} deadlineMs={60} />);
142
+ await act(() => Bun.sleep(150));
143
+ await frames(2);
144
+ s.grow(20_000); // too late: the restore settled where the page ended
145
+ await frames(3);
146
+ expect(s.el.scrollTop).toBe(2_200);
147
+ });
148
+
106
149
  test('the viewer taking the page back cancels a restore that is still waiting', async () => {
107
150
  const s = fakeScroller(20_000);
108
151
  const { rerender } = render(<Shell at='/' el={s.el} />);
@@ -151,14 +151,17 @@ export interface ScrollMemoryOptions {
151
151
  */
152
152
  enabled?: boolean;
153
153
  /**
154
- * How long a restore waits for the page to grow tall enough before settling as close as it
155
- * got. Default 3000ms — a reload, where only the first page of a list exists, needs a few
156
- * pages' round trips.
154
+ * How long a restore waits for the page to GROW before settling as close as it got — counted
155
+ * from the last time it grew, so a list paging itself down keeps being followed (up to 20s in
156
+ * all). Default 3000ms — a reload, where only the first page of a list exists, needs a few
157
+ * pages' round trips, each of which must land within it.
157
158
  */
158
159
  deadlineMs?: number;
159
160
  }
160
161
 
161
162
  const RESTORE_DEADLINE_MS = 3_000;
163
+ /** The longest a restore keeps following a page that is still growing — the viewer can always take it back sooner. */
164
+ const RESTORE_CEILING_MS = 20_000;
162
165
  /**
163
166
  * How long a landed restore holds its target against a scroll the viewer did not make. Long
164
167
  * enough for a list to finish settling (the late move in forge task 2784 came inside 800ms, under
@@ -271,7 +274,14 @@ function restore(
271
274
  deadlineMs: number,
272
275
  settled: (outcome: RestoreOutcome) => void,
273
276
  ): () => void {
274
- const deadline = performance.now() + deadlineMs;
277
+ // 🔴 The deadline is for a page that has STOPPED growing, not a stopwatch from the start (forge
278
+ // task 3069): flix's phone grid, reloaded and coming back to 30,000px, pages itself down one round
279
+ // trip at a time, and with eight browsers sharing the Mac those trips took longer than 3s in all.
280
+ // The restore gave up at 29,731px while the pages were still arriving — a slow phone network is
281
+ // the same case. Each time the page grows the wait starts again, up to RESTORE_CEILING_MS.
282
+ const started = performance.now();
283
+ let deadline = started + deadlineMs;
284
+ let lastRoom = -1;
275
285
  let frame = 0;
276
286
  let holdTimer: ReturnType<typeof setTimeout> | undefined;
277
287
  let phase: 'waiting' | 'holding' | 'done' = 'waiting';
@@ -306,7 +316,12 @@ function restore(
306
316
  }
307
317
  // Short of it: go as far as the page allows — that is what asks an infinite list for more.
308
318
  scroller.set(Math.max(0, room));
309
- if (performance.now() >= deadline) {
319
+ const now = performance.now();
320
+ if (room > lastRoom) {
321
+ lastRoom = room;
322
+ deadline = Math.min(now + deadlineMs, started + Math.max(deadlineMs, RESTORE_CEILING_MS));
323
+ }
324
+ if (now >= deadline) {
310
325
  finish();
311
326
  settled('deadline');
312
327
  } else frame = requestAnimationFrame(attempt);