@banou/ponyfill 0.0.4 → 0.0.6

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/README.md CHANGED
@@ -58,10 +58,18 @@ That is not hypothetical. It is why four of ripple's storage eviction tests sat
58
58
  they filled the origin to provoke that condition, and 3.5 GB of padding left the free figure
59
59
  identical to the byte.
60
60
 
61
- This package does **not** invent a normalised quota, and does not add API for the difference either:
62
- on Chromium you really can write 10 more GiB, so the native answer is true, and there is no platform
63
- name for "which shape is this". It is written down here and in the source because the two are
64
- indistinguishable at rest, which is how the difference went unnoticed for months.
61
+ **The behaviour picked is Firefox's: a ceiling does not rise because you put something under it.**
62
+ `quota` is reported as the lowest the platform has stated for this origin, which needs no probe and
63
+ no user agent sniffing, and invents nothing: the platform really did say the origin could hold that
64
+ much. Two ordinary things are broken by the rising version, and both were seen in ripple:
65
+ `usage / quota` as a gauge never fills, and `quota - usage < floor` never fires, so nothing can
66
+ detect pressure at all.
67
+
68
+ The cost is deliberate and in the safe direction. On Chromium, once bytes are written the reported
69
+ headroom is smaller than what could really be written, so a caller reclaims cache slightly early.
70
+ Cache is the thing that can be fetched again; a caller that never reclaims because it never sees
71
+ pressure is the failure this replaces. The ceiling still follows the platform DOWNWARD, and is never
72
+ reported below the bytes already held, so `quota - usage` reaches zero and never goes negative.
65
73
 
66
74
  ## Adding to it
67
75
 
