kenspeckle 0.1.4 → 0.2.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/LICENSE CHANGED
@@ -19,3 +19,16 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
19
  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
20
  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
21
  SOFTWARE.
22
+
23
+ ---
24
+
25
+ Third-party notices
26
+
27
+ Portions of this software are adapted from the following projects, each
28
+ distributed under the MIT License above with its own copyright notice.
29
+
30
+ runed
31
+ Copyright (c) 2024 Hunter Johnston <https://github.com/huntabyte>
32
+
33
+ svelte-put
34
+ Copyright (c) 2023-2024 Quang Phan
package/README.md CHANGED
@@ -3,116 +3,119 @@
3
3
  <img src="docs/assets/banner-light.svg" alt="kenspeckle, adjective, Scots, from Old Norse kennispeki: 1. easily recognised; conspicuous; familiar at sight. 2. (of Svelte utilities) knowable from the name alone.">
4
4
  </picture>
5
5
 
6
- Svelte utilities you recognize at sight. One naming convention, no `use` prefix, attachments first-class.
6
+ Svelte utilities you recognize at sight: each one is shaped the way Svelte shapes its own APIs, so it reads like something Svelte might have shipped.
7
7
 
8
- ## The sentence test
8
+ ## Shaped like Svelte
9
9
 
10
- Every export earns its shape by being said aloud: _"I want a new \___."_
10
+ A utility makes the cut only if Svelte doesn't already handle it. Handled means a Svelte or Kit API covers it (a binding, `on()`, `onDestroy`, `<svelte:document>`, an `$effect` with a returned cleanup), the platform does (`field-sizing`, `:focus-within`), or a few lines of Svelte do, in which case it becomes a [recipe](https://kenspeckle.brandonpittman.workers.dev/docs/recipes) instead of an export.
11
11
 
12
- - "a new **finite state machine**" — speakable, and you drive it with methods → `new FiniteStateMachine()`
13
- - "a new **element size**" — not a thing you ask for → `elementSize()`
14
- - "a new **is idle**" — nonsense → `idle()`
12
+ What makes the cut takes the shape Svelte already uses for the same kind of thing. Ask these in order; the first yes decides:
15
13
 
16
- Two rules fall out:
14
+ 1. **Is it global browser state?** A singleton with `current`, like `innerWidth` in `svelte/reactivity/window`: `documentVisible.current`. It reads the live value, and on the server it is `undefined`, as Svelte's are. Where a `<svelte:document>` or `<svelte:window>` binding already exists, the singleton earns its place only if modules need to read it outside a component: a polling store that pauses in a hidden tab does, and focus logic, which lives with its element, doesn't.
15
+ 2. **Is it a value configured at construction?** A class with `current`, like `new MediaQuery(query)`: `new Idle(60_000)`, `new PersistedState('theme', 'light')`.
16
+ 3. **Is it a value that follows another over time?** A class with `.of(fn)`, like `Spring.of(() => number)`, called in an effect root such as component init. It earns the class only if `current` holds something you couldn't produce where the value is written, the way a tween's in-between frames are. A debounced value and a previous value both fail this: each is a value already written, so `debounce()` or a saved copy at the write site covers it.
17
+ 4. **Do you drive it with methods?** A class: `new FiniteStateMachine()`, `new StateHistory()`.
18
+ 5. **Is it behavior on an element?** An attachment, named for what it does, as `copy` and `fade` are (`dragScroll`, `lockScroll`), or for the event it answers (`clickOutside`). An element value flows into your own `$state` through a callback, the way a function binding does (`bind:clientWidth={null, setWidth}`), never through a box the library owns, and it is named for that value, as a binding is (`scrollEdges`).
19
+ 6. **Otherwise**, a plain function, with no `use` prefix, which is a React Hooks rule Svelte doesn't have.
17
20
 
18
- 1. **Class** only when "new X" is speakable _and_ you drive it with methods. There are exactly two: `FiniteStateMachine` and `StateHistory`.
19
- 2. **Everything else is a camelCase function.** No `use` prefix — that's a React Hooks rule, and Svelte has no such rule. No `is` prefix either — predicates are bare words read as implicit questions: `mounted()`, `idle()`, `documentVisible()`. The question lives in the library, the answer in your variable: `const isMounted = mounted()`.
21
+ Each shape names the Svelte API it mirrors. An export that can't name one isn't shaped yet.
20
22
 
21
- That's why the name. Kenspeckle things are known at sight; so is every export here. The word is the spec.
23
+ ## Attachments, one form
22
24
 
