kenspeckle 0.0.1 → 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brandon Pittman
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,65 +1,199 @@
1
- # Svelte library
1
+ <picture>
2
+ <source media="(prefers-color-scheme: dark)" srcset="docs/assets/banner-dark.svg">
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
+ </picture>
2
5
 
3
- Everything you need to build a Svelte library, powered by [`sv`](https://npmjs.com/package/sv).
6
+ Svelte utilities you recognize at sight. One naming convention, no `use` prefix, attachments first-class.
4
7
 
5
- Read more about creating a library [in the docs](https://svelte.dev/docs/kit/packaging).
8
+ ## The sentence test
6
9
 
7
- ## Creating a project
10
+ Every export earns its shape by being said aloud: _"I want a new \___."_
8
11
 
9
- If you're seeing this, you've probably already done this step. Congrats!
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()`
10
15
 
11
- ```sh
12
- # create a new project in the current directory
13
- npx sv create
16
+ Two rules fall out:
17
+
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()`.
20
+
21
+ That's why the name. Kenspeckle things are known at sight; so is every export here. The word is the spec.
22
+
23
+ ## Attachments, curried
14
24
 
15
- # create a new project in my-app
16
- npx sv create my-app
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.
26
+
27
+ ```svelte
28
+ <script>
29
+ // same import, imperative
30
+ const cleanup = clickOutside(node, () => close());
31
+ </script>
32
+
33
+ <div {@attach clickOutside(() => close())}>…</div>
17
34
  ```
18
35
 
19
- To recreate this project with the same configuration:
36
+ Value-producing attachments are readable boxes — the factory's return _is_ the attachment, carrying reactive getters:
20
37
 
21
- ```sh
22
- # recreate this project
23
- npx sv@0.16.3 create --template library --types ts --add prettier eslint vitest="usages:unit,component" playwright sveltekit-adapter="adapter:cloudflare+cfTarget:workers" mdsvex mcp="ide:claude-code+setup:remote" experimental="versions:none+features:async,remoteFunctions,explicitEnvironmentVariables,handleRenderingErrors" --install npm kenspeckle
38
+ ```svelte
39
+ <script>
40
+ import { elementSize } from 'kenspeckle';
41
+
42
+ const size = elementSize(); // no target yet
43
+ </script>
44
+
45
+ <div {@attach size}>…</div><p>{size.width} × {size.height}</p>
24
46
  ```
25
47
 
26
- ## Developing
48
+ The same export works imperatively with a getter: `elementSize(() => node)`. Argument presence picks the mode.
27
49
 
28
- Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
50
+ ## Install
29
51
 
30
52
  ```sh
31
- npm run dev
53
+ npm install kenspeckle
54
+ ```
32
55
 
33
- # or start the server and open the app in a new browser tab
34
- npm run dev -- --open
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.
57
+
58
+ ## What's inside
59
+
60
+ | export | form | lineage |
61
+ | ------------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------- |
62
+ | `FiniteStateMachine` | class, with typed reactive `context` | runed `FiniteStateMachine` |
63
+ | `StateHistory` | class | runed `StateHistory` |
64
+ | `mounted()` | value factory | runed `IsMounted` |
65
+ | `idle()` | value factory | runed `IsIdle` |
66
+ | `documentVisible()` | value factory | runed `IsDocumentVisible` |
67
+ | `focusWithin()` | attachment, readable box | runed `IsFocusWithin` |
68
+ | `inViewport()` | attachment, readable box | runed `IsInViewport` |
69
+ | `elementSize()` | attachment, readable box | runed `ElementSize` |
70
+ | `elementRect()` | attachment, readable box | runed `ElementRect` |
71
+ | `scrollState()` | attachment, readable box; window form | runed `ScrollState` |
72
+ | `autosize()` | attachment | runed `TextareaAutosize` |
73
+ | `clickOutside()` | attachment + helper | runed `onClickOutside` · svelte-put `clickoutside` |
74
+ | `intersected()` | attachment + helper | runed `useIntersectionObserver` · svelte-put `intersect` |
75
+ | `resized()` | attachment + helper | runed `useResizeObserver` · svelte-put `resize` |
76
+ | `mutated()` | attachment + helper | runed `useMutationObserver` |
77
+ | `copy()` | attachment + helper | svelte-put `copy` |
78
+ | `shortcut()` | attachment + helper | svelte-put `shortcut` |
79
+ | `lockScroll` | attachment ≡ helper, stacked lock counting | new |
80
+ | `dragScroll()` | attachment | svelte-put `dragscroll` |
81
+ | `viewTransition()` | function; navigation form under `/kit` | new |
82
+ | `viewTransitionName()` | attachment + helper | new |
83
+ | `retreat()` | function, returns a disposer | new |
84
+ | `debounced()` / `throttled()` | value factory | runed `Debounced` / `Throttled` |
85
+ | `debounce()` / `throttle()` | function wrapper | runed `useDebounce` / `useThrottle` |
86
+ | `previous()` | value factory | runed `Previous` |
87
+ | `activeElement()` | value factory | runed `ActiveElement` |
88
+ | `pressedKeys()` | value factory | runed `PressedKeys` |
89
+ | `persisted()` | value factory | runed `PersistedState` |
90
+ | `animationFrames()` | value factory | runed `AnimationFrames` |
91
+ | `geolocation()` | value factory | runed `useGeolocation` |
92
+ | `listen()` | wiring | runed `useEventListener` |
93
+ | `interval()` | wiring | runed `useInterval` |
94
+ | `searchParams()` + helpers | value factory | runed `useSearchParams` |
95
+ | `watch` / `watchOnce` / `extract` / `onCleanup` / `boolAttr` | unchanged | runed |
96
+
97
+ Dropped, deliberately: runed's `Context` — Svelte ≥ 5.40's `createContext` returns a typed `[get, set]` pair and covers it. runed's `resource` waits in the backlog — SvelteKit remote functions cover the server-data case; it returns only if a real client-only async need shows up.
98
+
99
+ `FiniteStateMachine` gains a typed, `$state`-backed `context` object visible to lifecycle hooks and guards — the sidecar-data mechanism every real FSM grows, built in.
100
+
101
+ View transitions ship in two tiers. `viewTransition(update)` animates a state change within a route; the SvelteKit navigation form, plus `viewTransitionName` and `retreat`, come from the `kenspeckle/kit` subpath — separate so `$app/navigation` never enters the main entry. Both tiers fall back cleanly: with no `document.startViewTransition`, or under `prefers-reduced-motion`, the update still runs, untransitioned.
102
+
103
+ ## View transitions
104
+
105
+ Two entry points. `kenspeckle` exports the same-route form; `kenspeckle/kit` adds the SvelteKit
106
+ navigation form, so `$app/navigation` never enters the main entry. The `/kit` subpath expects
107
+ `@sveltejs/kit` — an optional peer.
108
+
109
+ ### The contract
110
+
111
+ One attribute on `<html>`, absent when idle:
112
+
113
+ | `data-view-transition` | written by |
114
+ | ---------------------- | ----------------------------------------------------- |
115
+ | `forward` | a navigation |
116
+ | `retreat` | a navigation the predicate claims, or a Back popstate |
117
+ | `step-forward` | `viewTransition(update)` |
118
+ | `step-retreat` | `viewTransition(update, { retreat: true })` |
119
+
120
+ Every animation is yours, keyed off that value. An unprefixed `::view-transition-old|new(name)` rule
121
+ matches both kinds, so scope it when a step and a navigation must differ.
122
+
123
+ ```css
124
+ html[data-view-transition='forward']::view-transition-old(root) {
125
+ animation: slide-out-left 300ms;
126
+ }
35
127
  ```
36
128
 
37
- Everything inside `src/lib` is part of your library, everything inside `src/routes` can be used as a showcase or preview app.
129
+ ### Navigations
38
130
 
39
- ## Building
131
+ Register once, from the root layout:
40
132
 
41
- To build your library:
133
+ ```svelte
134
+ <script>
135
+ import { viewTransition } from 'kenspeckle/kit';
42
136
 
43
- ```sh
44
- npm pack
137
+ viewTransition();
138
+ </script>
45
139
  ```
46
140
 
47
- To create a production version of your showcase app:
141
+ Direction is a predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while
142
+ another is in flight, so a flag set at click time can be stranded and reverse a later navigation.
143
+ `retreat()` returns an identity-guarded disposer, shaped for `$effect` cleanup.
48
144
 
49
- ```sh
50
- npm run build
145
+ ```svelte
146
+ <script>
147
+ import { retreat } from 'kenspeckle/kit';
148
+
149
+ $effect(() => retreat((navigation) => navigation.to?.route.id === '/'));
150
+ </script>
51
151
  ```
52
152
 
53
- You can preview the production build with `npm run preview`.
153
+ A Back popstate is a retreat without asking. A Forward popstate is not.
54
154
 
55
- > To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
155
+ ### Steps within a route
56
156
 
57
- ## Publishing
157
+ `onNavigate` never fires for a wizard advancing its own state, so drive the primitive directly:
58
158
 
59
- Go into the `package.json` and give your package the desired name through the `"name"` option. Also consider adding a `"license"` field and point it to a `LICENSE` file which you can create from a template (one popular option is the [MIT license](https://opensource.org/license/mit/)).
159
+ ```svelte
160
+ <script>
161
+ import { viewTransition } from 'kenspeckle';
60
162
 
61
- To publish your library to [npm](https://www.npmjs.com):
163
+ let step = $state(0);
164
+ const go = (delta) => viewTransition(() => (step += delta), { retreat: delta < 0 });
165
+ </script>
166
+ ```
62
167
 
63
- ```sh
64
- npm publish
168
+ The update callback suspends rendering until it settles, which past half a second reads as a hang —
169
+ `deadline` (600ms) skips the transition rather than let it. With no `document.startViewTransition`,
170
+ or under `prefers-reduced-motion`, the update still runs, untransitioned, and no attribute is
171
+ written.
172
+
173
+ ### Named elements
174
+
175
+ `viewTransitionName` claims `view-transition-name` for the duration of a transition and releases it
176
+ after. Two elements holding one name silently abort the whole transition, which is why claims are
177
+ scoped rather than left in static CSS.
178
+
179
+ ```svelte
180
+ <button {@attach viewTransitionName('back-button')}>Back</button>
181
+ ```
182
+
183
+ `when` claims only for navigations it accepts; `onArrival` claims inside the update callback instead,
184
+ for an element that mounts during the transition. The imperative form takes the element first and
185
+ returns a disposer:
186
+
187
+ ```js
188
+ const dispose = viewTransitionName(element, 'back-button');
65
189
  ```
190
+
191
+ **Claims are navigation-only.** The registration form drives the capture and arrival phases;
192
+ `viewTransition(update)` on a same-route step names nothing. Morph a stepping element with static
193
+ `view-transition-name` in CSS instead.
194
+
195
+ ## Provenance
196
+
197
+ 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.
198
+
199
+ Portions adapted from runed (MIT) and svelte-put (MIT). kenspeckle is MIT.
package/dist/copy.d.ts ADDED
@@ -0,0 +1,11 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ export type CopyText = string | ((node: HTMLElement) => string | Promise<string>);
3
+ export interface CopyOptions<K extends keyof HTMLElementEventMap = 'click'> {
4
+ text?: CopyText;
5
+ event?: K | K[];
6
+ enabled?: boolean;
7
+ onCopy?: (text: string) => void;
8
+ }
9
+ 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;
package/dist/copy.js ADDED
@@ -0,0 +1,40 @@
1
+ import { on } from 'svelte/events';
2
+ export async function copyText(text) {
3
+ try {
4
+ await navigator.clipboard.writeText(text);
5
+ }
6
+ catch {
7
+ const el = document.createElement('textarea');
8
+ el.value = text;
9
+ el.setAttribute('readonly', '');
10
+ el.style.position = 'absolute';
11
+ el.style.left = '-9999px';
12
+ document.body.appendChild(el);
13
+ el.select();
14
+ document.execCommand('copy');
15
+ document.body.removeChild(el);
16
+ }
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();
39
+ };
40
+ }
@@ -0,0 +1,53 @@
1
+ export type EventMap = Record<string, unknown[]>;
2
+ type EventArgs<EventsT extends EventMap> = {
3
+ [K in keyof EventsT]: {
4
+ event: K;
5
+ args: EventsT[K];
6
+ };
7
+ }[keyof EventsT];
8
+ export type ActionMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT, K extends keyof EventsT> = {
9
+ from: CurrentT;
10
+ event: K;
11
+ args: EventsT[K];
12
+ context: ContextT;
13
+ };
14
+ export type EnterMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
15
+ to: CurrentT;
16
+ context: ContextT;
17
+ } & (({
18
+ from: StatesT;
19
+ } & EventArgs<EventsT>) | {
20
+ from: null;
21
+ event: null;
22
+ args: [];
23
+ });
24
+ export type ExitMeta<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
25
+ from: CurrentT;
26
+ to: StatesT;
27
+ context: ContextT;
28
+ } & EventArgs<EventsT>;
29
+ export type Action<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT, K extends keyof EventsT> = StatesT | ((meta: ActionMeta<StatesT, EventsT, ContextT, CurrentT, K>) => StatesT | void);
30
+ export type StateHandler<StatesT extends string, EventsT extends EventMap, ContextT, CurrentT extends StatesT> = {
31
+ [K in keyof EventsT]?: Action<StatesT, EventsT, ContextT, CurrentT, K>;
32
+ } & {
33
+ _enter?: (meta: EnterMeta<StatesT, EventsT, ContextT, CurrentT>) => void;
34
+ _exit?: (meta: ExitMeta<StatesT, EventsT, ContextT, CurrentT>) => void;
35
+ };
36
+ export type Transition<StatesT extends string, EventsT extends EventMap, ContextT = undefined> = {
37
+ [S in StatesT]: StateHandler<StatesT, EventsT, ContextT, S>;
38
+ } & {
39
+ '*'?: StateHandler<StatesT, EventsT, ContextT, StatesT>;
40
+ };
41
+ export declare class FiniteStateMachine<StatesT extends string, EventsT extends EventMap, ContextT = undefined> {
42
+ #private;
43
+ readonly states: Transition<StatesT, EventsT, ContextT>;
44
+ constructor(initial: StatesT, states: Transition<StatesT, EventsT, ContextT>, ...options: undefined extends ContextT ? [] : [{
45
+ context: ContextT;
46
+ }]);
47
+ send: <K extends keyof EventsT>(event: K, ...args: EventsT[K]) => StatesT;
48
+ debounce: <K extends keyof EventsT>(event: K, wait?: number, ...args: EventsT[K]) => Promise<StatesT>;
49
+ get current(): StatesT;
50
+ get context(): ContextT;
51
+ set context(next: ContextT);
52
+ }
53
+ export {};
@@ -0,0 +1,78 @@
1
+ import { DEV } from 'esm-env';
2
+ export class FiniteStateMachine {
3
+ #current = $state();
4
+ #context = $state();
5
+ states;
6
+ constructor(initial, states, ...options) {
7
+ this.#current = initial;
8
+ this.states = states;
9
+ this.#context = options[0]?.context;
10
+ this.#lifecycle('_enter', initial, {
11
+ from: null,
12
+ to: initial,
13
+ event: null,
14
+ args: [],
15
+ context: this.#context
16
+ });
17
+ }
18
+ send = (event, ...args) => {
19
+ const action = this.states[this.#current]?.[event] ?? this.states['*']?.[event];
20
+ if (action === undefined) {
21
+ if (DEV) {
22
+ console.warn(`kenspeckle: unhandled event '${String(event)}' in state '${this.#current}'`);
23
+ }
24
+ return this.#current;
25
+ }
26
+ const target = typeof action === 'function'
27
+ ? action({
28
+ from: this.#current,
29
+ event,
30
+ args,
31
+ context: this.#context
32
+ })
33
+ : action;
34
+ if (target != null)
35
+ this.#transition(target, event, args);
36
+ return this.#current;
37
+ };
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
+ };
55
+ });
56
+ };
57
+ #transition(to, event, args) {
58
+ const from = this.#current;
59
+ this.#lifecycle('_exit', from, { from, to, event, args, context: this.#context });
60
+ if (this.#current !== from)
61
+ return; // _exit sent elsewhere; that transition supersedes
62
+ this.#current = to;
63
+ this.#lifecycle('_enter', to, { from, to, event, args, context: this.#context });
64
+ }
65
+ #lifecycle(kind, state, meta) {
66
+ const fn = this.states[state]?.[kind] ?? this.states['*']?.[kind];
67
+ fn?.(meta);
68
+ }
69
+ get current() {
70
+ return this.#current;
71
+ }
72
+ get context() {
73
+ return this.#context;
74
+ }
75
+ set context(next) {
76
+ this.#context = next;
77
+ }
78
+ }
package/dist/index.d.ts CHANGED
@@ -1 +1,6 @@
1
- export {};
1
+ export { copy, copyText } from './copy.js';
2
+ export type { CopyOptions, CopyText } from './copy.js';
3
+ export { FiniteStateMachine } from './finite-state-machine.svelte.js';
4
+ export type { Action, ActionMeta, EnterMeta, EventMap, ExitMeta, StateHandler, Transition } from './finite-state-machine.svelte.js';
5
+ export { viewTransition } from './view-transition.js';
6
+ export type { StartViewTransition, ViewTransitionOptions, ViewTransitionUpdate } from './view-transition.js';
package/dist/index.js CHANGED
@@ -1,2 +1,3 @@
1
- "use strict";
2
- // Reexport your entry components here
1
+ export { copy, copyText } from './copy.js';
2
+ export { FiniteStateMachine } from './finite-state-machine.svelte.js';
3
+ export { viewTransition } from './view-transition.js';
@@ -0,0 +1,5 @@
1
+ export { retreat, viewTransition } from './view-transition.js';
2
+ export type { NavigationTransitionOptions } from './view-transition.js';
3
+ export { viewTransitionName } from './view-transition-name.js';
4
+ export type { ViewTransitionNameOptions } from './view-transition-name.js';
5
+ export type { Navigation } from './types.js';
@@ -0,0 +1,2 @@
1
+ export { retreat, viewTransition } from './view-transition.js';
2
+ export { viewTransitionName } from './view-transition-name.js';
@@ -0,0 +1,12 @@
1
+ /** Structural, not imported from Kit — any router handing over a completion promise fits. */
2
+ export interface Navigation {
3
+ complete: Promise<void>;
4
+ type?: string;
5
+ delta?: number | null;
6
+ from?: {
7
+ url: URL;
8
+ } | null;
9
+ to?: {
10
+ url: URL;
11
+ } | null;
12
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,16 @@
1
+ import type { Attachment } from 'svelte/attachments';
2
+ import type { Navigation } from './types.js';
3
+ export interface ViewTransitionNameOptions {
4
+ /** Only claim the name for navigations this returns true for. */
5
+ when?: (navigation: Navigation) => boolean;
6
+ /** Claim inside the update callback instead, for an element that mounts during the transition. */
7
+ onArrival?: boolean;
8
+ }
9
+ type Phase = 'capture' | 'arrival';
10
+ /** @internal — driven by the navigation registration. */
11
+ export declare function beginPhase(navigation: Navigation, phase: Phase): void;
12
+ /** @internal */
13
+ 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;
16
+ export {};
@@ -0,0 +1,48 @@
1
+ const claims = new Set();
2
+ let current = null;
3
+ function wanted(claim, phase, navigation) {
4
+ if ((claim.options.onArrival ? 'arrival' : 'capture') !== phase)
5
+ return false;
6
+ return claim.options.when?.(navigation) ?? true;
7
+ }
8
+ function apply(claim) {
9
+ claim.element.style.viewTransitionName = claim.name;
10
+ claim.named = true;
11
+ }
12
+ function release(claim) {
13
+ if (!claim.named)
14
+ return;
15
+ claim.element.style.viewTransitionName = '';
16
+ claim.named = false;
17
+ }
18
+ /** @internal — driven by the navigation registration. */
19
+ export function beginPhase(navigation, phase) {
20
+ current = { navigation, phase };
21
+ for (const claim of claims)
22
+ if (wanted(claim, phase, navigation))
23
+ apply(claim);
24
+ }
25
+ /** @internal */
26
+ export function endTransition() {
27
+ current = null;
28
+ for (const claim of claims)
29
+ release(claim);
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);
40
+ };
41
+ }
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,18 @@
1
+ import { type ViewTransitionOptions, type ViewTransitionUpdate } from '../view-transition.js';
2
+ import type { Navigation } from './types.js';
3
+ export interface NavigationTransitionOptions extends Omit<ViewTransitionOptions, 'retreat' | 'onStart'> {
4
+ onStart?: (navigation: Navigation) => void;
5
+ }
6
+ /**
7
+ * A predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while another is in
8
+ * flight, so a flag set at click time can be stranded and reverse a later navigation.
9
+ *
10
+ * Returns a disposer shaped for `$effect` cleanup.
11
+ */
12
+ export declare function retreat(next: (navigation: Navigation) => boolean): () => void;
13
+ /** @internal */
14
+ export declare function isRetreat(navigation: Navigation): boolean;
15
+ /** @internal — the handler `viewTransition()` registers, exported so it is testable without a component. */
16
+ export declare function navigationTransition({ onStart, onSettle, ...rest }?: NavigationTransitionOptions): (navigation: Navigation) => Promise<void>;
17
+ export declare function viewTransition(options?: NavigationTransitionOptions): void;
18
+ export declare function viewTransition(update: ViewTransitionUpdate, options?: ViewTransitionOptions): ViewTransition | undefined;
@@ -0,0 +1,57 @@
1
+ import { onNavigate } from '$app/navigation';
2
+ import { runViewTransition, viewTransition as directTransition } from '../view-transition.js';
3
+ import { beginPhase, endTransition } from './view-transition-name.js';
4
+ const forward = () => false;
5
+ let predicate = forward;
6
+ /**
7
+ * A predicate, never a flag: Kit skips `beforeNavigate` for a navigation begun while another is in
8
+ * flight, so a flag set at click time can be stranded and reverse a later navigation.
9
+ *
10
+ * Returns a disposer shaped for `$effect` cleanup.
11
+ */
12
+ export function retreat(next) {
13
+ predicate = next;
14
+ return () => {
15
+ // Identity-guarded: a stale disposer must not unregister whoever replaced it.
16
+ if (predicate === next)
17
+ predicate = forward;
18
+ };
19
+ }
20
+ /** @internal */
21
+ export function isRetreat(navigation) {
22
+ // Kit sets delta only on a popstate: negative is Back, positive Forward. Absent, assume Back.
23
+ const back = navigation.type === 'popstate' && (navigation.delta ?? -1) < 0;
24
+ return back || predicate(navigation);
25
+ }
26
+ /** @internal — the handler `viewTransition()` registers, exported so it is testable without a component. */
27
+ export function navigationTransition({ onStart, onSettle, ...rest } = {}) {
28
+ return (navigation) => new Promise((resolve) => {
29
+ let transitioning = false;
30
+ runViewTransition(async () => {
31
+ // resolve() BEFORE awaiting breaks a mutual wait: Kit holds the navigation until this
32
+ // promise settles, the browser holds the snapshot until the callback settles.
33
+ resolve();
34
+ await navigation.complete;
35
+ if (transitioning)
36
+ beginPhase(navigation, 'arrival');
37
+ }, {
38
+ ...rest,
39
+ state: isRetreat(navigation) ? 'retreat' : 'forward',
40
+ onStart: () => {
41
+ transitioning = true;
42
+ beginPhase(navigation, 'capture');
43
+ onStart?.(navigation);
44
+ },
45
+ onSettle: () => {
46
+ transitioning = false;
47
+ endTransition();
48
+ onSettle?.();
49
+ }
50
+ });
51
+ });
52
+ }
53
+ export function viewTransition(first, second) {
54
+ if (typeof first === 'function')
55
+ return directTransition(first, second);
56
+ onNavigate(navigationTransition(first));
57
+ }
@@ -0,0 +1,22 @@
1
+ export type StartViewTransition = (update: () => void | Promise<void>) => ViewTransition;
2
+ /** A union of signatures, not `() => void | Promise<void>`: TS's void-return relaxation applies per
3
+ * member, so only this shape accepts a concise arrow like `() => (count += 1)`. */
4
+ export type ViewTransitionUpdate = (() => void) | (() => Promise<void>);
5
+ export interface ViewTransitionOptions {
6
+ /** Reverse direction — writes `step-retreat` instead of `step-forward`. */
7
+ retreat?: boolean;
8
+ /** Past this a crossfade reads as a hang: the update callback suspends rendering until it settles. */
9
+ deadline?: number;
10
+ onStart?: () => void;
11
+ onSettle?: () => void;
12
+ reducedMotion?: () => boolean;
13
+ /** Injected in tests; keeps the `bind` internal and init SSR-safe. */
14
+ start?: StartViewTransition;
15
+ }
16
+ /** @internal — `/kit` supplies its own attribute vocabulary. Not re-exported from the barrel. */
17
+ export interface RunOptions extends ViewTransitionOptions {
18
+ state?: string;
19
+ }
20
+ export declare function viewTransition(update: ViewTransitionUpdate, options?: ViewTransitionOptions): ViewTransition | undefined;
21
+ /** @internal */
22
+ export declare function runViewTransition(update: ViewTransitionUpdate, { deadline, onStart, onSettle, reducedMotion, start, state }?: RunOptions): ViewTransition | undefined;
@@ -0,0 +1,58 @@
1
+ import { settled } from 'svelte';
2
+ import { prefersReducedMotion } from 'svelte/motion';
3
+ const ignore = () => { };
4
+ const root = () => document.documentElement;
5
+ // A consumer throw must not strand the transition, so it goes to the page's error handling instead.
6
+ const guard = (callback) => {
7
+ try {
8
+ callback?.();
9
+ }
10
+ catch (error) {
11
+ reportError(error);
12
+ }
13
+ };
14
+ // One transition per document: starting a second skips the first, whose `finished` then settles.
15
+ let active;
16
+ export function viewTransition(update, options = {}) {
17
+ return runViewTransition(update, {
18
+ ...options,
19
+ state: options.retreat ? 'step-retreat' : 'step-forward'
20
+ });
21
+ }
22
+ /** @internal */
23
+ export function runViewTransition(update, { deadline = 600, onStart, onSettle, reducedMotion = () => prefersReducedMotion.current, start, state = 'step-forward' } = {}) {
24
+ // Resolved per call, never at module scope: SSR must not touch document.
25
+ const startTransition = start ??
26
+ (typeof document === 'undefined' ? undefined : document.startViewTransition?.bind(document));
27
+ // No transition at all, not merely no animation — the update callback suspends rendering.
28
+ if (!startTransition || reducedMotion()) {
29
+ // A sync throw still propagates; an async rejection is swallowed as the transition path swallows `finished`.
30
+ void Promise.resolve(update()).catch(ignore);
31
+ return;
32
+ }
33
+ root().dataset.viewTransition = state;
34
+ guard(onStart);
35
+ // `settled()`, not `tick()`: the browser snapshots the moment this callback resolves, and an async
36
+ // component's commit is batched — `tick()` only flushes synchronous updates and passes by luck.
37
+ const transition = startTransition(async () => {
38
+ await update();
39
+ await settled();
40
+ });
41
+ // Synchronously, before any microtask: a superseded predecessor settles one tick later.
42
+ active = transition;
43
+ const timer = setTimeout(() => transition.skipTransition(), deadline);
44
+ // Disarmed on the update callback, not `finished`: armed through the animation it snaps a running morph.
45
+ void transition.updateCallbackDone.catch(ignore).finally(() => clearTimeout(timer));
46
+ // Swallowed so cleanup still runs when the update callback threw; a skip fulfils `finished`.
47
+ void transition.finished.catch(ignore).finally(() => {
48
+ // Superseded: the successor owns the state and the claimed names, so its cleanup is not ours to run.
49
+ if (active !== transition)
50
+ return;
51
+ active = undefined;
52
+ delete root().dataset.viewTransition;
53
+ guard(onSettle);
54
+ });
55
+ // A skip rejects `ready`; unhandled, Chrome logs AbortError on the slow navigations the deadline rescues.
56
+ void transition.ready.catch(ignore);
57
+ return transition;
58
+ }
package/package.json CHANGED
@@ -1,6 +1,15 @@
1
1
  {
2
2
  "name": "kenspeckle",
3
- "version": "0.0.1",
3
+ "version": "0.1.1",
4
+ "description": "Svelte 5 utilities under one naming convention: attachments first-class, view transitions, and a state machine with context.",
5
+ "license": "MIT",
6
+ "author": "Brandon Pittman <brandon@brandonpittman.com>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/brandonpittman/kenspeckle.git"
10
+ },
11
+ "homepage": "https://github.com/brandonpittman/kenspeckle#readme",
12
+ "bugs": "https://github.com/brandonpittman/kenspeckle/issues",
4
13
  "scripts": {
5
14
  "dev": "vite dev",
6
15
  "build": "wrangler types --check && vite build && npm run prepack",
@@ -19,7 +28,8 @@
19
28
  "files": [
20
29
  "dist",
21
30
  "!dist/**/*.test.*",
22
- "!dist/**/*.spec.*"
31
+ "!dist/**/*.spec.*",
32
+ "!dist/vitest-examples"
23
33
  ],
24
34
  "sideEffects": [
25
35
  "**/*.css"
@@ -30,11 +40,23 @@
30
40
  "exports": {
31
41
  ".": {
32
42
  "types": "./dist/index.d.ts",
33
- "svelte": "./dist/index.js"
43
+ "svelte": "./dist/index.js",
44
+ "default": "./dist/index.js"
45
+ },
46
+ "./kit": {
47
+ "types": "./dist/kit/index.d.ts",
48
+ "svelte": "./dist/kit/index.js",
49
+ "default": "./dist/kit/index.js"
34
50
  }
35
51
  },
36
52
  "peerDependencies": {
37
- "svelte": "^5.0.0"
53
+ "svelte": "^5.40.0",
54
+ "@sveltejs/kit": "^2.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "@sveltejs/kit": {
58
+ "optional": true
59
+ }
38
60
  },
39
61
  "devDependencies": {
40
62
  "@eslint/js": "^10.0.1",
@@ -64,6 +86,16 @@
64
86
  "wrangler": "^4.97.0"
65
87
  },
66
88
  "keywords": [
67
- "svelte"
68
- ]
89
+ "svelte",
90
+ "svelte5",
91
+ "sveltekit",
92
+ "runes",
93
+ "attachments",
94
+ "view-transitions",
95
+ "state-machine",
96
+ "utilities"
97
+ ],
98
+ "dependencies": {
99
+ "esm-env": "^1.2.2"
100
+ }
69
101
  }
@@ -1,8 +0,0 @@
1
- <script>
2
- import { greet } from './greet';
3
-
4
- let { host = 'SvelteKit', guest = 'Vitest' } = $props();
5
- </script>
6
-
7
- <h1>{greet(host)}</h1>
8
- <p>{greet(guest)}</p>
@@ -1,13 +0,0 @@
1
- export default Welcome;
2
- type Welcome = {
3
- $on?(type: string, callback: (e: any) => void): () => void;
4
- $set?(props: Partial<$$ComponentProps>): void;
5
- };
6
- declare const Welcome: import("svelte").Component<{
7
- host?: string;
8
- guest?: string;
9
- }, {}, "">;
10
- type $$ComponentProps = {
11
- host?: string;
12
- guest?: string;
13
- };
@@ -1 +0,0 @@
1
- export declare function greet(name: string): string;
@@ -1,3 +0,0 @@
1
- export function greet(name) {
2
- return 'Hello, ' + name + '!';
3
- }