@@ -0,0 +1,28 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/file-system.ts
3
+ /**
4
+ * Whether this document is framed by another origin.
5
+ *
6
+ * Chromium exposes the picker either way and refuses it at call time, so a property probe says
7
+ * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
8
+ * an opaque origin, which is the case a sandboxed frame presents.
9
+ */
10
+ var framedByAnotherOrigin = () => {
11
+ if (typeof window === "undefined") return false;
12
+ const top = window.top;
13
+ if (!top || top === window.self) return false;
14
+ try {
15
+ top.location.origin;
16
+ return false;
17
+ } catch {
18
+ return true;
19
+ }
20
+ };
21
+ var showSaveFilePicker = async (options) => {
22
+ const picker = globalThis.showSaveFilePicker;
23
+ if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
24
+ if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
25
+ return picker(options);
26
+ };
27
+ //#endregion
28
+ exports.showSaveFilePicker = showSaveFilePicker;
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The File System Access pickers, with the same names and one behaviour picked.
3
+ *
4
+ * ## The divergence
5
+ *
6
+ * `showSaveFilePicker` exists on the window in Chromium whether or not it can actually be used, and
7
+ * refuses at CALL time in two cases that a property probe cannot see:
8
+ *
9
+ * - a cross origin ancestor frame, which fails with "Cross origin sub frames aren't allowed to show
10
+ * a file picker"
11
+ * - no transient activation, which fails with "Must be handling a user gesture to show a file picker"
12
+ *
13
+ * Firefox does not expose it at all, so `'showSaveFilePicker' in window` answers a different question
14
+ * on each engine: on one it means "might work", on the other "will never work".
15
+ *
16
+ * ## The pick
17
+ *
18
+ * REJECT BEFORE THE ACTIVATION IS SPENT, with a rejection a caller can tell apart from a cancel.
19
+ *
20
+ * That ordering is the whole of it. A caller with a fallback chain has one transient activation to
21
+ * spend, and a picker that rejects at call time has already consumed part of it, so the fallback the
22
+ * caller reaches for next can fail too. Rejecting before the call keeps the gesture intact.
23
+ *
24
+ * `NotAllowedError` for both refusals rather than `AbortError`, because `AbortError` is what the
25
+ * platform throws when the PERSON cancels, and a caller that cannot tell those apart shows an error
26
+ * for something the person chose to do. Ripple's `isSaveCancelled` matches on `AbortError`, so this
27
+ * distinction is load bearing rather than tidy.
28
+ *
29
+ * WHAT IS NOT ABSORBED, deliberately. The fallback chain itself stays with the caller: an anchor
30
+ * download, a service worker sink, or holding bytes in memory are not platform names and choosing
31
+ * between them is a product decision about what a page does when it cannot save. A ponyfill that
32
+ * silently downloaded something instead of opening a picker would be lying about which API it is.
33
+ */
34
+ type SaveFilePickerOptions = {
35
+ suggestedName?: string;
36
+ types?: {
37
+ description?: string;
38
+ accept: Record<string, string[]>;
39
+ }[];
40
+ excludeAcceptAllOption?: boolean;
41
+ id?: string;
42
+ startIn?: unknown;
43
+ };
44
+ type SaveFilePicker = (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
45
+ export declare const showSaveFilePicker: SaveFilePicker;
46
+ export {};
@@ -0,0 +1,27 @@
1
+ //#region src/file-system.ts
2
+ /**
3
+ * Whether this document is framed by another origin.
4
+ *
5
+ * Chromium exposes the picker either way and refuses it at call time, so a property probe says
6
+ * nothing. A same origin ancestor answers `location.origin`; a cross origin one throws, and so does
7
+ * an opaque origin, which is the case a sandboxed frame presents.
8
+ */
9
+ var framedByAnotherOrigin = () => {
10
+ if (typeof window === "undefined") return false;
11
+ const top = window.top;
12
+ if (!top || top === window.self) return false;
13
+ try {
14
+ top.location.origin;
15
+ return false;
16
+ } catch {
17
+ return true;
18
+ }
19
+ };
20
+ var showSaveFilePicker = async (options) => {
21
+ const picker = globalThis.showSaveFilePicker;
22
+ if (!picker) throw new DOMException("this browser has no file save picker", "NotAllowedError");
23
+ if (framedByAnotherOrigin()) throw new DOMException("a cross origin frame cannot show a file save picker", "NotAllowedError");
24
+ return picker(options);
25
+ };
26
+ //#endregion
27
+ export { showSaveFilePicker };
package/build/index.cjs CHANGED
@@ -1,3 +1,7 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_storage = require("./storage.cjs");
3
+ const require_permissions = require("./permissions.cjs");
4
+ const require_file_system = require("./file-system.cjs");
5
+ exports.permissions = require_permissions.permissions;
6
+ exports.showSaveFilePicker = require_file_system.showSaveFilePicker;
3
7
  exports.storage = require_storage.storage;
package/build/index.d.ts CHANGED
@@ -13,8 +13,12 @@
13
13
  * anything: it is a utility library wearing the word. So the surface here is exactly the platform's
14
14
  * surface, and a helper only becomes public if the platform has one by that name.
15
15
  *
16
- * Everything else lives behind it. The walk that corrects `estimate().usage`, the bounds on that
17
- * walk, and the reconciliation between the measured and reported figures are all internal, because
16
+ * WHERE ENGINES DIVERGE, ONE BEHAVIOUR IS PICKED and made consistent, so application code never
17
+ * branches. That is the point of the package rather than a liberty it takes. Each pick is argued
18
+ * where it lives, and each says what it gives up.
19
+ *
20
+ * Everything else stays behind the surface. The walk that corrects `estimate().usage`, the bounds on
21
+ * that walk, and the reconciliation between measured and reported figures are all internal, because
18
22
  * `navigator.storage` has no such members and neither should this.
19
23
  *
20
24
  * ## What belongs in here
@@ -25,7 +29,11 @@
25
29
  * next person is not made to repeat it.
26
30
  *
27
31
  * Every module here states what was measured, on what, and when. A workaround with no measurement
28
- * behind it is a guess that outlives the bug it was written for.
32
+ * behind it is a guess that outlives the bug it was written for, and where a measurement is missing
33
+ * the module says so rather than implying one.
29
34
  */
30
35
  export { storage } from './storage';
36
+ export { permissions } from './permissions';
37
+ export { showSaveFilePicker } from './file-system';
31
38
  export type { StorageEstimate } from './storage';
39
+ export type { PermissionStatus } from './permissions';
package/build/index.js CHANGED
@@ -1,2 +1,4 @@
1
1
  import { storage } from "./storage.js";
2
- export { storage };
2
+ import { permissions } from "./permissions.js";
3
+ import { showSaveFilePicker } from "./file-system.js";
4
+ export { permissions, showSaveFilePicker, storage };
@@ -0,0 +1,23 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ //#region src/permissions.ts
3
+ var isState = (value) => value === "granted" || value === "denied" || value === "prompt";
4
+ var permissions = {
5
+ /**
6
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
7
+ *
8
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
9
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
10
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
11
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
12
+ * whatever moment the feature is first used.
13
+ */
14
+ query: async (descriptor) => {
15
+ try {
16
+ const status = await (globalThis.navigator?.permissions)?.query(descriptor);
17
+ return { state: isState(status?.state) ? status.state : "prompt" };
18
+ } catch {
19
+ return { state: "prompt" };
20
+ }
21
+ } };
22
+ //#endregion
23
+ exports.permissions = permissions;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * `navigator.permissions`, with the same member name and one behaviour picked.
3
+ *
4
+ * ## The divergence
5
+ *
6
+ * `query()` has FOUR ways of not answering, and an app that wants a permission state has to handle
7
+ * all of them separately even though every one means the same thing to it:
8
+ *
9
+ * 1. the engine has no Permissions API at all, so `navigator.permissions` is undefined
10
+ * 2. the engine has it but rejects the name, because `PermissionName` is a per-engine list
11
+ * 3. the engine has it and THROWS SYNCHRONOUSLY for a name it does not know, which a `.catch` on
12
+ * the returned promise does not see at all
13
+ * 4. it answers, with a real `PermissionState`
14
+ *
15
+ * Only the fourth is the platform's documented behaviour. The other three are the same question
16
+ * coming back unanswered in three different shapes, and the shapes are not interchangeable: case 3
17
+ * needs a `try` around the CALL rather than a `.catch` on its result, which is the kind of detail
18
+ * that is correct in the one file someone thought about it in and wrong everywhere else.
19
+ *
20
+ * ## The pick
21
+ *
22
+ * All three collapse to `'prompt'`.
23
+ *
24
+ * `'prompt'` rather than `'denied'` because an engine that cannot be asked has not refused anything,
25
+ * and treating silence as refusal costs the person a control that might have worked. `'prompt'` is
26
+ * also the honest description of what happens next: press the button and the browser will decide.
27
+ *
28
+ * WHAT THAT LOSES, stated rather than buried: after the collapse, `'prompt'` means either "the
29
+ * browser will ask" or "the browser cannot be asked at all", and nothing distinguishes them any
30
+ * more. That is deliberate. An app that genuinely needs to tell those apart is asking a question the
31
+ * Permissions API does not answer either, since case 2 and case 4 are indistinguishable from a
32
+ * rejected promise.
33
+ *
34
+ * NOT MEASURED, and this is the one entry in the package without a dated number behind it. What is
35
+ * recorded upstream is the SHAPE of the failure rather than an engine and a version: a query for a
36
+ * name an engine does not implement rejects, or throws outright. Both are handled here, and the
37
+ * absence of a measurement is why this file says so rather than inventing one.
38
+ */
39
+ /** The platform's own `PermissionStatus`, narrowed to the member anything actually reads. */
40
+ export type PermissionStatus = {
41
+ state: PermissionState;
42
+ };
43
+ export declare const permissions: {
44
+ /**
45
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
46
+ *
47
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
48
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
49
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
50
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
51
+ * whatever moment the feature is first used.
52
+ */
53
+ query: (descriptor: PermissionDescriptor) => Promise<PermissionStatus>;
54
+ };
@@ -0,0 +1,22 @@
1
+ //#region src/permissions.ts
2
+ var isState = (value) => value === "granted" || value === "denied" || value === "prompt";
3
+ var permissions = {
4
+ /**
5
+ * Same name and same signature, answering `'prompt'` wherever the platform would not answer.
6
+ *
7
+ * The `await` is INSIDE the `try` on purpose, and it is the whole reason this is a wrapper rather
8
+ * than a one-line `.catch`. `query` can throw synchronously for a name the engine does not know
9
+ * and reject asynchronously for one it does, and a `.catch` on the returned promise sees only the
10
+ * second. Getting that wrong turns a missing permission name into an uncaught exception at
11
+ * whatever moment the feature is first used.
12
+ */
13
+ query: async (descriptor) => {
14
+ try {
15
+ const status = await (globalThis.navigator?.permissions)?.query(descriptor);
16
+ return { state: isState(status?.state) ? status.state : "prompt" };
17
+ } catch {
18
+ return { state: "prompt" };
19
+ }
20
+ } };
21
+ //#endregion
22
+ export { permissions };
package/build/storage.cjs CHANGED
@@ -66,6 +66,40 @@ var reconcile = (estimate, walked) => {
66
66
  }
67
67
  return Math.max(reported, walked);
68
68
  };
