hazo_ui 3.2.1 → 3.4.1

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/CHANGE_LOG.md CHANGED
@@ -5,6 +5,84 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## v3.4.1 — 2026-06-12
9
+
10
+ **Fix:** Webpack compatibility in test-harness format.ts + source sync.
11
+
12
+ - Changed `require("node:fs")` → `require("fs")` in `src/test-harness/scenarios/format.ts`. The `"node:"` prefix triggers webpack's `UnhandledSchemeError` at build time before the try/catch can degrade in the browser. The bare specifier allows webpack to stub it as an empty module on the client (resolve.fallback), and Next.js gracefully falls through the catch on the server. Both forms transpile to identical `__require("fs")` in dist.
13
+ - Added `"use client"` directive to `src/index.ts` (already applied by tsup post-processing in dist, now the source matches).
14
+ - Test-app: added `/device-hooks` nav entry for the new interactive demo.
15
+
16
+ ## v3.4.0 — 2026-06-12
17
+
18
+ **New:** `use_wake_lock(active)` and `use_fullscreen()` — snake_case declarative hook variants (FR-001).
19
+
20
+ Adds two thin wrapper hooks for snake_case consumers (the gotimer family) that can't adopt
21
+ `useWakeLock` / `useFullscreen` verbatim because the existing hooks have different contracts.
22
+ Both hooks delegate to their camelCase counterparts for all DOM-API logic (visibility-reacquire,
23
+ unmount-release, SSR safety).
24
+
25
+ - **`use_wake_lock(active: boolean): void`** — declarative; the screen wake lock is held while
26
+ `active` is `true` and released when it becomes `false`. No imperative `request()`/`release()`
27
+ calls required.
28
+ - **`use_fullscreen<T>(): { is_fullscreen, toggle, ref }`** — self-binding; creates the ref
29
+ internally and returns it alongside `is_fullscreen: boolean` and `toggle: () => Promise<void>`.
30
+ The consumer attaches `ref` to the element to fullscreen.
31
+
32
+ ```ts
33
+ import { use_wake_lock, use_fullscreen } from "hazo_ui";
34
+
35
+ // Declarative wake lock
36
+ use_wake_lock(timer.is_running);
37
+
38
+ // Self-binding fullscreen
39
+ const { is_fullscreen, toggle, ref } = use_fullscreen();
40
+ return <div ref={ref}><button onClick={toggle}>Fullscreen</button></div>;
41
+ ```
42
+
43
+ **Note:** The base `useFullscreen` hook syncs on the native `fullscreenchange` event. gotimer's
44
+ local hook also had an Escape-key fallback for a browser-specific optimistic CSS state — this
45
+ edge case is not included in `use_fullscreen` to avoid changing base-hook behaviour. Escape still
46
+ exits real fullscreen via the `fullscreenchange` listener.
47
+
48
+ **Exports added:** `use_wake_lock`, `use_fullscreen`, `UseFullscreenRefResult`.
49
+
50
+ ## v3.3.0 — 2026-06-11
51
+
52
+ **New:** Required `doc` field on `Case` + per-case documentation accordion in `AutoTestRunner`.
53
+
54
+ Every test case registered via `registerScenario` must now declare a `doc: CaseDoc` with four
55
+ required string fields: `description`, `inputs`, `expectedOutputs`, and `caveats`. The field is
56
+ enforced at the type level — a case without `doc` fails `tsc`. All 20 existing scenario files
57
+ across the hazo workspace have been backfilled in this release.
58
+
59
+ The `AutoTestRunner` renders a `▸/▾` caret toggle next to each case name. Clicking it expands a
60
+ doc panel showing the four fields as labeled sections (Description / Inputs / Expected outputs /
61
+ Caveats). Failed-case error output and "Copy prompt" behaviour are unchanged.
62
+
63
+ **Exports added:** `CaseDoc` is now part of the public `hazo_ui/test-harness` surface.
64
+
65
+ ```ts
66
+ import { registerScenario, type CaseDoc } from "hazo_ui/test-harness";
67
+
68
+ registerScenario("my-pkg", {
69
+ name: "My Package",
70
+ pkg: "my-pkg",
71
+ cases: [
72
+ {
73
+ name: "creates a record",
74
+ doc: {
75
+ description: "Verifies that createRecord() inserts a row and returns the new ID.",
76
+ inputs: "A mock DB adapter seeded with an empty table; payload { title: 'hello' }.",
77
+ expectedOutputs: "Resolved string ID, table row count increases to 1.",
78
+ caveats: "None",
79
+ },
80
+ run: async () => { /* ... */ },
81
+ },
82
+ ],
83
+ });
84
+ ```
85
+
8
86
  ## v3.2.0 — 2026-05-31