23
- ## Attachments, curried
24
-
25
- Element-bound utilities are one name in two forms. Options only → returns an [attachment](https://svelte.dev/docs/svelte/@attach). Element first → the imperative helper, cleanup returned.
25
+ An element-bound utility returns an [attachment](https://svelte.dev/docs/svelte/@attach) and has no second form. An attachment is a function of the element, so outside a template you call it on one and get its cleanup back.
26
26
 
27
27
  ```svelte
28
28
  <script>
29
- // same import, imperative
30
- const cleanup = clickOutside(node, () => close());
29
+ // outside a template
30
+ const cleanup = clickOutside(() => close())(node);
31
31
  </script>
32
32
 
33
33
  <div {@attach clickOutside(() => close())}>…</div>
34
34
  ```
35
35
 
36
- Value-producing attachments are readable boxes — the factory's return _is_ the attachment, carrying reactive getters:
36
+ An attachment that observes the element hands what it sees to a callback, so the value lives in your state:
37
37
 
38
38
  ```svelte
39
39
  <script>
40
- import { elementSize } from 'kenspeckle';
40
+ import { scrollEdges } from 'kenspeckle';
41
41
 
42
- const size = elementSize(); // no target yet
42
+ let atBottom = $state(false);
43
43
  </script>
44
44
 
45
- <div {@attach size}>…</div><p>{size.width} × {size.height}</p>
45
+ <div {@attach scrollEdges((edges) => (atBottom = edges.blockEnd))}>…</div>
46
46
  ```
47
47
 
48
- The same export works imperatively with a getter: `elementSize(() => node)`. Argument presence picks the mode.
49
-
50
48
  ## Install
51
49
 
52
50
  ```sh
53
51
  npm install kenspeckle
54
52
  ```
55
53
 
56
- Peer dependencies: `svelte >= 5.40`, plus `@sveltejs/kit >= 2` for the `kenspeckle/kit` subpath (optional). SSR-safe — value factories return inert defaults on the server.
54
+ Peer dependencies: `svelte >= 5.40`, plus `@sveltejs/kit >= 2` for the `kenspeckle/kit` subpath (optional). SSR-safe — singletons read `undefined` on the server, as Svelte's window values do.
57
55
 
58
56
  ## What's shipped
59
57
 
60
- Five exports, two entry points. `kenspeckle` is Svelte-only; `kenspeckle/kit` adds the SvelteKit
58
+ Ten exports, two entry points, each documented under [the docs site](https://kenspeckle.brandonpittman.workers.dev/docs). `kenspeckle` is Svelte-only; `kenspeckle/kit` adds the SvelteKit
61
59
  pieces, so `$app/navigation` never enters the main entry.
62
60
 
63
- | export | form | lineage |
64
- | ----------------------- | -------------------------------------- | -------------------------- |
65
- | `FiniteStateMachine` | class, with typed reactive `context` | runed `FiniteStateMachine` |
66
- | `copy()` / `copyText()` | attachment + helper | svelte-put `copy` |
67
- | `viewTransition()` | function; navigation form under `/kit` | new |
68
- | `viewTransitionName()` | attachment + helper, `/kit` | new |
69
- | `retreat()` | function, returns a disposer, `/kit` | new |
70
-
71
- `FiniteStateMachine` gains a typed, `$state`-backed `context` object visible to lifecycle hooks and
61
+ | export | form |
62
+ | ------------------------------------------------------------------------------------------------- | -------------------------------------- |
63
+ | [`FiniteStateMachine`](https://kenspeckle.brandonpittman.workers.dev/docs/finite-state-machine) | class, with typed reactive `context` |
64
+ | [`PersistedState`](https://kenspeckle.brandonpittman.workers.dev/docs/persisted-state) | class, `.current` |
65
+ | [`documentVisible`](https://kenspeckle.brandonpittman.workers.dev/docs/document-visible) | singleton, `.current` |
66
+ | [`copy()` / `copyText()`](https://kenspeckle.brandonpittman.workers.dev/docs/copy) | attachment |
67
+ | [`scrollEdges()`](https://kenspeckle.brandonpittman.workers.dev/docs/scroll-edges) | attachment, edges to a callback |
68
+ | [`viewTransition()`](https://kenspeckle.brandonpittman.workers.dev/docs/view-transition) | function; navigation form under `/kit` |
69
+ | [`viewTransitionName()`](https://kenspeckle.brandonpittman.workers.dev/docs/view-transition-name) | attachment, `/kit` |
70
+ | [`retreat()`](https://kenspeckle.brandonpittman.workers.dev/docs/view-transition) | function, returns a disposer, `/kit` |
71
+ | [`debounce()`](https://kenspeckle.brandonpittman.workers.dev/docs/debounce) | function wrapper |
72
+ | [`throttle()`](https://kenspeckle.brandonpittman.workers.dev/docs/throttle) | function wrapper |
73
+
74
+ `FiniteStateMachine` has a typed, `$state`-backed `context` object visible to lifecycle hooks and
72
75
  guards — the sidecar-data mechanism every real FSM grows, built in.
73
76
 
74
77
  View transitions are what the package is for so far; they get their own section below.
75
78
 
76
79
  ## Planned
77
80
 
78
- The rest of the curation. These names are the design, not an API you can call — nothing below has
81
+ The rest of the plan. These names are the design, not an API you can call — nothing below has
79
82
  shipped.
80
83
 
81
- | export | form | lineage |
82
- | ------------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------- |
83
- | `StateHistory` | class | runed `StateHistory` |
84
- | `mounted()` | value factory | runed `IsMounted` |
85
- | `idle()` | value factory | runed `IsIdle` |
86
- | `documentVisible()` | value factory | runed `IsDocumentVisible` |
87
- | `focusWithin()` | attachment, readable box | runed `IsFocusWithin` |
88
- | `inViewport()` | attachment, readable box | runed `IsInViewport` |
89
- | `elementSize()` | attachment, readable box | runed `ElementSize` |
90
- | `elementRect()` | attachment, readable box | runed `ElementRect` |
91
- | `scrollState()` | attachment, readable box; window form | runed `ScrollState` |
92
- | `autosize()` | attachment | runed `TextareaAutosize` |
93
- | `clickOutside()` | attachment + helper | runed `onClickOutside` · svelte-put `clickoutside` |
94
- | `intersected()` | attachment + helper | runed `useIntersectionObserver` · svelte-put `intersect` |
95
- | `resized()` | attachment + helper | runed `useResizeObserver` · svelte-put `resize` |
96
- | `mutated()` | attachment + helper | runed `useMutationObserver` |
97
- | `shortcut()` | attachment + helper | svelte-put `shortcut` |
98
- | `lockScroll` | attachment ≡ helper, stacked lock counting | new |
99
- | `dragScroll()` | attachment | svelte-put `dragscroll` |
100
- | `debounced()` / `throttled()` | value factory | runed `Debounced` / `Throttled` |
101
- | `debounce()` / `throttle()` | function wrapper | runed `useDebounce` / `useThrottle` |
102
- | `previous()` | value factory | runed `Previous` |
103
- | `activeElement()` | value factory | runed `ActiveElement` |
104
- | `pressedKeys()` | value factory | runed `PressedKeys` |
105
- | `persisted()` | value factory | runed `PersistedState` |
106
- | `animationFrames()` | value factory | runed `AnimationFrames` |
107
- | `geolocation()` | value factory | runed `useGeolocation` |
108
- | `listen()` | wiring | runed `useEventListener` |
109
- | `interval()` | wiring | runed `useInterval` |
110
- | `searchParams()` + helpers | value factory | runed `useSearchParams` |
111
- | `watch` / `watchOnce` / `extract` / `onCleanup` / `boolAttr` | unchanged | runed |
112
-
113
- Dropped, deliberately: runed's `Context` — Svelte ≥ 5.40's `createContext` returns a typed
114
- `[get, set]` pair and covers it. runed's `resource` waits in the backlog — SvelteKit remote functions
115
- cover the server-data case; it returns only if a real client-only async need shows up.
84
+ | export | form |
85
+ | --------------------- | ------------------------------------------------------------------------------------------------- |
86
+ | `StateHistory` | class: `new StateHistory(get, set)`, `.undo()`, `.redo()` |
87
+ | `Idle` | class: `new Idle(ms)`, `.current` |
88
+ | `AsyncPersistedState` | class over IndexedDB: `await` it, or read `.current`, `.loading`, `.error`, like a remote `query` |
89
+ | `CurrentPosition` | class: `new CurrentPosition(options)`, `.current` |
90
+ | `pressedKeys` | singleton, read like a set: `.has(key)` |
91
+ | `clickOutside()` | attachment |
92
+ | `shortcut()` | attachment |
93
+ | `lockScroll` | attachment ≡ helper, stacked lock counting |
94
+ | `dragScroll()` | attachment |
95
+
96
+ Dropped, deliberately, because Svelte, Kit or the platform already handles it:
97
+
98
+ - `Context`: Svelte ≥ 5.40's `createContext` returns a typed `[get, set]` pair.
99
+ - `Debounced` / `Throttled`: `debounce()` / `throttle()` writing into a `$state`, with any effect that follows a source kept in the component that owns it.
100
+ - `elementSize`, `elementRect`, `resized`: the dimension bindings (`bind:clientWidth`, `bind:contentRect`, `bind:borderBoxSize`) already measure with a `ResizeObserver`.
101
+ - `autosize`: CSS `field-sizing: content`, Baseline since June 2026.
102
+ - `intersected`, `inViewport`: an inline attachment that creates an `IntersectionObserver` and returns its `disconnect`.
103
+ - `mutated`: the same inline attachment around a `MutationObserver`.
104
+ - `focusWithin`: CSS `:focus-within` for styling, and `onfocusin` / `onfocusout` with `relatedTarget` for a value.
105
+ - `scrollState`, except its edges: `onscroll` gives an element's position, a comparison with the last value gives direction, and `<svelte:window bind:scrollY>` covers the window.
106
+ - `activeElement`: `<svelte:document bind:activeElement />`, since focus logic lives in the component that holds the element.
107
+ - `Previous`: save the old value where you write the new one (`previous = count; count = next`); for a prop you don't write, an `$effect.pre` does it.
108
+ - `mounted`: `onMount` setting a `$state`.
109
+ - `listen`: `on()` from `svelte/events`.
110
+ - `interval`, `animationFrames`: an `$effect` that starts the timer or loop and returns its cleanup.
111
+ - `onCleanup`: `onDestroy`, or an effect's returned cleanup.
112
+ - `watch` / `watchOnce`: an effect responds to state being a certain way, not to it changing; handle the change where it happens.
113
+ - `extract`: nothing here takes a value-or-getter.
114
+ - `boolAttr`: Svelte omits nullish attributes, so `data-x={flag || undefined}`.
115
+
116
+ The [recipes](https://kenspeckle.brandonpittman.workers.dev/docs/recipes) page spells out the replacements for `autosize`, `intersected`, `mutated`, `focusWithin` and `Previous`.
117
+
118
+ Waiting in the backlog: `resource`, since SvelteKit remote functions cover the server-data case, and `searchParams`, since Kit's `page.url.searchParams` and `goto` cover reads and writes until a typed layer earns its place.
116
119
 
117
120
  ## View transitions
118
121
 
@@ -195,11 +198,11 @@ scoped rather than left in static CSS.
195
198
  ```
196
199
 
197
200
  `when` claims only for navigations it accepts; `onArrival` claims inside the update callback instead,
198
- for an element that mounts during the transition. The imperative form takes the element first and
199
- returns a disposer:
201
+ for an element that mounts during the transition. Outside a template, call the attachment on the
202
+ element to get its disposer:
200
203
 
201
204
  ```js
202
- const dispose = viewTransitionName(element, 'back-button');
205
+ const dispose = viewTransitionName('back-button')(element);
203
206
  ```
204
207
 
205
208
  **Claims are navigation-only.** The registration form drives the capture and arrival phases;
@@ -208,18 +211,16 @@ const dispose = viewTransitionName(element, 'back-button');
208
211
 
209
212
  ## Claude Code plugin
210
213
 
211
- This repo doubles as a Claude Code plugin. `skills/view-transitions/` is the
212
- agent-facing guide to the view-transition tier — the `data-view-transition`
213
- contract, `viewTransitionName` claims and their phases, the duplicate-name
214
- abort, and the list of things the library already handles so an agent stops
215
- reimplementing them. Point a marketplace entry at this repo
214
+ This repo doubles as a Claude Code plugin with two agent-facing guides.
215
+ `skills/view-transitions/` covers the view-transition tier — the
216
+ `data-view-transition` contract, `viewTransitionName` claims and their phases,
217
+ the duplicate-name abort, and the list of things the library already handles so
218
+ an agent stops reimplementing them. `skills/finite-state-machine/` covers
219
+ `FiniteStateMachine` — the always-transitions rule, the `'*'` re-entry hazard,
220
+ lifecycle ordering and context. Point a marketplace entry at this repo
216
221
  (`.claude-plugin/plugin.json` is at the root) and it installs as `kenspeckle`,
217
- invoked as `/kenspeckle:view-transitions`.
222
+ invoked as `/kenspeckle:view-transitions` and `/kenspeckle:finite-state-machine`.
223
+ It bundles no MCP server; for the Svelte MCP, install the official `svelte`
224
+ plugin from [`sveltejs/ai-tools`](https://github.com/sveltejs/ai-tools).
218
225
 
219
226
  The skill and the code ship in the same change, same rule as the docs pages.
220
-
221
- ## Provenance
222
-
223
- kenspeckle is a curation, not a fork. [runed](https://runed.dev) has genuinely useful utilities under three naming regimes at once — PascalCase classes, bare functions, and React-style `use` hooks, with pairs like `Debounced` / `useDebounce` coexisting. [svelte-put](https://svelte-put.vnphanquang.com) has good actions from the Svelte 4 era, before attachments existed. kenspeckle reshapes both pools under one convention and adds what neither ships: attachments as a first-class layer.
224
-
225
- Portions adapted from runed (MIT) and svelte-put (MIT). kenspeckle is MIT.
package/dist/copy.d.ts CHANGED
@@ -1,4 +1,3 @@
1
- import type { Attachment } from 'svelte/attachments';
2
1
  export type CopyText = string | ((node: HTMLElement) => string | Promise<string>);
3
2
  export interface CopyOptions<K extends keyof HTMLElementEventMap = 'click'> {
4
3
  text?: CopyText;
@@ -7,5 +6,4 @@ export interface CopyOptions<K extends keyof HTMLElementEventMap = 'click'> {
7
6
  onCopy?: (text: string) => void;
8
7
  }
9
8
  export declare function copyText(text: string): Promise<void>;
10
- export declare function copy<K extends keyof HTMLElementEventMap = 'click'>(options?: CopyOptions<K>): Attachment<HTMLElement>;
11
- export declare function copy<K extends keyof HTMLElementEventMap = 'click'>(node: HTMLElement, options?: CopyOptions<K>): () => void;
9
+ export declare function copy<K extends keyof HTMLElementEventMap = 'click'>(options?: CopyOptions<K>): (node: HTMLElement) => () => void;
package/dist/copy.js CHANGED
@@ -15,26 +15,21 @@ export async function copyText(text) {
15
15
  document.body.removeChild(el);
16
16
  }
17
17
  }
18
- export function copy(nodeOrOptions, maybeOptions) {
19
- if (typeof Element !== 'undefined' && nodeOrOptions instanceof Element) {
20
- return wire(nodeOrOptions, maybeOptions);
21
- }
22
- const options = nodeOrOptions;
23
- return (node) => wire(node, options);
24
- }
25
- function wire(node, options = {}) {
26
- const { text, event, enabled = true, onCopy } = options;
27
- if (!enabled)
28
- return () => { };
29
- const events = (event === undefined ? ['click'] : Array.isArray(event) ? event : [event]);
30
- async function handler() {
31
- const resolved = typeof text === 'function' ? await text(node) : (text ?? node.textContent ?? '');
32
- await copyText(resolved);
33
- onCopy?.(resolved);
34
- }
35
- const offs = events.map((e) => on(node, e, handler));
36
- return () => {
37
- for (const off of offs)
38
- off();
18
+ export function copy(options = {}) {
19
+ return (node) => {
20
+ const { text, event, enabled = true, onCopy } = options;
21
+ if (!enabled)
22
+ return () => { };
23
+ const events = (event === undefined ? ['click'] : Array.isArray(event) ? event : [event]);
24
+ async function handler() {
25
+ const resolved = typeof text === 'function' ? await text(node) : (text ?? node.textContent ?? '');
26
+ await copyText(resolved);
27
+ onCopy?.(resolved);
28
+ }
29
+ const offs = events.map((e) => on(node, e, handler));
30
+ return () => {
31
+ for (const off of offs)
32
+ off();
33
+ };
39
34
  };
40
35
  }
@@ -0,0 +1,29 @@
1
+ export type DebouncedFunction<Args extends unknown[], Return> = {
2
+ (...args: Args): Promise<Awaited<Return>>;
3
+ readonly pending: boolean;
4
+ flush(): void;
5
+ cancel(): void;
6
+ };
7
+ export declare const DEFAULT_WAIT = 250;
8
+ /**
9
+ * `wait` is a getter so `FiniteStateMachine.debounce` can take a different wait on each call.
10
+ * `leading` runs a call at once when no window is open, and the window it opens is fixed: later
11
+ * calls wait for it to end instead of restarting it.
12
+ */
13
+ export declare class Scheduler<Args extends unknown[], Return> {
14
+ #private;
15
+ /**
16
+ * `reactive: false` keeps `pending` out of Svelte's graph, so a `$derived` or template expression
17
+ * can call the scheduler: writing `$state` there throws `state_unsafe_mutation`.
18
+ */
19
+ constructor(callback: (...args: Args) => Return, wait: () => number, { leading, reactive }?: {
20
+ leading?: boolean;
21
+ reactive?: boolean;
22
+ });
23
+ get pending(): boolean;
24
+ call(...args: Args): Promise<Awaited<Return>>;
25
+ flush(): void;
26
+ /** The promise is left unsettled: rejecting would be unhandled in every fire-and-forget caller. */
27
+ cancel(): void;
28
+ }
29
+ export declare function debounce<Args extends unknown[], Return>(callback: (...args: Args) => Return, wait?: number): DebouncedFunction<Args, Return>;
@@ -0,0 +1,105 @@
1
+ export const DEFAULT_WAIT = 250;
2
+ /** `Promise.withResolvers` would raise the runtime floor to Safari 17.4 and Node 22. */
3
+ function withResolvers() {
4
+ let resolve;
5
+ let reject;
6
+ const promise = new Promise((res, rej) => {
7
+ resolve = res;
8
+ reject = rej;
9
+ });
10
+ return { promise, resolve, reject };
11
+ }
12
+ /**
13
+ * `wait` is a getter so `FiniteStateMachine.debounce` can take a different wait on each call.
14
+ * `leading` runs a call at once when no window is open, and the window it opens is fixed: later
15
+ * calls wait for it to end instead of restarting it.
16
+ */
17
+ export class Scheduler {
18
+ #callback;
19
+ #wait;
20
+ #leading;
21
+ #timer;
22
+ /** Plain, so a call made inside an `$effect` doesn't make the effect depend on it. */
23
+ #scheduled;
24
+ #reactive;
25
+ #tracked = $state(false);
26
+ #untracked = false;
27
+ /**
28
+ * `reactive: false` keeps `pending` out of Svelte's graph, so a `$derived` or template expression
29
+ * can call the scheduler: writing `$state` there throws `state_unsafe_mutation`.
30
+ */
31
+ constructor(callback, wait, { leading = false, reactive = true } = {}) {
32
+ this.#callback = callback;
33
+ this.#wait = wait;
34
+ this.#leading = leading;
35
+ this.#reactive = reactive;
36
+ }
37
+ get pending() {
38
+ return this.#pending;
39
+ }
40
+ get #pending() {
41
+ return this.#reactive ? this.#tracked : this.#untracked;
42
+ }
43
+ set #pending(value) {
44
+ if (this.#reactive)
45
+ this.#tracked = value;
46
+ else
47
+ this.#untracked = value;
48
+ }
49
+ call(...args) {
50
+ if (this.#leading && this.#timer === undefined) {
51
+ const deferred = withResolvers();
52
+ this.#start();
53
+ this.#run({ args, deferred });
54
+ return deferred.promise;
55
+ }
56
+ const deferred = this.#scheduled?.deferred ?? withResolvers();
57
+ this.#scheduled = { args, deferred };
58
+ this.#pending = true;
59
+ if (!this.#leading)
60
+ this.#start();
61
+ return deferred.promise;
62
+ }
63
+ flush() {
64
+ clearTimeout(this.#timer);
65
+ this.#elapse();
66
+ }
67
+ /** The promise is left unsettled: rejecting would be unhandled in every fire-and-forget caller. */
68
+ cancel() {
69
+ clearTimeout(this.#timer);
70
+ this.#timer = undefined;
71
+ this.#scheduled = undefined;
72
+ this.#pending = false;
73
+ }
74
+ #start() {
75
+ clearTimeout(this.#timer);
76
+ this.#timer = setTimeout(() => this.#elapse(), this.#wait());
77
+ }
78
+ #elapse() {
79
+ this.#timer = undefined;
80
+ const scheduled = this.#scheduled;
81
+ if (scheduled === undefined)
82
+ return;
83
+ this.#scheduled = undefined;
84
+ this.#pending = false;
85
+ if (this.#leading)
86
+ this.#start();
87
+ this.#run(scheduled);
88
+ }
89
+ #run({ args, deferred }) {
90
+ try {
91
+ deferred.resolve(this.#callback(...args));
92
+ }
93
+ catch (error) {
94
+ deferred.reject(error);
95
+ }
96
+ }
97
+ }
98
+ export function debounce(callback, wait = DEFAULT_WAIT) {
99
+ const scheduler = new Scheduler(callback, () => wait);
100
+ return Object.defineProperties((...args) => scheduler.call(...args), {
101
+ pending: { get: () => scheduler.pending },
102
+ flush: { value: () => scheduler.flush() },
103
+ cancel: { value: () => scheduler.cancel() }
104
+ });
105
+ }
@@ -0,0 +1,4 @@
1
+ export interface DocumentVisible {
2
+ readonly current: boolean | undefined;
3
+ }
4
+ export declare const documentVisible: DocumentVisible;
@@ -0,0 +1,12 @@
1
+ import { BROWSER } from 'esm-env';
2
+ import { on } from 'svelte/events';
3
+ import { createSubscriber } from 'svelte/reactivity';
4
+ const subscribe = createSubscriber((update) => on(document, 'visibilitychange', update));
5
+ export const documentVisible = {
6
+ get current() {
7
+ if (!BROWSER)
8
+ return undefined;
9
+ subscribe();
10
+ return document.visibilityState === 'visible';
11
+ }
12
+ };
@@ -1,4 +1,5 @@
1
1
  import { DEV } from 'esm-env';
2
+ import { DEFAULT_WAIT, Scheduler } from './debounce.svelte.js';
2
3
  export class FiniteStateMachine {
3
4
  #current = $state();
4
5
  #context = $state();
@@ -35,24 +36,14 @@ export class FiniteStateMachine {
35
36
  this.#transition(target, event, args);
36
37
  return this.#current;
37
38
  };
38
- #timeouts = {};
39
- debounce = (event, wait = 500, ...args) => {
40
- const pending = this.#timeouts[event];
41
- if (pending)
42
- clearTimeout(pending.id);
43
- const resolvers = pending?.resolvers ?? [];
44
- return new Promise((resolve) => {
45
- resolvers.push(resolve);
46
- this.#timeouts[event] = {
47
- id: setTimeout(() => {
48
- delete this.#timeouts[event];
49
- const state = this.send(event, ...args);
50
- for (const r of resolvers)
51
- r(state);
52
- }, wait),
53
- resolvers
54
- };
39
+ #debounced = {};
40
+ debounce = (event, wait = DEFAULT_WAIT, ...args) => {
41
+ const debounced = (this.#debounced[event] ??= {
42
+ wait,
43
+ scheduler: new Scheduler((...args) => this.send(event, ...args), () => debounced.wait, { reactive: false })
55
44
  });
45
+ debounced.wait = wait;
46
+ return debounced.scheduler.call(...args);
56
47
  };
57
48
  #transition(to, event, args) {
58
49
  const from = this.#current;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  export { copy, copyText } from './copy.js';
2
2
  export type { CopyOptions, CopyText } from './copy.js';
3
+ export { debounce } from './debounce.svelte.js';
4
+ export type { DebouncedFunction } from './debounce.svelte.js';
5
+ export { documentVisible } from './document-visible.js';
6
+ export type { DocumentVisible } from './document-visible.js';
3
7
  export { FiniteStateMachine } from './finite-state-machine.svelte.js';
4
8
  export type { Action, ActionMeta, EnterMeta, EventMap, ExitMeta, StateHandler, Transition } from './finite-state-machine.svelte.js';
9
+ export { PersistedState } from './persisted-state.svelte.js';
10
+ export type { PersistedStateOptions } from './persisted-state.svelte.js';
11
+ export { scrollEdges } from './scroll-edges.js';
12
+ export type { ScrollEdges, ScrollEdgesOptions } from './scroll-edges.js';
13
+ export { throttle } from './throttle.svelte.js';
14
+ export type { ThrottledFunction } from './throttle.svelte.js';
5
15
  export { viewTransition } from './view-transition.js';
6
16
  export type { StartViewTransition, ViewTransitionOptions, ViewTransitionUpdate } from './view-transition.js';
package/dist/index.js CHANGED
@@ -1,3 +1,8 @@
1
1
  export { copy, copyText } from './copy.js';
2
+ export { debounce } from './debounce.svelte.js';
3
+ export { documentVisible } from './document-visible.js';
2
4
  export { FiniteStateMachine } from './finite-state-machine.svelte.js';
5
+ export { PersistedState } from './persisted-state.svelte.js';
6
+ export { scrollEdges } from './scroll-edges.js';
7
+ export { throttle } from './throttle.svelte.js';
3
8
  export { viewTransition } from './view-transition.js';
@@ -1,4 +1,3 @@
1
- import type { Attachment } from 'svelte/attachments';
2
1
  import type { Navigation } from './types.js';
3
2
  export interface ViewTransitionNameOptions {
4
3
  /** Only claim the name for navigations this returns true for. */
@@ -11,6 +10,5 @@ type Phase = 'capture' | 'arrival';
11
10
  export declare function beginPhase(navigation: Navigation, phase: Phase): void;
12
11
  /** @internal */
13
12
  export declare function endTransition(): void;
14
- export declare function viewTransitionName(name: string, options?: ViewTransitionNameOptions): Attachment<HTMLElement>;
15
- export declare function viewTransitionName(element: HTMLElement, name: string, options?: ViewTransitionNameOptions): () => void;
13
+ export declare function viewTransitionName(name: string, options?: ViewTransitionNameOptions): (element: HTMLElement) => () => void;
16
14
  export {};
@@ -28,21 +28,16 @@ export function endTransition() {
28
28
  for (const claim of claims)
29
29
  release(claim);
30
30
  }
31
- function wire(element, name, options = {}) {
32
- const claim = { element, name, options, named: false };
33
- claims.add(claim);
34
- // An element mounting mid-transition registers after its phase already swept.
35
- if (current && wanted(claim, current.phase, current.navigation))
36
- apply(claim);
37
- return () => {
38
- release(claim);
39
- claims.delete(claim);
31
+ export function viewTransitionName(name, options = {}) {
32
+ return (element) => {
33
+ const claim = { element, name, options, named: false };
34
+ claims.add(claim);
35
+ /** An element mounting mid-transition registers after its phase already swept. */
36
+ if (current && wanted(claim, current.phase, current.navigation))
37
+ apply(claim);
38
+ return () => {
39
+ release(claim);
40
+ claims.delete(claim);
41
+ };
40
42
  };
41
43
  }
42
- export function viewTransitionName(first, second, third) {
43
- if (typeof Element !== 'undefined' && first instanceof Element) {
44
- return wire(first, second, third);
45
- }
46
- const options = second;
47
- return (element) => wire(element, first, options);
48
- }
@@ -0,0 +1,9 @@
1
+ export interface PersistedStateOptions {
2
+ storage?: 'local' | 'session';
3
+ }
4
+ export declare class PersistedState<T> {
5
+ #private;
6
+ constructor(key: string, initial: T, options?: PersistedStateOptions);
7
+ get current(): T;
8
+ set current(value: T);
9
+ }
@@ -0,0 +1,129 @@
1
+ import { parse, stringify } from 'devalue';
2
+ import { BROWSER, DEV } from 'esm-env';
3
+ import { untrack } from 'svelte';
4
+ import { on } from 'svelte/events';
5
+ import { createSubscriber } from 'svelte/reactivity';
6
+ const probe = Symbol();
7
+ /**
8
+ * Freezing a `$state` proxy throws only after its target is already non-extensible, which breaks the
9
+ * caller's state. The proxy refuses a non-enumerable property before touching its target, so this
10
+ * probe tells one apart without changing anything.
11
+ */
12
+ function isStateProxy(value) {
13
+ try {
14
+ const descriptor = { value: undefined, enumerable: false, configurable: true, writable: true };
15
+ if (Reflect.defineProperty(value, probe, descriptor))
16
+ Reflect.deleteProperty(value, probe);
17
+ return false;
18
+ }
19
+ catch {
20
+ return true;
21
+ }
22
+ }
23
+ function freeze(value) {
24
+ if (!DEV || value === null || typeof value !== 'object')
25
+ return value;
26
+ const prototype = Object.getPrototypeOf(value);
27
+ if ((Array.isArray(value) || prototype === Object.prototype || prototype === null) &&
28
+ !isStateProxy(value)) {
29
+ Object.freeze(value);
30
+ }
31
+ return value;
32
+ }
33
+ export class PersistedState {
34
+ #key;
35
+ #initial;
36
+ #area;
37
+ #value;
38
+ /** `undefined` until the first read, so a stored `null` (an empty key) still counts as seen. */
39
+ #seen;
40
+ /** Wakes readers after an own write, including one that never reached storage. */
41
+ #version = $state.raw(0);
42
+ /** Set while `#value` holds a write storage refused, so any later write to the key replaces it. */
43
+ #unsaved = false;
44
+ #subscribe = createSubscriber((update) => on(window, 'storage', (event) => {
45
+ if (event.storageArea !== this.#storage())
46
+ return;
47
+ if (event.key !== null && event.key !== this.#key)
48
+ return;
49
+ /**
50
+ * The write may have stored the very string `#seen` captured at the failure, which the getter
51
+ * cannot tell apart from no write at all.
52
+ */
53
+ if (this.#unsaved) {
54
+ this.#unsaved = false;
55
+ this.#seen = undefined;
56
+ }
57
+ update();
58
+ }));
59
+ constructor(key, initial, options = {}) {
60
+ this.#key = key;
61
+ this.#initial = freeze(initial);
62
+ this.#area = `${options.storage ?? 'local'}Storage`;
63
+ this.#value = initial;
64
+ }
65
+ #storage() {
66
+ try {
67
+ return window[this.#area];
68
+ }
69
+ catch {
70
+ return undefined;
71
+ }
72
+ }
73
+ /** `undefined` when storage refuses the read. */
74
+ #read() {
75
+ try {
76
+ return this.#storage()?.getItem(this.#key) ?? null;
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ #fail(error) {
83
+ if (DEV)
84
+ console.warn(`PersistedState could not write "${this.#key}"`, error);
85
+ /** Marks what storage still holds as seen, so the next read keeps the in-memory value. */
86
+ this.#seen = this.#read();
87
+ this.#unsaved = true;
88
+ }
89
+ get current() {
90
+ void this.#version;
91
+ if (!BROWSER)
92
+ return this.#value;
93
+ this.#subscribe();
94
+ const raw = this.#read();
95
+ if (raw === undefined || raw === this.#seen)
96
+ return this.#value;
97
+ this.#seen = raw;
98
+ try {
99
+ this.#value = raw === null ? this.#initial : freeze(parse(raw));
100
+ }
101
+ catch {
102
+ this.#value = this.#initial;
103
+ }
104
+ return this.#value;
105
+ }
106
+ set current(value) {
107
+ const raw = stringify(value);
108
+ this.#value = freeze(value);
109
+ /** Untracked, so an effect that assigns `current` does not come to depend on its own write and rerun forever. */
110
+ this.#version = untrack(() => this.#version) + 1;
111
+ if (!BROWSER)
112
+ return;
113
+ const storage = this.#storage();
114
+ if (!storage) {
115
+ this.#fail(new Error(`${this.#area} is unavailable`));
116
+ return;
117
+ }
118
+ try {
119
+ storage.setItem(this.#key, raw);
120
+ }
121
+ catch (error) {
122
+ this.#fail(error);
123
+ return;
124
+ }
125
+ this.#seen = raw;
126
+ this.#unsaved = false;
127
+ window.dispatchEvent(new StorageEvent('storage', { key: this.#key, newValue: raw, storageArea: storage }));
128
+ }
129
+ }
@@ -0,0 +1,10 @@
1
+ export interface ScrollEdges {
2
+ blockStart: boolean;
3
+ blockEnd: boolean;
4
+ inlineStart: boolean;
5
+ inlineEnd: boolean;
6
+ }
7
+ export interface ScrollEdgesOptions {
8
+ offset?: number;
9
+ }
10
+ export declare function scrollEdges(callback: (edges: ScrollEdges) => void, options?: ScrollEdgesOptions): (node: HTMLElement) => () => void;
@@ -0,0 +1,88 @@
1
+ import { untrack } from 'svelte';
2
+ import { on } from 'svelte/events';
3
+ export function scrollEdges(callback, options = {}) {
4
+ return (node) => {
5
+ /** The extra pixel absorbs the sub-pixel gap a fractional box height leaves at maximum scrollTop. */
6
+ const reach = (options.offset ?? 0) + 1;
7
+ let last;
8
+ function compute() {
9
+ /** A hidden or detached scroller measures 0 by 0, which would read as every edge reached; its own ResizeObserver recomputes once it renders. */
10
+ if (node.clientWidth === 0 && node.clientHeight === 0)
11
+ return;
12
+ const style = getComputedStyle(node);
13
+ const flex = style.display.includes('flex');
14
+ const column = style.flexDirection.startsWith('column');
15
+ const wrapReverse = style.flexWrap === 'wrap-reverse';
16
+ /** A reversed flex axis moves the scroll origin to that axis's end edge. */
17
+ const blockFromEnd = flex && (column ? style.flexDirection === 'column-reverse' : wrapReverse);
18
+ const inlineFromEnd = flex && (column ? wrapReverse : style.flexDirection === 'row-reverse');
19
+ /** scrollTop and scrollLeft run negative from 0 when the origin sits on the bottom or right edge. */
20
+ const inlineOriginRight = (style.direction === 'rtl') !== inlineFromEnd;
21
+ const blockMax = node.scrollHeight - node.clientHeight;
22
+ const inlineMax = node.scrollWidth - node.clientWidth;
23
+ /** Elastic overscroll pushes the position past either end, which must not count as distance. */
24
+ const blockScrolled = clamp(blockFromEnd ? -node.scrollTop : node.scrollTop, blockMax);
25
+ const inlineScrolled = clamp(inlineOriginRight ? -node.scrollLeft : node.scrollLeft, inlineMax);
26
+ const fromBlockStart = blockFromEnd ? blockMax - blockScrolled : blockScrolled;
27
+ const fromInlineStart = inlineFromEnd ? inlineMax - inlineScrolled : inlineScrolled;
28
+ const edges = {
29
+ blockStart: fromBlockStart < reach,
30
+ blockEnd: blockMax - fromBlockStart < reach,
31
+ inlineStart: fromInlineStart < reach,
32
+ inlineEnd: inlineMax - fromInlineStart < reach
33
+ };
34
+ if (last?.blockStart === edges.blockStart &&
35
+ last.blockEnd === edges.blockEnd &&
36
+ last.inlineStart === edges.inlineStart &&
37
+ last.inlineEnd === edges.inlineEnd) {
38
+ return;
39
+ }
40
+ last = edges;
41
+ callback(edges);
42
+ }
43
+ const resize = new ResizeObserver(compute);
44
+ resize.observe(node);
45
+ for (const child of node.children)
46
+ resize.observe(child);
47
+ const mutation = new MutationObserver((records) => {
48
+ for (const record of records) {
49
+ if (record.target !== node)
50
+ continue;
51
+ /** replaceChildren can list a kept child as both removed and added, so it must be observed last. */
52
+ for (const removed of record.removedNodes) {
53
+ if (removed instanceof Element)
54
+ resize.unobserve(removed);
55
+ }
56
+ for (const added of record.addedNodes) {
57
+ if (added instanceof Element)
58
+ resize.observe(added);
59
+ }
60
+ }
61
+ compute();
62
+ });
63
+ /** Rewritten text, a margin, a position or a grandchild overflowing a fixed-size child changes the scroll extent without resizing any observed box. */
64
+ mutation.observe(node, {
65
+ childList: true,
66
+ characterData: true,
67
+ attributes: true,
68
+ subtree: true
69
+ });
70
+ const page = node === node.ownerDocument.scrollingElement;
71
+ /** The browser fires a viewport scroll at the document, never at the root element that scrolls. */
72
+ const off = on(page ? node.ownerDocument : node, 'scroll', compute);
73
+ const view = node.ownerDocument.defaultView;
74
+ /** The root element's box follows its content, not the viewport, so a window that grows or shrinks without moving the scroll position resizes nothing observed. */
75
+ const offResize = page && view ? on(view, 'resize', compute) : undefined;
76
+ /** The first compute runs inside the attachment's effect, so state the callback reads would otherwise re-create the attachment on every write. */
77
+ untrack(compute);
78
+ return () => {
79
+ off();
80
+ offResize?.();
81
+ resize.disconnect();
82
+ mutation.disconnect();
83
+ };
84
+ };
85
+ }
86
+ function clamp(value, max) {
87
+ return Math.min(Math.max(value, 0), max);
88
+ }
@@ -0,0 +1,6 @@
1
+ export type ThrottledFunction<Args extends unknown[], Return> = {
2
+ (...args: Args): Promise<Awaited<Return>>;
3
+ readonly pending: boolean;
4
+ cancel(): void;
5
+ };
6
+ export declare function throttle<Args extends unknown[], Return>(callback: (...args: Args) => Return, wait?: number): ThrottledFunction<Args, Return>;
@@ -0,0 +1,8 @@
1
+ import { DEFAULT_WAIT, Scheduler } from './debounce.svelte.js';
2
+ export function throttle(callback, wait = DEFAULT_WAIT) {
3
+ const scheduler = new Scheduler(callback, () => wait, { leading: true });
4
+ return Object.defineProperties((...args) => scheduler.call(...args), {
5
+ pending: { get: () => scheduler.pending },
6
+ cancel: { value: () => scheduler.cancel() }
7
+ });
8
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kenspeckle",
3
- "version": "0.1.4",
3
+ "version": "0.2.1",
4
4
  "description": "Svelte 5 utilities under one naming convention: attachments first-class, view transitions, and a state machine with context.",
5
5
  "license": "MIT",
6
6
  "author": "Brandon Pittman <brandon@brandonpittman.com>",
@@ -8,7 +8,7 @@
8
8
  "type": "git",
9
9
  "url": "git+https://github.com/brandonpittman/kenspeckle.git"
10
10
  },
11
- "homepage": "https://github.com/brandonpittman/kenspeckle#readme",
11
+ "homepage": "https://kenspeckle.brandonpittman.workers.dev/docs",
12
12
  "bugs": "https://github.com/brandonpittman/kenspeckle/issues",
13
13
  "scripts": {
14
14
  "dev": "vite dev",
@@ -96,6 +96,7 @@
96
96
  "utilities"
97
97
  ],
98
98
  "dependencies": {
99
+ "devalue": "^5.8.1 || ^6",
99
100
  "esm-env": "^1.2.2"
100
101
  }
101
102
  }