69
+ /**
70
+ * The narrowest quota this origin has reported, which is the one that gets reported back.
71
+ *
72
+ * THE DIVERGENCE, AND THE PICK. `quota - usage` already means the same on both engines: bytes you
73
+ * can still write. What diverges is `quota` as a TOTAL, and Chromium never states one. It states
74
+ * `usage + headroom`, recomputed on every read, so the number grows as you fill the origin: measured
75
+ * 10.737 GB rising to 12.353 GB across 1.615 GB written. Firefox states a real total and holds it.
76
+ *
77
+ * Two things a caller does with `quota` are broken by the growing version and neither is exotic:
78
+ *
79
+ * - `usage / quota` as a gauge never fills, because the denominator runs away from the numerator.
80
+ * Ripple showed "2 MB / 10.74 GB" over a library holding 1.78 GB for exactly this reason.
81
+ * - `quota - usage < floor` never fires, so nothing can ever detect pressure. That is what left four
82
+ * of ripple's eviction tests failing for months against a condition that could not occur.
83
+ *
84
+ * So the behaviour picked here is FIREFOX'S: a ceiling, and a ceiling does not rise because you put
85
+ * something under it. It is implemented as the lowest quota seen for this origin, which needs no
86
+ * probe and no user agent sniffing, and it is TRUE in the strong sense: the platform really did say
87
+ * the origin could hold this much, and nothing here invents a number the platform never stated.
88
+ *
89
+ * The cost is deliberate and is in the safe direction. On Chromium, once some bytes are written, the
90
+ * reported headroom is smaller than what could really be written, so a caller reclaims cache
91
+ * slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
92
+ * it never sees pressure is the failure this replaces.
93
+ *
94
+ * In memory, and per page. Persisting it would mean owning storage to describe storage, and a reload
95
+ * re-anchors on a figure the platform stated at that moment, which is the same guarantee.
96
+ */
97
+ var narrowest;
98
+ var ceiling = (reported, usage) => {
99
+ if (reported === void 0) return void 0;
100
+ narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
101
+ return Math.max(narrowest, usage ?? 0);
102
+ };
69
103
  var storage = {
70
104
  /**
71
105
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
@@ -78,13 +112,44 @@ var storage = {
78
112
  const native = globalThis.navigator?.storage;
79
113
  if (!native?.estimate) return {};
80
114
  const estimate = await native.estimate();
81
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
115
+ const usage = reconcile(estimate, native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null);
82
116
  return {
83
117
  ...estimate,
84
- usage: reconcile(estimate, walked)
118
+ usage,
119
+ quota: ceiling(estimate.quota, usage)
85
120
  };
86
121
  },
87
- persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
122
+ /**
123
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
124
+ *
125
+ * TWO PICKS, both inside one call.
126
+ *
127
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
128
+ * app then lives in. Those are different questions and only the second decides anything: whether
129
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
130
+ * already persistent, and an app reading the first answer then offers a button that has nothing
131
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
132
+ * platform will not state the state.
133
+ *
134
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
135
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
136
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
137
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
138
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
139
+ * whatever the platform now says.
140
+ *
141
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
142
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
143
+ * answering, and `persisted()` reports the same false afterwards.
144
+ */
145
+ persist: async () => {
146
+ const native = globalThis.navigator?.storage;
147
+ if (!native?.persist) return false;
148
+ const answered = await native.persist().catch(() => false);
149
+ const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
150
+ if (persisted) narrowest = void 0;
151
+ return persisted;
152
+ },
88
153
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
89
154
  getDirectory: () => {
90
155
  const native = globalThis.navigator?.storage;
@@ -81,6 +81,29 @@ export declare const storage: {
81
81
  * downgrade, because the platform's figure is a floor rather than a guess.
82
82
  */
83
83
  estimate: () => Promise<StorageEstimate>;
84
+ /**
85
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
86
+ *
87
+ * TWO PICKS, both inside one call.
88
+ *
89
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
90
+ * app then lives in. Those are different questions and only the second decides anything: whether
91
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
92
+ * already persistent, and an app reading the first answer then offers a button that has nothing
93
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
94
+ * platform will not state the state.
95
+ *
96
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
97
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
98
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
99
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
100
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
101
+ * whatever the platform now says.
102
+ *
103
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
104
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
105
+ * answering, and `persisted()` reports the same false afterwards.
106
+ */
84
107
  persist: () => Promise<boolean>;
85
108
  persisted: () => Promise<boolean>;
86
109
  getDirectory: () => Promise<FileSystemDirectoryHandle>;
package/build/storage.js CHANGED
@@ -65,6 +65,40 @@ var reconcile = (estimate, walked) => {
65
65
  }
66
66
  return Math.max(reported, walked);
67
67
  };
68
+ /**
69
+ * The narrowest quota this origin has reported, which is the one that gets reported back.
70
+ *
71
+ * THE DIVERGENCE, AND THE PICK. `quota - usage` already means the same on both engines: bytes you
72
+ * can still write. What diverges is `quota` as a TOTAL, and Chromium never states one. It states
73
+ * `usage + headroom`, recomputed on every read, so the number grows as you fill the origin: measured
74
+ * 10.737 GB rising to 12.353 GB across 1.615 GB written. Firefox states a real total and holds it.
75
+ *
76
+ * Two things a caller does with `quota` are broken by the growing version and neither is exotic:
77
+ *
78
+ * - `usage / quota` as a gauge never fills, because the denominator runs away from the numerator.
79
+ * Ripple showed "2 MB / 10.74 GB" over a library holding 1.78 GB for exactly this reason.
80
+ * - `quota - usage < floor` never fires, so nothing can ever detect pressure. That is what left four
81
+ * of ripple's eviction tests failing for months against a condition that could not occur.
82
+ *
83
+ * So the behaviour picked here is FIREFOX'S: a ceiling, and a ceiling does not rise because you put
84
+ * something under it. It is implemented as the lowest quota seen for this origin, which needs no
85
+ * probe and no user agent sniffing, and it is TRUE in the strong sense: the platform really did say
86
+ * the origin could hold this much, and nothing here invents a number the platform never stated.
87
+ *
88
+ * The cost is deliberate and is in the safe direction. On Chromium, once some bytes are written, the
89
+ * reported headroom is smaller than what could really be written, so a caller reclaims cache
90
+ * slightly early. Cache is the thing that can be fetched again; a caller that never reclaims because
91
+ * it never sees pressure is the failure this replaces.
92
+ *
93
+ * In memory, and per page. Persisting it would mean owning storage to describe storage, and a reload
94
+ * re-anchors on a figure the platform stated at that moment, which is the same guarantee.
95
+ */
96
+ var narrowest;
97
+ var ceiling = (reported, usage) => {
98
+ if (reported === void 0) return void 0;
99
+ narrowest = narrowest === void 0 ? reported : Math.min(narrowest, reported);
100
+ return Math.max(narrowest, usage ?? 0);
101
+ };
68
102
  var storage = {
69
103
  /**
70
104
  * Same name and same shape as the platform's, with `usage` MEASURED rather than reported.
@@ -77,13 +111,44 @@ var storage = {
77
111
  const native = globalThis.navigator?.storage;
78
112
  if (!native?.estimate) return {};
79
113
  const estimate = await native.estimate();
80
- const walked = native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null;
114
+ const usage = reconcile(estimate, native.getDirectory ? await native.getDirectory().then((directory) => walkBytes(directory)).catch(() => null) : null);
81
115
  return {
82
116
  ...estimate,
83
- usage: reconcile(estimate, walked)
117
+ usage,
118
+ quota: ceiling(estimate.quota, usage)
84
119
  };
85
120
  },
86
- persist: () => globalThis.navigator?.storage?.persist?.() ?? Promise.resolve(false),
121
+ /**
122
+ * Same name and signature, resolving what the origin IS rather than what the call said it did.
123
+ *
124
+ * TWO PICKS, both inside one call.
125
+ *
126
+ * FIRST, the answer. `persist()` resolves its own claim, and `persisted()` resolves the state the
127
+ * app then lives in. Those are different questions and only the second decides anything: whether
128
+ * the origin can still be evicted. A call can resolve false on an engine where the origin is
129
+ * already persistent, and an app reading the first answer then offers a button that has nothing
130
+ * left to do. So this resolves `persisted()`, falling back to the call's own answer only where the
131
+ * platform will not state the state.
132
+ *
133
+ * SECOND, the ceiling. A granted persist can move the quota by orders of magnitude: measured
134
+ * 2026-09-01 on Firefox, granting the "Store data in persistent storage" doorhanger moved the
135
+ * reported quota from 12 GB to 3.97 TB on an 8.03 TB device, about 330 times. `estimate()` latches
136
+ * the narrowest quota it has seen, which is right while nothing changes the ceiling and wrong the
137
+ * moment something does, so a successful grant forgets it. The next `estimate()` re-anchors on
138
+ * whatever the platform now says.
139
+ *
140
+ * Chromium, measured 2026-08-30 on Chrome 151, refuses this on every attempt with no prompt shown
141
+ * at any point, and the quota stays flat. That is not a failure to handle: it is the engine
142
+ * answering, and `persisted()` reports the same false afterwards.
143
+ */
144
+ persist: async () => {
145
+ const native = globalThis.navigator?.storage;
146
+ if (!native?.persist) return false;
147
+ const answered = await native.persist().catch(() => false);
148
+ const persisted = await native.persisted?.().catch(() => null) ?? null ?? answered;
149
+ if (persisted) narrowest = void 0;
150
+ return persisted;
151
+ },
87
152
  persisted: () => globalThis.navigator?.storage?.persisted?.() ?? Promise.resolve(false),
88
153
  getDirectory: () => {
89
154
  const native = globalThis.navigator?.storage;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/ponyfill",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "type": "module",
5
5
  "main": "build/index.cjs",
6
6
  "module": "build/index.js",
@@ -15,6 +15,16 @@
15
15
  "types": "./build/storage.d.ts",
16
16
  "import": "./build/storage.js",
17
17
  "require": "./build/storage.cjs"
18
+ },
19
+ "./permissions": {
20
+ "types": "./build/permissions.d.ts",
21
+ "import": "./build/permissions.js",
22
+ "require": "./build/permissions.cjs"
23
+ },
24
+ "./file-system": {
25
+ "types": "./build/file-system.d.ts",
26
+ "import": "./build/file-system.js",
27
+ "require": "./build/file-system.cjs"
18
28
  }
19
29
  },
20
30
  "scripts": {