9
87
 
10
88
  **New:** `MarkdownEditor` — a generic, SSR-safe Markdown/MDX editor.
package/README.md CHANGED
@@ -3285,6 +3285,47 @@ try { await sync(); } catch (e) {
3285
3285
 
3286
3286
  Also exports `rawToast` (re-export of sonner's `toast`) for advanced use cases.
3287
3287
 
3288
+ ### useWakeLock / use_wake_lock
3289
+
3290
+ Screen Wake Lock API wrapper. Prevents screen sleep while the lock is held. Automatically
3291
+ reacquires when the tab becomes visible again. Returns `supported: false` in environments
3292
+ that don't implement the API (SSR, older browsers).
3293
+
3294
+ **Imperative variant** (`useWakeLock`) — call `request()` / `release()` directly:
3295
+
3296
+ ```ts
3297
+ import { useWakeLock } from "hazo_ui";
3298
+ const { supported, acquired, request, release } = useWakeLock();
3299
+ ```
3300
+
3301
+ **Declarative variant** (`use_wake_lock`) — hold the lock while a condition is true:
3302
+
3303
+ ```ts
3304
+ import { use_wake_lock } from "hazo_ui";
3305
+ use_wake_lock(timer.is_running); // acquires when true, releases when false
3306
+ ```
3307
+
3308
+ ### useFullscreen / use_fullscreen
3309
+
3310
+ Fullscreen API wrapper. Syncs `isFullscreen`/`is_fullscreen` with the native
3311
+ `fullscreenchange` event; SSR-safe.
3312
+
3313
+ **Explicit-ref variant** (`useFullscreen`) — pass your own element ref:
3314
+
3315
+ ```ts
3316
+ import { useFullscreen } from "hazo_ui";
3317
+ const container_ref = useRef<HTMLDivElement>(null);
3318
+ const { isFullscreen, toggle } = useFullscreen(container_ref);
3319
+ ```
3320
+
3321
+ **Self-binding variant** (`use_fullscreen`) — ref created and returned internally:
3322
+
3323
+ ```ts
3324
+ import { use_fullscreen } from "hazo_ui";
3325
+ const { is_fullscreen, toggle, ref } = use_fullscreen();
3326
+ return <div ref={ref}><button onClick={toggle}>Fullscreen</button></div>;
3327
+ ```
3328
+
3288
3329
  ### useLoadingState
3289
3330
 
3290
3331
  Hook that returns a controlled loading flag plus an async wrapper.
@@ -3920,13 +3961,18 @@ export default function RootLayout({ children }) {
3920
3961
  // test-app/scenarios/my_feature.ts
3921
3962
  import { registerScenario, assertEqual } from 'hazo_ui/test-harness';
3922
3963
 
3923
- registerScenario({
3924
- id: 'my_feature',
3964
+ registerScenario('my_feature', {
3925
3965
  name: 'My Feature',
3926
3966
  pkg: 'my_pkg',
3927
3967
  cases: [
3928
3968
  {
3929
3969
  name: 'returns correct value',
3970
+ doc: {
3971
+ description: 'Verifies that myFunction adds two numbers correctly.',
3972
+ inputs: 'myFunction(1, 2)',
3973
+ expectedOutputs: 'Returns 3.',
3974
+ caveats: 'None',
3975
+ },
3930
3976
  run: async () => {
3931
3977
  const result = myFunction(1, 2);
3932
3978
  assertEqual(result, 3, 'should add two numbers');
@@ -3934,6 +3980,12 @@ registerScenario({
3934
3980
  },
3935
3981
  {
3936
3982
  name: 'throws on invalid input',
3983
+ doc: {
3984
+ description: 'Verifies that myFunction rejects null as the first argument.',
3985
+ inputs: 'myFunction(null, 2)',
3986
+ expectedOutputs: 'Throws an error containing "invalid input".',
3987
+ caveats: 'None',
3988
+ },
3937
3989
  run: async () => {
3938
3990
  await assertThrows(() => myFunction(null, 2), 'invalid input');
3939
3991
  },
@@ -3954,6 +4006,22 @@ export default function MyFeaturePage() {
3954
4006
  }
3955
4007
  ```
3956
4008
 
4009
+ ### Case documentation (`doc` — required)
4010
+
4011
+ Every case **must** include a `doc: CaseDoc` field. This is enforced at the TypeScript level — omitting it is a compile error. The `AutoTestRunner` surfaces it as a `▸/▾` per-case accordion so reviewers can read what each test verifies without opening the source file.
4012
+
4013
+ ```ts
4014
+ import type { CaseDoc } from 'hazo_ui/test-harness';
4015
+
4016
+ // All four fields are required strings:
4017
+ const doc: CaseDoc = {
4018
+ description: 'What this test verifies, in plain language.',
4019
+ inputs: 'Inputs / preconditions fed in (URL, payload, fixture, state).',
4020
+ expectedOutputs: 'What is asserted on success.',
4021
+ caveats: 'None', // use "None" when nothing notable applies
4022
+ };
4023
+ ```
4024
+
3957
4025
  ### Copying failures as a Claude prompt
3958
4026
 
3959
4027
  `CopyAllFailuresButton` copies all failed cases as a structured prompt with 8 sections (what-went-wrong, expected/actual/diff, test code, code under test, error chain, context, ring buffer). Place it in your sidebar or test page header.
@@ -258,8 +258,9 @@ import {
258
258
  // Import celebration (v2.18.0)
259
259
  import { CelebrationProvider, celebrate, CELEBRATION_GRADIENT } from 'hazo_ui';
260
260
 
261
- // Import test harness (v3.0.0) — test-app only, never in production bundles
262
- import { AutoTestProvider, AutoTestRunner, SidebarLayout, AppSidebar, registerScenario, assertEqual } from 'hazo_ui/test-harness';
261
+ // Import test harness (v3.3.0) — test-app only, never in production bundles
262
+ // Note: every Case must include a `doc: CaseDoc` field (required since v3.3.0)
263
+ import { AutoTestProvider, AutoTestRunner, SidebarLayout, AppSidebar, registerScenario, assertEqual, type CaseDoc } from 'hazo_ui/test-harness';
263
264
  ```
264
265
 
265
266
  **Toaster setup**: Mount `<HazoUiToaster />` once at the root of your app (e.g., in `layout.tsx`) so `successToast()` / `errorToast()` calls have somewhere to render.
package/dist/index.cjs CHANGED
@@ -74,6 +74,7 @@ var TogglePrimitive = require('@radix-ui/react-toggle');
74
74
  var ToggleGroupPrimitive = require('@radix-ui/react-toggle-group');
75
75
  var AlertDialogPrimitive = require('@radix-ui/react-alert-dialog');
76
76
  var sonner = require('sonner');
77
+ var client$1 = require('hazo_state/client');
77
78
 
78
79
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
79
80
 
@@ -7999,6 +8000,18 @@ function useFullscreen(elementRef) {
7999
8000
  }, [enter, exit]);
8000
8001
  return { isFullscreen: is_fullscreen, enter, exit, toggle };
8001
8002
  }
8003
+ function use_wake_lock(active) {
8004
+ const { acquired, request, release } = useWakeLock();
8005
+ React26.useEffect(() => {
8006
+ if (active && !acquired) void request();
8007
+ if (!active && acquired) void release();
8008
+ }, [active, acquired, request, release]);
8009
+ }
8010
+ function use_fullscreen() {
8011
+ const ref = React26.useRef(null);
8012
+ const { isFullscreen, toggle } = useFullscreen(ref);
8013
+ return { is_fullscreen: isFullscreen, toggle, ref };
8014
+ }
8002
8015
  function KanbanCard({
8003
8016
  item,
8004
8017
  renderCard,
@@ -10701,6 +10714,177 @@ function CelebrationModalInner({
10701
10714
  );
10702
10715
  }
10703
10716
 
10717
+ // src/components/hazo_ui_eta_progress/eta.ts
10718
+ function median(values) {
10719
+ if (values.length === 0) return 0;
10720
+ const sorted = [...values].sort((a, b) => a - b);
10721
+ const mid = Math.floor(sorted.length / 2);
10722
+ return sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid];
10723
+ }
10724
+ function computeEta(durationWindow, unitCount, concurrency = 1, fallbackUnitMs = 5e3) {
10725
+ const unitMs = durationWindow.length > 0 ? median(durationWindow) : fallbackUnitMs;
10726
+ return unitMs * Math.ceil(unitCount / Math.max(1, concurrency));
10727
+ }
10728
+ function easeToward(elapsed, eta, maxCap = 0.95) {
10729
+ if (eta <= 0 || elapsed <= 0) return 0;
10730
+ const t = elapsed / eta;
10731
+ return maxCap * (1 - Math.exp(-1.5 * t));
10732
+ }
10733
+ function HazoUiProgressBar({
10734
+ value,
10735
+ label,
10736
+ showPercent = false,
10737
+ size = 8,
10738
+ className
10739
+ }) {
10740
+ const pct = Math.round(Math.min(1, Math.max(0, value)) * 100);
10741
+ return /* @__PURE__ */ jsxRuntime.jsxs("div", { className: cn("w-full", className), children: [
10742
+ (label || showPercent) && /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "flex items-center justify-between mb-1", children: [
10743
+ label && /* @__PURE__ */ jsxRuntime.jsx("span", { className: "text-sm text-muted-foreground", children: label }),
10744
+ showPercent && /* @__PURE__ */ jsxRuntime.jsxs("span", { className: "text-xs text-muted-foreground tabular-nums", children: [
10745
+ pct,
10746
+ "%"
10747
+ ] })
10748
+ ] }),
10749
+ /* @__PURE__ */ jsxRuntime.jsx(
10750
+ "div",
10751
+ {
10752
+ role: "progressbar",
10753
+ "aria-valuenow": pct,
10754
+ "aria-valuemin": 0,
10755
+ "aria-valuemax": 100,
10756
+ "aria-label": label,
10757
+ className: "w-full rounded-full overflow-hidden bg-muted",
10758
+ style: { height: size },
10759
+ children: /* @__PURE__ */ jsxRuntime.jsx(
10760
+ "div",
10761
+ {
10762
+ className: "h-full rounded-full bg-accent transition-[width] duration-200 ease-out motion-reduce:transition-none",
10763
+ style: { width: `${pct}%` }
10764
+ }
10765
+ )
10766
+ }
10767
+ )
10768
+ ] });
10769
+ }
10770
+ function useEtaProgress(opts) {
10771
+ const {
10772
+ unitCount,
10773
+ concurrency = 1,
10774
+ fallbackUnitMs = 5e3,
10775
+ loadDurations,
10776
+ appendDuration
10777
+ } = opts;
10778
+ const [value, setValue] = React26__namespace.useState(0);
10779
+ const stateRef = React26__namespace.useRef({
10780
+ started: false,
10781
+ finished: false,
10782
+ startTime: 0,
10783
+ unitsDone: 0,
10784
+ unitTimestamps: [],
10785
+ // timestamps of each unitDone() call
10786
+ eta: 0,
10787
+ rafId: 0
10788
+ });
10789
+ const stop = React26__namespace.useCallback(() => {
10790
+ if (stateRef.current.rafId) {
10791
+ cancelAnimationFrame(stateRef.current.rafId);
10792
+ stateRef.current.rafId = 0;
10793
+ }
10794
+ }, []);
10795
+ React26__namespace.useEffect(() => stop, [stop]);
10796
+ const tick = React26__namespace.useCallback(() => {
10797
+ const s = stateRef.current;
10798
+ if (s.finished || !s.started) return;
10799
+ const elapsed = Date.now() - s.startTime;
10800
+ const timeValue = easeToward(elapsed, s.eta);
10801
+ const unitValue = unitCount > 0 ? s.unitsDone / unitCount : 0;
10802
+ setValue(Math.max(timeValue, unitValue));
10803
+ s.rafId = requestAnimationFrame(tick);
10804
+ }, [unitCount]);
10805
+ const start = React26__namespace.useCallback(() => {
10806
+ const s = stateRef.current;
10807
+ if (s.started) return;
10808
+ const window2 = loadDurations();
10809
+ const eta = computeEta(window2, unitCount, concurrency, fallbackUnitMs);
10810
+ s.started = true;
10811
+ s.finished = false;
10812
+ s.startTime = Date.now();
10813
+ s.unitsDone = 0;
10814
+ s.unitTimestamps = [];
10815
+ s.eta = eta;
10816
+ stop();
10817
+ s.rafId = requestAnimationFrame(tick);
10818
+ }, [loadDurations, unitCount, concurrency, fallbackUnitMs, stop, tick]);
10819
+ const unitDone = React26__namespace.useCallback(() => {
10820
+ const s = stateRef.current;
10821
+ if (!s.started || s.finished) return;
10822
+ s.unitsDone = Math.min(s.unitsDone + 1, unitCount);
10823
+ s.unitTimestamps.push(Date.now());
10824
+ }, [unitCount]);
10825
+ const finish = React26__namespace.useCallback(() => {
10826
+ const s = stateRef.current;
10827
+ if (s.finished) return;
10828
+ s.finished = true;
10829
+ stop();
10830
+ setValue(1);
10831
+ if (s.started && s.unitTimestamps.length > 0) {
10832
+ const totalMs = Date.now() - s.startTime;
10833
+ const perUnitMs = totalMs / Math.ceil(unitCount / Math.max(1, concurrency));
10834
+ appendDuration(perUnitMs);
10835
+ }
10836
+ s.started = false;
10837
+ s.unitsDone = 0;
10838
+ }, [stop, unitCount, concurrency, appendDuration]);
10839
+ return { value, start, unitDone, finish };
10840
+ }
10841
+ function HazoUiEtaProgress({
10842
+ estimateKey,
10843
+ unitCount,
10844
+ concurrency = 1,
10845
+ windowSize = 5,
10846
+ fallbackUnitMs = 5e3,
10847
+ level = "global",
10848
+ label,
10849
+ endpoint,
10850
+ handleRef,
10851
+ className
10852
+ }) {
10853
+ const { value: stored, append } = client$1.useHazoState(estimateKey, {
10854
+ level,
10855
+ fallback: [],
10856
+ ...endpoint ? { endpoint } : {}
10857
+ });
10858
+ const durationWindow = Array.isArray(stored) ? stored : [];
10859
+ const loadDurations = React26__namespace.useCallback(() => durationWindow, [durationWindow]);
10860
+ const appendDuration = React26__namespace.useCallback(
10861
+ (ms) => {
10862
+ append(ms, windowSize);
10863
+ },
10864
+ [append, windowSize]
10865
+ );
10866
+ const handle = useEtaProgress({
10867
+ unitCount,
10868
+ concurrency,
10869
+ fallbackUnitMs,
10870
+ loadDurations,
10871
+ appendDuration
10872
+ });
10873
+ React26__namespace.useEffect(() => {
10874
+ if (handleRef) {
10875
+ handleRef.current = handle;
10876
+ }
10877
+ }, [handleRef, handle]);
10878
+ return /* @__PURE__ */ jsxRuntime.jsx(
10879
+ HazoUiProgressBar,
10880
+ {
10881
+ value: handle.value,
10882
+ label,
10883
+ className
10884
+ }
10885
+ );
10886
+ }
10887
+
10704
10888
  Object.defineProperty(exports, "rawToast", {
10705
10889
  enumerable: true,
10706
10890
  get: function () { return sonner.toast; }
@@ -10783,6 +10967,7 @@ exports.HazoUiDialogPortal = DialogPortal;
10783
10967
  exports.HazoUiDialogRoot = Dialog;
10784
10968
  exports.HazoUiDialogTitle = DialogTitle;
10785
10969
  exports.HazoUiDialogTrigger = DialogTrigger;
10970
+ exports.HazoUiEtaProgress = HazoUiEtaProgress;
10786
10971
  exports.HazoUiFlexInput = HazoUiFlexInput;
10787
10972
  exports.HazoUiFlexRadio = HazoUiFlexRadio;
10788
10973
  exports.HazoUiKanban = HazoUiKanban;
@@ -10790,6 +10975,7 @@ exports.HazoUiKanbanFilter = HazoUiKanbanFilter;
10790
10975
  exports.HazoUiMultiFilterDialog = HazoUiMultiFilterDialog;
10791
10976
  exports.HazoUiMultiSortDialog = HazoUiMultiSortDialog;
10792
10977
  exports.HazoUiPillRadio = HazoUiPillRadio;
10978
+ exports.HazoUiProgressBar = HazoUiProgressBar;
10793
10979
  exports.HazoUiRte = HazoUiRte;
10794
10980
  exports.HazoUiTable = HazoUiTable;
10795
10981
  exports.HazoUiTextarea = HazoUiTextarea;
@@ -10863,12 +11049,15 @@ exports.applyKanbanFilter = applyKanbanFilter;
10863
11049
  exports.buttonGroupVariants = buttonGroupVariants;
10864
11050
  exports.celebrate = celebrate;
10865
11051
  exports.cn = cn;
11052
+ exports.computeEta = computeEta;
10866
11053
  exports.create_command_suggestion_extension = create_command_suggestion_extension;
11054
+ exports.easeToward = easeToward;
10867
11055
  exports.errorToast = errorToast;
10868
11056
  exports.format_num = format_num;
10869
11057
  exports.generateUUID = generateUUID;
10870
11058
  exports.get_hazo_ui_config = get_hazo_ui_config;
10871
11059
  exports.get_logger = get_logger;
11060
+ exports.median = median;
10872
11061
  exports.parse_commands_from_text = parse_commands_from_text;
10873
11062
  exports.pick_x_label_indices = pick_x_label_indices;
10874
11063
  exports.reset_hazo_ui_config = reset_hazo_ui_config;
@@ -10882,6 +11071,7 @@ exports.useClickOutside = useClickOutside;
10882
11071
  exports.useCopyToClipboard = useCopyToClipboard;
10883
11072
  exports.useDebounce = useDebounce;
10884
11073
  exports.useErrorDisplay = useErrorDisplay;
11074
+ exports.useEtaProgress = useEtaProgress;
10885
11075
  exports.useFullscreen = useFullscreen;
10886
11076
  exports.useIsMobile = useIsMobile;
10887
11077
  exports.useLoadingState = useLoadingState;
@@ -10890,5 +11080,7 @@ exports.useMediaQuery = useMediaQuery;
10890
11080
  exports.useSessionStorage = useSessionStorage;
10891
11081
  exports.useViewport = useViewport;
10892
11082
  exports.useWakeLock = useWakeLock;
11083
+ exports.use_fullscreen = use_fullscreen;
11084
+ exports.use_wake_lock = use_wake_lock;
10893
11085
  //# sourceMappingURL=index.cjs.map
10894
11086
  //# sourceMappingURL=index.cjs.map