diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 9375dc7..a019204 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -29,7 +29,17 @@ updates: labels: [dependencies] - package-ecosystem: npm - directories: [/examples/vite, /examples/next] + directories: + [ + /examples/vite, + /examples/next, + /examples/vue, + /examples/nuxt, + /examples/sveltekit, + /examples/solid, + /examples/angular, + /examples/astro, + ] schedule: interval: monthly cooldown: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e4ee89e..a1b87a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,7 @@ jobs: # every source file opens with the licence and copyright lines LICENSE gives (SPDX); CONTRIBUTING.md says how - if: matrix.node == 'latest' run: | - missing="$(git ls-files '*.ts' '*.tsx' '*.mjs' '*.js' '*.css' '*.html' | while read -r file; do + missing="$(git ls-files '*.ts' '*.tsx' '*.mjs' '*.js' '*.css' '*.html' '*.vue' '*.svelte' '*.astro' | while read -r file; do head -n 4 "$file" | grep -q 'SPDX-License-Identifier: MIT' && head -n 4 "$file" | grep -q 'Copyright (c) 2026 Arcwright Studio' || echo "$file" done)" if [ -n "$missing" ]; then @@ -55,6 +55,44 @@ jobs: run: npm run coverage - run: npm run check:package + # every example, installed from the package as it would be published, built with its framework's own tool and driven in a browser + examples: + name: examples + runs-on: ubuntu-26.04 + timeout-minutes: 30 + steps: + - run: git config --global init.defaultBranch main + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: lts/* + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: npm run check:examples + + # each framework entry, tested against the oldest version of its framework that package.json supports + oldest: + name: oldest frameworks + runs-on: ubuntu-26.04 + timeout-minutes: 20 + steps: + - run: git config --global init.defaultBranch main + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: lts/* + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run build + - run: npm run check:oldest + # every commit of a pull request certifies the Developer Certificate of Origin (CONTRIBUTING.md) with a sign-off naming its author; # Dependabot's updates are made by a tool, not a person sign-off: diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..3f7401a --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,48 @@ +name: CodeQL + +on: + push: + branches: [main] + pull_request: + # weekly too, so a query added to CodeQL meets what is already on main + schedule: + - cron: '23 5 * * 1' + +permissions: + contents: read + +# a newer push to a pull request replaces its run under way; every push to main is analysed +concurrency: + group: codeql-${{ github.event.pull_request.number || github.sha }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-26.04 + timeout-minutes: 20 + permissions: + contents: read + # to upload what the analysis found + security-events: write + strategy: + fail-fast: false + matrix: + # the workflows, and the source with its tests, scripts and examples + language: [actions, javascript-typescript] + steps: + # the name for the branch checkout starts its repository with (unset, git prints a hint about its default) + - run: git config --global init.defaultBranch main + # each action is pinned to the commit of its release, which Dependabot keeps current + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + languages: ${{ matrix.language }} + # nothing is compiled: CodeQL reads the source as it is + build-mode: none + queries: security-extended + - uses: github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3 + with: + category: /language:${{ matrix.language }} diff --git a/CHANGELOG.md b/CHANGELOG.md index ec4335e..bfeffb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ Svifa follows [semantic versioning](https://semver.org). +## Unreleased + +### Added + +- `snap`: a scroller with `scroll-snap-type` glides to the snap points its CSS sets. The wheel moves as far as it would without + snapping and the glide rests on the nearest snap point; with `mandatory`, one notch or one trackpad swipe moves one point. + `scrollTo` and same-page links rest on the snap point nearest their target. `scroll-padding`, `scroll-margin`, + `scroll-snap-stop: always` and areas longer than the visible area are honoured, and every rest is where the browser itself would + let the scroller rest, so its own snapping never moves it afterwards. `configure` changes it while Svifa runs. +- Components for every major framework, beside the React one: Vue and Nuxt (`@arcwrightstudio/svifa/vue`: `SmoothScroll`, + `useSvifa`), Svelte and SvelteKit (`@arcwrightstudio/svifa/svelte`: `smoothScroll`, `useSvifa`), Solid + (`@arcwrightstudio/svifa/solid`: `SmoothScroll`, `useSvifa`), Angular (`@arcwrightstudio/svifa/angular`: `provideSmoothScroll`, + `injectSvifa`, `createSvifa`) and Astro (`@arcwrightstudio/svifa/astro`: `SmoothScroll`, view transitions included). Each renders + on a server and is tested on the oldest version of its framework it supports: Vue 3.3, Svelte 4, Solid 1.6 and Angular 19. +- Examples for Vue, Nuxt, SvelteKit, Solid, Angular and Astro (Nuxt, SvelteKit and Angular rendered on the server). Every example + is built with its framework's own tool from the package as it would be published, served as its framework serves it, and driven + in a browser, on each change. + +### Fixed + +- Installing Svifa in a Yarn Plug'n'Play project no longer turns on Yarn's experimental ES module loader, nor its warning that the + loader is experimental. The package is `"type": "commonjs"`; `dist/package.json` marks the built ES modules for Node, every file + keeps its name, and each framework entry imports the core from the file beside it. +- React: a prop taken off `SmoothScroll` (left out of its props) now goes back to its default; it kept the value it had. +- The README gave the size as under 8 kB minified and gzipped, which 1.1.0 already passed (8.6 kB). It now gives the size as + measured, and a test keeps it true. + ## 1.1.0 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 36e2582..d981926 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,23 +18,27 @@ Development needs Node 22.18, 24.11, 26 or later. npm ci # the tools (and npx playwright install, once, for the browsers) npm run lint # oxlint for defects, and Prettier for the code style (npm run format applies it) npm run typecheck # the library, then the tests and the config files -npm run build # dist/: the ES module, the CommonJS file, the script builds, the React entry, the types, the SBOM +npm run build # dist/: the ES module, the CommonJS file, the script builds, the framework entries, the types, the SBOM npm test # the suite in Chromium, Firefox and WebKit, about a minute npm run coverage # how much of src/ the tests run, file by file; fails below the floors in scripts/coverage.mjs npm run check:package # the exports and types as every module resolution reads them, and the CDN tags' pinned hashes npm run check:installs # installs the packed package with npm, pnpm, Yarn 1 and 4 (Plug'n'Play), Bun and Deno; fails on warnings +npm run check:examples # builds every example from the packed package with its framework's own tool and drives it in a browser +npm run check:oldest # each framework entry's tests on the oldest version of its framework that package.json supports ``` -`SVIFA_REACT_DIR= npx playwright test tests/react.spec.ts` runs the React entry's tests -against another React version (React 18 is supported as well as 19). +`SVIFA__DIR= npx playwright test tests/.spec.ts` runs a framework entry's tests against another +version of its framework (`SVIFA_REACT_DIR`, `SVIFA_VUE_DIR`, `SVIFA_SVELTE_DIR`, `SVIFA_SOLID_DIR` or `SVIFA_ANGULAR_DIR`); +`check:oldest` does this for the oldest version of each. ### When the checks run On every pull request and every push to `main`, CI runs the lint, the license lines, the types, the build, the suite in all three -engines on the newest Node release and the newest long-term support release, the coverage floors, the package checks and the -reproducible build check; a pull request also gets dependency review and the sign-off check, and CodeQL analyses both. A pull -request can merge only once the required checks pass. Before a version is staged on npm, the Release workflow checks the -dependencies with `npm audit` and runs the lint, the types, the build, the suite and the package checks again. +engines on the newest Node release and the newest long-term support release, the coverage floors, the package checks, every example +built and driven in a browser, each framework entry on the oldest version of its framework, and the reproducible build check; a pull +request also gets dependency review and the sign-off check, and CodeQL analyses both. A pull request can merge only once the +required checks pass. Before a version is staged on npm, the Release workflow checks the dependencies with `npm audit` and runs the +lint, the types, the build, the suite and the package checks again. ### How it works @@ -50,9 +54,13 @@ drawn on the chosen elements. Anything else that moves the scroller (a key, the - `src/subpixel.ts`: draws the part of a pixel the browser cannot scroll by as a `translate` on the chosen elements, holding sticky elements still while they are held. - `src/target.ts`: what a `scrollTo` target names and the position it stands for (scroll-margin, scroll-padding, nested boxes). +- `src/snap.ts`: snapping (`snap`): the places a scroller may rest, read from its CSS, and which of them a movement rests on. The + browser has the last word: `src/svifa.ts` asks it where it would rest the scroller before each glide. - `src/wheel.ts`: a wheel event in pixels, and whether an element inside the scroller takes it itself. -- `src/types.ts`: the public types; their JSDoc is the reference editors show. `src/react.ts`: the React entry (`SmoothScroll`, - `useSvifa`), which imports the core by the package's own name (`tsconfig.json` maps it to `src/index.ts`). +- `src/types.ts`: the public types; their JSDoc is the reference editors show. +- `src/react.ts`, `src/vue.ts`, `src/svelte.ts`, `src/solid.ts`, `src/angular.ts` and `astro/SmoothScroll.astro`: the framework + entries, each importing the core by the package's own name (`tsconfig.json` maps it to `src/index.ts`), so a project holds one + Svifa class whichever it uses. `src/adapter.ts` is what they share: which options retune a Svifa and which make it again. - `tests/`: one spec per area. `tests/setup.ts` loads the script build and steps Svifa frame by frame on a clock of the test's own (`autoRaf: false`), so every run sees the same frames at any chosen refresh rate. A test for something only some engines have is tagged (`@chromium-only`, `@not-webkit`) or listed in `playwright.config.ts`, so it runs only there and nothing is reported @@ -69,9 +77,9 @@ defects with its correctness and suspicious rules, as `.oxlintrc.json` sets them drive, and call a constructor for what it does). `npm run lint` checks both, and CI runs it on every change. An exception to a rule is rare, and is written where it applies, with the reason beside it. -Every source file opens with two lines: the license as an [SPDX](https://spdx.dev/learn/handling-license-info/) identifier, and -the copyright that `LICENSE` gives. CSS and HTML files write them in their own comments. A new file starts with them too, and CI -checks every file for them. +Every source file opens with two lines: the license as an [SPDX](https://spdx.dev/learn/handling-license-info/) identifier, and the +copyright that `LICENSE` gives. CSS, HTML, Vue, Svelte and Astro files write them in their own comments. A new file starts with them +too, and CI checks every file for them. ```ts // SPDX-License-Identifier: MIT diff --git a/README.md b/README.md index 145a675..0a41874 100644 --- a/README.md +++ b/README.md @@ -35,9 +35,9 @@ the rest of smooth scrolling right: else that reads the scroll position work as they do without it. - **Out of the way.** The keyboard, the scroll bar, touch and find in page stay the browser's own, and any of them takes over from a scroll at once, as does any scroll Svifa did not make. A reader who asks for reduced motion gets the browser's own scrolling. -- **Small and anywhere.** Plain JavaScript with no dependencies, under 8 kB minified and gzipped, and safe to import during server - rendering. A component for React and Next.js, and a recipe for every major framework. ES module, CommonJS and a ` + + +``` + +`SmoothScroll` takes Svifa's options as props (and `scroller` as a template ref too) and renders only what it holds. Changing a prop +that shapes the feel retunes the running Svifa, and one taken off goes back to its default; changing what it scrolls makes it again. +A flag written alone is on (``), and one left out keeps its default. Anything inside reaches the Svifa with +`useSvifa`, a ref that is null on the server and until the Svifa is made: + +```vue + + + ``` +Vue 3.3 and later. + ### Nuxt -A client-only plugin, `app/plugins/svifa.client.ts` (in Nuxt 3, `plugins/svifa.client.ts`): +The Vue component, in `app/app.vue` (in Nuxt 3, `app.vue`) or a layout. It makes the Svifa in the browser only, so server +rendering is untouched: -```ts -import Svifa from '@arcwrightstudio/svifa' +```vue + -export default defineNuxtPlugin(() => { - const svifa = new Svifa() - return { provide: { svifa } } -}) + ``` +Add the stylesheet in `nuxt.config.ts`: `css: ['@arcwrightstudio/svifa/svifa.css']`. Pages and components reach the Svifa with +`useSvifa`, as above. + ### Svelte and SvelteKit In a component, or for a whole SvelteKit site in `src/routes/+layout.svelte`: ```svelte + +{@render children()} +``` + +`smoothScroll` is called while the component starts. It makes the Svifa once the component is in the page, takes it off when the +component leaves, and returns a store of it: `$svifa` is null on the server and until the Svifa is made. Components inside reach the +same store with `useSvifa`, which also takes a function to call on every scroll: + +```svelte + + + + +``` + +For a scrolling element, pass a function that returns it: `smoothScroll({ scroller: () => box })`, with `bind:this={box}` on the +element. Retune it with `$svifa?.configure({ lerp })`, in an `$effect` to follow state. Svelte 4 and 5. + +### Solid + +The Solid entry has a component and a primitive: + +```tsx +import { SmoothScroll } from '@arcwrightstudio/svifa/solid' +import '@arcwrightstudio/svifa/svifa.css' + +export function App(props) { + return {props.children} +} +``` + +`SmoothScroll` takes Svifa's options as props, with `scroller` also taking a function that returns an element (one set with +`ref`). Changing a prop that shapes the feel retunes the running Svifa; changing what it scrolls makes it again. Anything inside +reaches the Svifa with `useSvifa`, an accessor that is null on the server and until the Svifa is made: + +```tsx +import { createSignal } from 'solid-js' +import { useSvifa } from '@arcwrightstudio/svifa/solid' + +function BackToTop() { + const svifa = useSvifa() + return +} + +function ReadingProgress() { + const [progress, setProgress] = createSignal(0) + useSvifa((current) => setProgress(current.progress)) + return +} ``` +Solid 1.6 and later, SolidStart included. + ### Angular -In the root component (`src/app/app.ts` in a new project): +In the app's providers, `src/app/app.config.ts`: ```ts -import { Component, DestroyRef, NgZone, afterNextRender, inject } from '@angular/core' -import Svifa from '@arcwrightstudio/svifa' +import { type ApplicationConfig } from '@angular/core' +import { provideSmoothScroll } from '@arcwrightstudio/svifa/angular' + +export const appConfig: ApplicationConfig = { + providers: [provideSmoothScroll({ lerp: 0.08 })], +} +``` + +Add `node_modules/@arcwrightstudio/svifa/svifa.css` to `styles` in `angular.json`. The Svifa is made once the app has first +rendered, only in the browser, so server rendering is untouched; and outside Angular's zone, so a glide's frames do not run change +detection. It works with zone.js and without it. Anything in the app reaches it with `injectSvifa`, a signal that is null until the +Svifa is made, which also takes a function to call on every scroll: + +```ts +import { Component, signal } from '@angular/core' +import { injectSvifa } from '@arcwrightstudio/svifa/angular' + +@Component({ + selector: 'app-reading', + template: ` + + + `, +}) +export class Reading { + protected readonly svifa = injectSvifa() + protected readonly progress = signal(0) -@Component({ selector: 'app-root', templateUrl: './app.html' }) -export class App { constructor() { - const zone = inject(NgZone) - const destroyRef = inject(DestroyRef) - // only in the browser, and outside Angular's zone so a glide's frames do not run change detection - afterNextRender(() => { - const svifa = zone.runOutsideAngular(() => new Svifa()) - destroyRef.onDestroy(() => svifa.destroy()) - }) + injectSvifa((svifa) => this.progress.set(svifa.progress)) } } ``` -Add `node_modules/@arcwrightstudio/svifa/svifa.css` to `styles` in `angular.json`. The same code works with zone.js and without it. +For a scrolling element in a component, `createSvifa` makes a Svifa of the component's own, taken off with it. Its options may be a +function, read once the component has rendered, so they can name an element from a view query: + +```ts +private readonly box = viewChild.required>('box') +private readonly boxed = createSvifa(() => ({ scroller: this.box().nativeElement })) +``` + +Angular 19 and later. ### Astro In a layout: ```astro - ``` @@ -265,9 +398,11 @@ svifa.configure({ duration: undefined }) // back to lerp Whatever the setting, a glide takes the same time at every refresh rate, from 60 Hz to 1000 Hz. -Each recipe above is checked in a project made with that framework's own tool: Next.js 16 with React 19, Vue 3.5 with Vite, -Nuxt 4, Svelte 5, SvelteKit 3, Angular 22 (with zone.js and without) and Astro 7, as well as the plain page and GSAP 3. The React -entry is also tested on React 18. +Each component is tested in an app of its framework, and every example is built with its framework's own tool and driven in a +browser on each change: Next.js 16 with React 19, Vue 3.5 with Vite, Nuxt 4, SvelteKit 3 with Svelte 5, Solid 1.9 with Vite, +Angular 22 and Astro 7, as well as plain Vite and the plain page. The components are also tested on the oldest version of each +framework they support: React 18, Vue 3.3, Svelte 4, Solid 1.6 and Angular 19 (with zone.js and without). GSAP 3 drives Svifa from +its own ticker. ## Options @@ -289,6 +424,7 @@ new Svifa({ lerp: 0.08, anchors: { offset: -80 } }) | `chain` | `true` | For a scrolling element: once it is at its start or end, the wheel carries on to the page around it, as the browser's own scrolling does. | | `subpixel` | none | The elements to draw between whole pixels while the content glides. See [Drawing between pixels](#drawing-between-pixels). | | `anchors` | `false` | Glide to same-page links when they are clicked. `true`, or `scrollTo` options for every such glide. See [Same-page links](#same-page-links). | +| `snap` | `false` | Glide to the snap points the page sets in CSS (`scroll-snap-type`). See [Scroll snapping](#scroll-snapping). | | `reducedMotion` | `true` | Follow the reader's reduced-motion setting: while it is on, the wheel is the browser's and `scrollTo` moves at once. | | `autoRaf` | `true` | Run Svifa's own frame loop, which runs only while something moves. `false` to call `svifa.raf(time)` from your own loop. | @@ -330,8 +466,8 @@ moves at once and resolves `true`. ### `configure(changes)` -Changes how Svifa feels while it runs: `lerp`, `duration`, `easing`, `wheel`, `wheelSpeed`, `nested`, `native`, `chain` and -`anchors`. Each one given replaces the one in use, from the next glide or wheel turn; `duration: undefined` goes back to gliding by +Changes how Svifa feels while it runs: `lerp`, `duration`, `easing`, `wheel`, `wheelSpeed`, `nested`, `native`, `chain`, `anchors` +and `snap`. Each one given replaces the one in use, from the next glide or wheel turn; `duration: undefined` goes back to gliding by `lerp`, and a number that is not a finite one leaves that option as it was. `scroller`, `axis`, `subpixel`, `reducedMotion` and `autoRaf` belong to the scroller itself: to change them, destroy Svifa and make it again. See [Tuning the feel](#tuning-the-feel). @@ -494,8 +630,26 @@ that scrolls to an element. ## Scroll snapping -A scroller with `scroll-snap-type` is left to the browser, which puts every position on a snap point: the wheel scrolls it as the -browser does, and `scrollTo` moves at once. +```js +new Svifa({ snap: true }) +``` + +With `snap: true`, a scroller with `scroll-snap-type` glides to the snap points its CSS sets. The wheel moves as far as it would +without snapping, and the glide comes to rest on the nearest snap point: + +- With `mandatory`, a turn too small to get halfway to the next point still goes on to it, so one notch or one trackpad swipe moves + one point, and a longer movement goes as far as the wheel takes it. +- With `proximity`, it rests where the wheel takes it, unless a snap point is within a third of the visible area; then it rests on + the point ahead. +- `scrollTo` and same-page links rest on the snap point nearest their target. +- `scroll-padding`, `scroll-margin`, `scroll-snap-stop: always` and areas longer than the visible area are honoured. + +Browsers differ over where a scroller may rest, most of all inside an area longer than the visible area, so before each glide Svifa +asks the browser and rests exactly where the browser itself would. The browser's snapping is held off while Svifa glides and put +back when it rests, so it never moves the scroller afterwards. The keyboard, the scroll bar and touch snap as the browser does. + +Without `snap`, a scroller that snaps is left to the browser: the wheel scrolls it as the browser does, and `scrollTo` moves at +once. So is a scroller in a vertical writing mode, with or without it. ## Your own frame loop @@ -531,7 +685,8 @@ the constructor throws an error that says so. Create it where only the browser r **Nothing glides.** Check that the page itself scrolls. A layout that sets `html, body { height: 100%; overflow: auto }` makes `` the scroller instead; pass `scroller: document.body`, or whichever element really scrolls. Svifa also leaves the wheel -alone while the scroller has `overflow: hidden`, which is how many dialogs lock scrolling, and on a scroller that snaps. +alone while the scroller has `overflow: hidden`, which is how many dialogs lock scrolling, and on a scroller that snaps unless +`snap` is on. **A box inside the page does not scroll, or the page scrolls instead.** A box scrolls on its own only while it has room to move the way the wheel turns and its `overflow` is `auto` or `scroll`. For anything else (a map, a canvas, a custom scroller), add @@ -573,10 +728,15 @@ only through their `style` property, which no policy restricts. This is tested i | ` diff --git a/examples/README.md b/examples/README.md index 211ed13..b9e6ff3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -4,7 +4,14 @@ Each opens on StackBlitz, ready to run and change: - [Vite](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/vite): plain TypeScript, with a reading progress bar from Svifa's scroll event. - [Next.js](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/next): the App Router, with `SmoothScroll` in the root layout and `useSvifa` in a client component. +- [Vue](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/vue): Vite and Vue, with `SmoothScroll` around the app and `useSvifa` in a component. +- [Nuxt](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/nuxt): the Vue component in `app.vue`, rendered on the server. +- [SvelteKit](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/sveltekit): `smoothScroll` in the root layout and `useSvifa` in a component, rendered on the server. +- [Solid](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/solid): Vite and Solid, with `SmoothScroll` around the app and `useSvifa` in a component. +- [Angular](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/angular): `provideSmoothScroll` in the app's providers, `injectSvifa` for the reading progress, and a box with a Svifa of its own from `createSvifa`, rendered on the server. +- [Astro](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/astro): `SmoothScroll` in the layout, with view transitions between two pages. - [A plain page](https://stackblitz.com/github/arcwrightstudio/svifa/tree/main/examples/html): one HTML file, with Svifa from a CDN. -All three glide the page, glide to same-page links (stopping below the sticky header) and draw the content between pixels while -it moves. +All of them glide the page, glide to same-page links (stopping below the sticky header) and draw the content between pixels while it +moves. On every change each is built with its framework's own tool, from the package as it would be published, and driven in a +browser (`npm run check:examples`). diff --git a/examples/angular/angular.json b/examples/angular/angular.json new file mode 100644 index 0000000..e4ca956 --- /dev/null +++ b/examples/angular/angular.json @@ -0,0 +1,31 @@ +{ + "$schema": "./node_modules/@angular/cli/lib/config/schema.json", + "version": 1, + "cli": { "analytics": false }, + "newProjectRoot": "projects", + "projects": { + "svifa-example": { + "projectType": "application", + "root": "", + "sourceRoot": "src", + "architect": { + "build": { + "builder": "@angular/build:application", + "options": { + "browser": "src/main.ts", + "server": "src/main.server.ts", + "outputMode": "server", + "ssr": { "entry": "src/server.ts" }, + "security": { "allowedHosts": ["localhost"] }, + "tsConfig": "tsconfig.json", + "styles": ["node_modules/@arcwrightstudio/svifa/svifa.css", "src/style.css"] + } + }, + "serve": { + "builder": "@angular/build:dev-server", + "options": { "buildTarget": "svifa-example:build" } + } + } + } + } +} diff --git a/examples/angular/package.json b/examples/angular/package.json new file mode 100644 index 0000000..cf98e32 --- /dev/null +++ b/examples/angular/package.json @@ -0,0 +1,35 @@ +{ + "name": "svifa-example-angular", + "private": true, + "scripts": { + "start": "ng serve", + "build": "ng build", + "serve": "node dist/svifa-example/server/server.mjs" + }, + "dependencies": { + "@angular/common": "^22.2.0", + "@angular/compiler": "^22.2.0", + "@angular/core": "^22.2.0", + "@angular/platform-browser": "^22.2.0", + "@angular/platform-server": "^22.2.0", + "@angular/ssr": "^22.2.0", + "@arcwrightstudio/svifa": "^1.2.0", + "express": "^5.1.0", + "rxjs": "^7.8.2", + "tslib": "^2.8.1" + }, + "devDependencies": { + "@angular/build": "^22.2.0", + "@angular/cli": "^22.2.0", + "@angular/compiler-cli": "^22.2.0", + "@types/express": "^5.0.1", + "@types/node": "^22.12.0", + "typescript": "~6.0.0" + }, + "allowScripts": { + "@parcel/watcher": false, + "esbuild": false, + "lmdb": false, + "msgpackr-extract": false + } +} diff --git a/examples/angular/src/app/app.config.server.ts b/examples/angular/src/app/app.config.server.ts new file mode 100644 index 0000000..8509e31 --- /dev/null +++ b/examples/angular/src/app/app.config.server.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { mergeApplicationConfig, type ApplicationConfig } from '@angular/core' +import { provideServerRendering, withRoutes } from '@angular/ssr' +import { appConfig } from './app.config' +import { serverRoutes } from './app.routes.server' + +// the browser's providers, Svifa's among them, with server rendering added +export const config: ApplicationConfig = mergeApplicationConfig(appConfig, { + providers: [provideServerRendering(withRoutes(serverRoutes))], +}) diff --git a/examples/angular/src/app/app.config.ts b/examples/angular/src/app/app.config.ts new file mode 100644 index 0000000..1903510 --- /dev/null +++ b/examples/angular/src/app/app.config.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { provideZonelessChangeDetection, type ApplicationConfig } from '@angular/core' +import { provideClientHydration } from '@angular/platform-browser' +import { provideSmoothScroll } from '@arcwrightstudio/svifa/angular' + +export const appConfig: ApplicationConfig = { + providers: [ + provideZonelessChangeDetection(), + // the browser takes over the page the server rendered, rather than drawing it again + provideClientHydration(), + // the page glides, and so do same-page links, stopping below the sticky header + provideSmoothScroll({ subpixel: 'main > section', anchors: { offset: -56 } }), + ], +} diff --git a/examples/angular/src/app/app.routes.server.ts b/examples/angular/src/app/app.routes.server.ts new file mode 100644 index 0000000..3ae8845 --- /dev/null +++ b/examples/angular/src/app/app.routes.server.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { RenderMode, type ServerRoute } from '@angular/ssr' + +// every page is rendered on the server when it is asked for +export const serverRoutes: ServerRoute[] = [{ path: '**', renderMode: RenderMode.Server }] diff --git a/examples/angular/src/app/app.ts b/examples/angular/src/app/app.ts new file mode 100644 index 0000000..c3e40ad --- /dev/null +++ b/examples/angular/src/app/app.ts @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { Component, ElementRef, signal, viewChild } from '@angular/core' +import { createSvifa, injectSvifa } from '@arcwrightstudio/svifa/angular' + +@Component({ + selector: 'app-root', + template: ` +
+ + +
+
+
+

Svifa

+

Turn the mouse wheel or swipe on the trackpad, and the page glides to where you sent it.

+
+
+

Out of the way

+

The keyboard, the scroll bar, touch and find in page stay the browser's own, and take over at once.

+
+ @for (line of lines; track line) { +

Line {{ line }} of a box that glides with a Svifa of its own.

+ } +
+
+
+

Between pixels

+

Scroll slowly and watch the text: it moves evenly to the end, with no one-pixel hops as it settles.

+
+
+ `, +}) +export class App { + protected readonly progress = signal(0) + protected readonly lines = Array.from({ length: 30 }, (_, i) => i + 1) + private readonly box = viewChild.required>('box') + + constructor() { + // the reading progress, from the page's Svifa (made by provideSmoothScroll in app.config.ts) + injectSvifa((svifa) => this.progress.set(svifa.progress)) + // the box's own Svifa, made once the box is on the page + createSvifa(() => ({ scroller: this.box().nativeElement })) + } +} diff --git a/examples/angular/src/index.html b/examples/angular/src/index.html new file mode 100644 index 0000000..1b5eb3b --- /dev/null +++ b/examples/angular/src/index.html @@ -0,0 +1,14 @@ + + + + + + + + + Svifa with Angular + + + + + diff --git a/examples/angular/src/main.server.ts b/examples/angular/src/main.server.ts new file mode 100644 index 0000000..46b841d --- /dev/null +++ b/examples/angular/src/main.server.ts @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { bootstrapApplication, type BootstrapContext } from '@angular/platform-browser' +import { App } from './app/app' +import { config } from './app/app.config.server' + +// the app as the server renders it, for each request +const bootstrap = (context: BootstrapContext) => bootstrapApplication(App, config, context) + +export default bootstrap diff --git a/examples/angular/src/main.ts b/examples/angular/src/main.ts new file mode 100644 index 0000000..5fed0f5 --- /dev/null +++ b/examples/angular/src/main.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { bootstrapApplication } from '@angular/platform-browser' +import { App } from './app/app' +import { appConfig } from './app/app.config' + +bootstrapApplication(App, appConfig).catch((error: unknown) => console.error(error)) diff --git a/examples/angular/src/server.ts b/examples/angular/src/server.ts new file mode 100644 index 0000000..e289da4 --- /dev/null +++ b/examples/angular/src/server.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { join } from 'node:path' +import { AngularNodeAppEngine, createNodeRequestHandler, isMainModule, writeResponseToNodeResponse } from '@angular/ssr/node' +import express from 'express' + +const browserFiles = join(import.meta.dirname, '../browser') +const app = express() +const angularApp = new AngularNodeAppEngine() + +// the built scripts and styles, as files +app.use(express.static(browserFiles, { maxAge: '1y', index: false, redirect: false })) + +// every other request, rendered by Angular +app.use((request, response, next) => { + angularApp + .handle(request) + .then((rendered) => (rendered ? writeResponseToNodeResponse(rendered, response) : next())) + .catch(next) +}) + +// run directly (npm run serve), it listens on PORT, or 4000 +if (isMainModule(import.meta.url)) { + const port = process.env['PORT'] || 4000 + app.listen(port, (error) => { + if (error) throw error + console.log(`Listening on http://localhost:${port}`) + }) +} + +// the handler the Angular CLI uses while developing and building +export const reqHandler = createNodeRequestHandler(app) diff --git a/examples/angular/src/style.css b/examples/angular/src/style.css new file mode 100644 index 0000000..c005a29 --- /dev/null +++ b/examples/angular/src/style.css @@ -0,0 +1,101 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} +/* a box with a Svifa of its own */ +.box { + height: 12rem; + margin-top: 1.5rem; + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; + padding: 0 1rem; +} diff --git a/examples/angular/tsconfig.json b/examples/angular/tsconfig.json new file mode 100644 index 0000000..76556df --- /dev/null +++ b/examples/angular/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "strict": true, + "noImplicitOverride": true, + "isolatedModules": true, + "experimentalDecorators": true, + "importHelpers": true, + "target": "ES2022", + "module": "preserve", + "skipLibCheck": true, + "types": ["node"] + }, + "angularCompilerOptions": { + "strictTemplates": true + }, + "files": ["src/main.ts", "src/main.server.ts", "src/server.ts"] +} diff --git a/examples/astro/astro.config.mjs b/examples/astro/astro.config.mjs new file mode 100644 index 0000000..4bf66f0 --- /dev/null +++ b/examples/astro/astro.config.mjs @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { defineConfig } from 'astro/config' + +export default defineConfig({}) diff --git a/examples/astro/package.json b/examples/astro/package.json new file mode 100644 index 0000000..1e483ea --- /dev/null +++ b/examples/astro/package.json @@ -0,0 +1,14 @@ +{ + "name": "svifa-example-astro", + "private": true, + "type": "module", + "scripts": { + "dev": "astro dev", + "build": "astro build", + "preview": "astro preview" + }, + "dependencies": { + "@arcwrightstudio/svifa": "^1.2.0", + "astro": "^7.3.8" + } +} diff --git a/examples/astro/src/layouts/Layout.astro b/examples/astro/src/layouts/Layout.astro new file mode 100644 index 0000000..f2eb8d0 --- /dev/null +++ b/examples/astro/src/layouts/Layout.astro @@ -0,0 +1,48 @@ +--- +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { ClientRouter } from 'astro:transitions' +import SmoothScroll from '@arcwrightstudio/svifa/astro' +import '../styles/style.css' + +const { title } = Astro.props +--- + + + + + + {title} + + + + + + +
+ + +
+
+ +
+ + + diff --git a/examples/astro/src/pages/index.astro b/examples/astro/src/pages/index.astro new file mode 100644 index 0000000..2b5de48 --- /dev/null +++ b/examples/astro/src/pages/index.astro @@ -0,0 +1,22 @@ +--- +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import Layout from '../layouts/Layout.astro' +--- + + +
+

Svifa

+

Turn the mouse wheel or swipe on the trackpad, and the page glides to where you sent it.

+
+
+

Out of the way

+

The keyboard, the scroll bar, touch and find in page stay the browser's own, and take over at once.

+
+
+

Between pixels

+

Scroll slowly and watch the text: it moves evenly to the end, with no one-pixel hops as it settles.

+

On to another page, which comes in with a view transition and glides as this one does.

+
+
diff --git a/examples/astro/src/pages/more.astro b/examples/astro/src/pages/more.astro new file mode 100644 index 0000000..aafa1a6 --- /dev/null +++ b/examples/astro/src/pages/more.astro @@ -0,0 +1,21 @@ +--- +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import Layout from '../layouts/Layout.astro' +--- + + +
+

Another page

+

It came in with a view transition, and its Svifa was made as it did.

+
+
+

The same feel

+

The wheel glides here too, and same-page links still stop below the header.

+
+
+

And back

+

Back to the first page.

+
+
diff --git a/examples/astro/src/styles/style.css b/examples/astro/src/styles/style.css new file mode 100644 index 0000000..b013f9a --- /dev/null +++ b/examples/astro/src/styles/style.css @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} diff --git a/examples/next/package.json b/examples/next/package.json index 8999c96..f32b190 100644 --- a/examples/next/package.json +++ b/examples/next/package.json @@ -8,7 +8,7 @@ }, "dependencies": { "@arcwrightstudio/svifa": "^1.0.0", - "next": "^16.4.2", + "next": "^16.4.0", "react": "^19.3.1", "react-dom": "^19.3.1" }, diff --git a/examples/nuxt/app/app.vue b/examples/nuxt/app/app.vue new file mode 100644 index 0000000..6b1bf60 --- /dev/null +++ b/examples/nuxt/app/app.vue @@ -0,0 +1,35 @@ + + + + + + diff --git a/examples/nuxt/app/assets/style.css b/examples/nuxt/app/assets/style.css new file mode 100644 index 0000000..b013f9a --- /dev/null +++ b/examples/nuxt/app/assets/style.css @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} diff --git a/examples/nuxt/app/components/ReadingProgress.vue b/examples/nuxt/app/components/ReadingProgress.vue new file mode 100644 index 0000000..81aa768 --- /dev/null +++ b/examples/nuxt/app/components/ReadingProgress.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/examples/nuxt/nuxt.config.ts b/examples/nuxt/nuxt.config.ts new file mode 100644 index 0000000..aa93c3f --- /dev/null +++ b/examples/nuxt/nuxt.config.ts @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +export default defineNuxtConfig({ + compatibilityDate: '2026-10-01', + app: { head: { title: 'Svifa with Nuxt' } }, + css: ['@arcwrightstudio/svifa/svifa.css', '~/assets/style.css'], + devtools: { enabled: false }, + // Nitro looks @vue/shared up with a trailing slash when it leaves it outside the server bundle, which Node reports as deprecated + // (DEP0155); bundled in, it is never looked up that way + nitro: { externals: { inline: ['@vue/shared'] } }, +}) diff --git a/examples/nuxt/package.json b/examples/nuxt/package.json new file mode 100644 index 0000000..4536f2e --- /dev/null +++ b/examples/nuxt/package.json @@ -0,0 +1,23 @@ +{ + "name": "svifa-example-nuxt", + "private": true, + "type": "module", + "scripts": { + "dev": "nuxt dev", + "build": "nuxt build", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@arcwrightstudio/svifa": "^1.2.0", + "nuxt": "^4.6.1", + "vue": "^3.5.44" + }, + "overrides": { + "archiver-utils": { + "glob": "^13.0.6" + } + }, + "allowScripts": { + "esbuild": false + } +} diff --git a/examples/solid/index.html b/examples/solid/index.html new file mode 100644 index 0000000..84c852b --- /dev/null +++ b/examples/solid/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Svifa with Solid + + +
+ + + diff --git a/examples/solid/package.json b/examples/solid/package.json new file mode 100644 index 0000000..ab5d344 --- /dev/null +++ b/examples/solid/package.json @@ -0,0 +1,18 @@ +{ + "name": "svifa-example-solid", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@arcwrightstudio/svifa": "^1.2.0", + "solid-js": "^1.9.17" + }, + "devDependencies": { + "vite": "^8.3.4", + "vite-plugin-solid": "^2.11.14" + } +} diff --git a/examples/solid/src/App.tsx b/examples/solid/src/App.tsx new file mode 100644 index 0000000..1fc9343 --- /dev/null +++ b/examples/solid/src/App.tsx @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { createSignal } from 'solid-js' +import { SmoothScroll, useSvifa } from '@arcwrightstudio/svifa/solid' + +function ReadingProgress() { + const [progress, setProgress] = createSignal(0) + useSvifa((svifa) => setProgress(svifa.progress)) + return +} + +export function App() { + return ( + // the page glides, and so do same-page links, stopping below the sticky header + +
+ + +
+
+
+

Svifa

+

Turn the mouse wheel or swipe on the trackpad, and the page glides to where you sent it.

+
+
+

Out of the way

+

The keyboard, the scroll bar, touch and find in page stay the browser's own, and take over at once.

+
+
+

Between pixels

+

Scroll slowly and watch the text: it moves evenly to the end, with no one-pixel hops as it settles.

+
+
+
+ ) +} diff --git a/examples/solid/src/index.tsx b/examples/solid/src/index.tsx new file mode 100644 index 0000000..e3be62a --- /dev/null +++ b/examples/solid/src/index.tsx @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { render } from 'solid-js/web' +import '@arcwrightstudio/svifa/svifa.css' +import { App } from './App' +import './style.css' + +render(() => , document.getElementById('root')!) diff --git a/examples/solid/src/style.css b/examples/solid/src/style.css new file mode 100644 index 0000000..b013f9a --- /dev/null +++ b/examples/solid/src/style.css @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} diff --git a/examples/solid/tsconfig.json b/examples/solid/tsconfig.json new file mode 100644 index 0000000..2a2728e --- /dev/null +++ b/examples/solid/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "jsx": "preserve", + "jsxImportSource": "solid-js", + "strict": true, + "noEmit": true, + "types": ["vite/client"] + }, + "include": ["src"] +} diff --git a/examples/solid/vite.config.ts b/examples/solid/vite.config.ts new file mode 100644 index 0000000..7bcde5f --- /dev/null +++ b/examples/solid/vite.config.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { defineConfig } from 'vite' +import solid from 'vite-plugin-solid' + +export default defineConfig({ plugins: [solid()] }) diff --git a/examples/sveltekit/package.json b/examples/sveltekit/package.json new file mode 100644 index 0000000..d2cc314 --- /dev/null +++ b/examples/sveltekit/package.json @@ -0,0 +1,20 @@ +{ + "name": "svifa-example-sveltekit", + "private": true, + "type": "module", + "scripts": { + "dev": "vite dev", + "build": "vite build", + "start": "node build" + }, + "dependencies": { + "@arcwrightstudio/svifa": "^1.2.0" + }, + "devDependencies": { + "@sveltejs/adapter-node": "^6.0.0", + "@sveltejs/kit": "^3.1.0", + "@sveltejs/vite-plugin-svelte": "^7.3.1", + "svelte": "^5.57.2", + "vite": "^8.3.4" + } +} diff --git a/examples/sveltekit/src/app.html b/examples/sveltekit/src/app.html new file mode 100644 index 0000000..878f4b1 --- /dev/null +++ b/examples/sveltekit/src/app.html @@ -0,0 +1,15 @@ + + + + + + + + + Svifa with SvelteKit + %sveltekit.head% + + +
%sveltekit.body%
+ + diff --git a/examples/sveltekit/src/lib/ReadingProgress.svelte b/examples/sveltekit/src/lib/ReadingProgress.svelte new file mode 100644 index 0000000..ac7b0f2 --- /dev/null +++ b/examples/sveltekit/src/lib/ReadingProgress.svelte @@ -0,0 +1,11 @@ + + + + + + diff --git a/examples/sveltekit/src/routes/+layout.svelte b/examples/sveltekit/src/routes/+layout.svelte new file mode 100644 index 0000000..16eab82 --- /dev/null +++ b/examples/sveltekit/src/routes/+layout.svelte @@ -0,0 +1,15 @@ + + + + + +{@render children()} diff --git a/examples/sveltekit/src/routes/+page.svelte b/examples/sveltekit/src/routes/+page.svelte new file mode 100644 index 0000000..9ae3b10 --- /dev/null +++ b/examples/sveltekit/src/routes/+page.svelte @@ -0,0 +1,30 @@ + + + + + +
+ + +
+
+
+

Svifa

+

Turn the mouse wheel or swipe on the trackpad, and the page glides to where you sent it.

+
+
+

Out of the way

+

The keyboard, the scroll bar, touch and find in page stay the browser's own, and take over at once.

+
+
+

Between pixels

+

Scroll slowly and watch the text: it moves evenly to the end, with no one-pixel hops as it settles.

+
+
diff --git a/examples/sveltekit/src/style.css b/examples/sveltekit/src/style.css new file mode 100644 index 0000000..b013f9a --- /dev/null +++ b/examples/sveltekit/src/style.css @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} diff --git a/examples/sveltekit/vite.config.js b/examples/sveltekit/vite.config.js new file mode 100644 index 0000000..8b6e927 --- /dev/null +++ b/examples/sveltekit/vite.config.js @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import adapter from '@sveltejs/adapter-node' +import { sveltekit } from '@sveltejs/kit/vite' +import { defineConfig } from 'vite' + +export default defineConfig({ plugins: [sveltekit({ adapter: adapter() })] }) diff --git a/examples/vue/index.html b/examples/vue/index.html new file mode 100644 index 0000000..bbb174a --- /dev/null +++ b/examples/vue/index.html @@ -0,0 +1,15 @@ + + + + + + + + + Svifa with Vue + + +
+ + + diff --git a/examples/vue/package.json b/examples/vue/package.json new file mode 100644 index 0000000..a960386 --- /dev/null +++ b/examples/vue/package.json @@ -0,0 +1,18 @@ +{ + "name": "svifa-example-vue", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@arcwrightstudio/svifa": "^1.2.0", + "vue": "^3.5.44" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^6.0.10", + "vite": "^8.3.4" + } +} diff --git a/examples/vue/src/App.vue b/examples/vue/src/App.vue new file mode 100644 index 0000000..d28406c --- /dev/null +++ b/examples/vue/src/App.vue @@ -0,0 +1,36 @@ + + + + + + diff --git a/examples/vue/src/ReadingProgress.vue b/examples/vue/src/ReadingProgress.vue new file mode 100644 index 0000000..81aa768 --- /dev/null +++ b/examples/vue/src/ReadingProgress.vue @@ -0,0 +1,14 @@ + + + + + + diff --git a/examples/vue/src/env.d.ts b/examples/vue/src/env.d.ts new file mode 100644 index 0000000..4622ef0 --- /dev/null +++ b/examples/vue/src/env.d.ts @@ -0,0 +1,8 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent + export default component +} diff --git a/examples/vue/src/main.ts b/examples/vue/src/main.ts new file mode 100644 index 0000000..bde2e06 --- /dev/null +++ b/examples/vue/src/main.ts @@ -0,0 +1,9 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { createApp } from 'vue' +import '@arcwrightstudio/svifa/svifa.css' +import App from './App.vue' +import './style.css' + +createApp(App).mount('#app') diff --git a/examples/vue/src/style.css b/examples/vue/src/style.css new file mode 100644 index 0000000..b013f9a --- /dev/null +++ b/examples/vue/src/style.css @@ -0,0 +1,92 @@ +/* SPDX-License-Identifier: MIT */ +/* Copyright (c) 2026 Arcwright Studio */ + +:root { + --paper: #ffffff; + --ink: #14142b; + --soft: #5b5c74; + --line: #e2e2ed; + --gradient: linear-gradient(90deg, #0431b0, #9c31e1, #fc6947); +} +body { + margin: 0; + background: var(--paper); + color: var(--ink); + font: + 18px/1.6 system-ui, + sans-serif; +} +header { + position: sticky; + top: 0; + z-index: 1; + display: flex; + align-items: center; + height: 56px; + /* the links start where the text below does */ + padding: 0 max(1.5rem, calc((100% - 37rem) / 2)); + background: var(--paper); + border-bottom: 1px solid var(--line); +} +nav { + display: flex; + gap: 1.5rem; +} +nav a { + color: var(--soft); + font-size: 15px; + text-decoration: none; +} +nav a:hover { + color: var(--ink); +} +/* the reading progress, a hairline along the header's foot that the gradient fills as the page is read */ +progress { + position: absolute; + left: 0; + bottom: -1px; + width: 100%; + height: 2px; + border: 0; + appearance: none; + background: transparent; +} +progress::-webkit-progress-bar { + background: transparent; +} +progress::-webkit-progress-value { + background: var(--gradient) 0 0 / 100vw 100%; +} +progress::-moz-progress-bar { + background: var(--gradient) 0 0 / 100vw 100%; +} +section { + box-sizing: border-box; + display: grid; + align-content: center; + min-height: 100vh; + max-width: 40rem; + margin: 0 auto; + padding: 4rem 1.5rem; +} +h1, +h2 { + margin: 0; + font-weight: 600; + line-height: 1.1; + letter-spacing: -0.02em; +} +h1 { + font-size: 3.5rem; +} +h2 { + font-size: 2.25rem; +} +p { + margin: 1rem 0 0; + color: var(--soft); +} +/* drawn between pixels while the page glides (Svifa's subpixel option) */ +.svifa-subpixel main > section { + will-change: translate; +} diff --git a/examples/vue/vite.config.ts b/examples/vue/vite.config.ts new file mode 100644 index 0000000..591688e --- /dev/null +++ b/examples/vue/vite.config.ts @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite' + +export default defineConfig({ plugins: [vue()] }) diff --git a/llms.txt b/llms.txt index 6734ff8..5609e2e 100644 --- a/llms.txt +++ b/llms.txt @@ -23,7 +23,8 @@ Script tag: https://cdn.jsdelivr.net/npm/@arcwrightstudio/svifa@1.1.0/dist/svifa - To scroll from code, call svifa.scrollTo(...), not window.scrollTo: a scroll Svifa did not make ends any glide at once. - Optional CSS: import '@arcwrightstudio/svifa/svifa.css' (stop() then also holds the keyboard and the scroll bar). - Reduced motion is followed by default: the wheel stays the browser's and scrollTo moves at once. -- A scroller with scroll-snap-type is left to the browser (the wheel scrolls natively; scrollTo moves at once). +- A scroller with scroll-snap-type is left to the browser (the wheel scrolls natively; scrollTo moves at once) unless the snap + option is on: then Svifa glides to its snap points and rests exactly where the browser would (see Options). - Works under a strict Content Security Policy, Trusted Types included: no inline script or style, no eval, nothing fetched. ## Plain JavaScript @@ -54,20 +55,81 @@ useSvifa((g) => setProgress(g.progress)) // called on every scroll ``` SmoothScroll props are the options below; `scroller` may also be a ref. Changing lerp, duration, easing, wheel, wheelSpeed, nested, -native, chain or anchors retunes the running Svifa; changing scroller, axis, subpixel (a selector), reducedMotion or autoRaf makes it -again. Works under StrictMode. +native, chain, anchors or snap retunes the running Svifa (a prop taken off goes back to its default); changing scroller, axis, +subpixel (a selector), reducedMotion or autoRaf makes it again. Works under StrictMode. -## Other frameworks +## Vue and Nuxt (@arcwrightstudio/svifa/vue, needs vue 3.3 or later) -- Vue: `let svifa; onMounted(() => (svifa = new Svifa())); onBeforeUnmount(() => svifa?.destroy())` -- Nuxt 4: app/plugins/svifa.client.ts (Nuxt 3: plugins/svifa.client.ts): - `export default defineNuxtPlugin(() => ({ provide: { svifa: new Svifa() } }))` -- Svelte / SvelteKit (a component, or src/routes/+layout.svelte): `onMount(() => { const svifa = new Svifa(); return () => - svifa.destroy() })` -- Angular (root component, src/app/app.ts), in the constructor, with `zone = inject(NgZone)` and - `destroyRef = inject(DestroyRef)`: `afterNextRender(() => { const svifa = zone.runOutsideAngular(() => new Svifa()); - destroyRef.onDestroy(() => svifa.destroy()) })`. Works with zone.js and without it. -- Astro: `` in a layout. +```vue + + +``` + +Inside it: `const svifa = useSvifa()` (a ref, null on the server and until made), `useSvifa((g) => (progress.value = g.progress))`. +Props as React's; `scroller` may also be a template ref (null waits for it). A flag written alone is on (``); +one left out keeps its default. It renders only what it holds. + +## Svelte and SvelteKit (@arcwrightstudio/svifa/svelte, needs svelte 4 or 5) + +```svelte + +``` + +Call smoothScroll while the component starts; it makes the Svifa on mount and takes it off on destroy, and shares the store with +components inside: `useSvifa()` returns it, `useSvifa((g) => (progress = g.progress))` also listens. For an element: `smoothScroll({ +scroller: () => box })` with `bind:this={box}`. Retune with `$svifa?.configure({ lerp })`. + +## Solid (@arcwrightstudio/svifa/solid, needs solid-js 1.6 or later) + +```tsx +import { SmoothScroll, useSvifa } from '@arcwrightstudio/svifa/solid' +{props.children} +const svifa = useSvifa() // an accessor: svifa() is null on the server and until made +useSvifa((g) => setProgress(g.progress)) +``` + +Props as React's; `scroller` may also be a function returning an element (one set with ref). + +## Angular (@arcwrightstudio/svifa/angular, needs @angular/core 19 or later) + +```ts +// app.config.ts +providers: [provideSmoothScroll({ lerp: 0.08 })] +// any component or service (injection context): a signal, null until made +svifa = injectSvifa() +constructor() { injectSvifa((g) => this.progress.set(g.progress)) } +// a Svifa of a component's own, for a scrolling element from a view query +box = viewChild.required>('box') +boxed = createSvifa(() => ({ scroller: this.box().nativeElement })) +``` + +Made after the first render, only in the browser, outside Angular's zone; works with zone.js and without. Add +node_modules/@arcwrightstudio/svifa/svifa.css to styles in angular.json. + +## Astro (@arcwrightstudio/svifa/astro) + +```astro +--- +import SmoothScroll from '@arcwrightstudio/svifa/astro' +--- + +``` + +Props are the options that can be written in markup (not easing or native); scroller and subpixel are CSS selectors. With view +transitions () a Svifa is made for each page. A script reaches it as document.querySelector('svifa-scroll').svifa. + +## Without a component + +Make it in the browser and destroy it when whatever made it goes away: `const svifa = new Svifa()` in an effect, onMounted, +onMount, afterNextRender or a script tag, then `svifa.destroy()`. ## Options (new Svifa(options)) @@ -85,6 +147,7 @@ again. Works under StrictMode. | chain | true | For an element scroller: at its start or end the wheel goes on to the page. | | subpixel | none | A selector, a list or a function: elements drawn between whole pixels while the content glides (see below). | | anchors | false | true, or scrollTo options: glide to same-page links (#id, #, #top). Adds a history entry, sends hashchange, moves keyboard focus for a link pressed from the keyboard. | +| snap | false | Glide to the snap points the page sets in CSS (scroll-snap-type, scroll-snap-align). The wheel goes as far as it would and rests on the nearest point; mandatory: one notch or swipe moves one point; proximity: rests on a point only within a third of the visible area. scrollTo and same-page links rest on the nearest point. scroll-padding, scroll-margin, scroll-snap-stop and areas longer than the visible area are honoured; every rest is where the browser itself would rest. | | reducedMotion | true | Follow prefers-reduced-motion. | | autoRaf | true | false to call svifa.raf(time) from your own loop (GSAP: gsap.ticker.add((t) => svifa.raf(t * 1000))). | @@ -98,7 +161,7 @@ A number that is not finite is taken as its default. arrives), force (glide while stopped). An element lands as for a link to it (scroll-margin and scroll-padding honoured; a nested scrolling box is brought into view first). Resolves true on arrival, false if something else moved the scroller, the wheel or a later scrollTo took over, Svifa is stopped, or the target is missing (with a warning). Never rejects. -- configure(changes): change lerp, duration, easing, wheel, wheelSpeed, nested, native, chain, anchors while running +- configure(changes): change lerp, duration, easing, wheel, wheelSpeed, nested, native, chain, anchors, snap while running (duration: undefined goes back to lerp). Other options are fixed at creation. - stop() / start(): hold the scroller for a dialog; with svifa.css the keyboard and the scroll bar are held too, and the scroll bar's room is kept so nothing shifts. @@ -112,7 +175,8 @@ scroll (current position: exact while gliding, the browser's at rest), target, l (px/s), direction (1, -1, 0), isGliding, isStopped, element ( for the page), axis, options (read only); x and y, each axis on its own ({ scroll, target, limit, progress, velocity, direction }, null for an axis it does not move); both ways, the single-axis properties are the vertical axis. Exports: Svifa (default and named), easeOut, version; types SvifaOptions, ScrollToOptions, -TunableOptions, SvifaTarget, Point, AxisState, Easing, Axis, SvifaEvent, ResolvedOptions. +TunableOptions, SvifaTarget, Point, AxisState, Easing, Axis, SvifaEvent, ResolvedOptions. Entries: /react, /vue, /svelte, /solid, +/angular, /astro, /svifa.css. ## Events and classes @@ -134,5 +198,5 @@ a listed element uses greyscale antialiasing. Off while reduced motion is on, an ## Yarn Plug'n'Play -The React entry works through a bundler. Importing it directly in Node through Yarn's experimental ES module loader fails (React -itself fails the same way there); use require in that case. The core imports either way. +Every entry works through a bundler, and installing Svifa does not turn on Yarn's experimental ES module loader. Code Node runs +directly under Plug'n'Play loads Svifa with require; an ES module run that way needs that loader (pnpEnableEsmLoader: true). diff --git a/package-lock.json b/package-lock.json index fd8defd..b554051 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,10 @@ "version": "1.1.0", "license": "MIT", "devDependencies": { + "@angular/common": "22.2.3", + "@angular/compiler": "22.2.3", + "@angular/core": "22.2.3", + "@angular/platform-browser": "22.2.3", "@arethetypeswrong/cli": "0.18.5", "@playwright/test": "1.64.1", "@types/node": "24.19.2", @@ -22,19 +26,40 @@ "publint": "0.3.25", "react": "19.3.1", "react-dom": "19.3.1", + "rxjs": "7.8.2", + "solid-js": "1.9.17", + "svelte": "5.57.2", "tsdown": "0.23.1", "typescript": "7.0.2", - "v8-to-istanbul": "9.3.0" + "v8-to-istanbul": "9.3.0", + "vue": "3.5.44", + "zone.js": "0.16.3" }, "engines": { "node": ">=14" }, "peerDependencies": { - "react": ">=18" + "@angular/core": ">=19", + "react": ">=18", + "solid-js": ">=1.6", + "svelte": ">=4", + "vue": ">=3.3" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "react": { "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } } }, @@ -45,6 +70,85 @@ "dev": true, "license": "MIT" }, + "node_modules/@angular/common": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/common/-/common-22.2.3.tgz", + "integrity": "sha512-sRzcvxzurbkCDVFJMguNVgsHds3NvDj5qbZ44NHjH7J9MBk5oZYXHppdHVm8AolBhCXAWvMk0vx+NO8Bt4rS7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/core": "22.2.3", + "rxjs": "^6.5.3 || ^7.4.0" + } + }, + "node_modules/@angular/compiler": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-22.2.3.tgz", + "integrity": "sha512-GjDkmOBqW+6RJTeW5mlLRivf6Zy7Ed0auVh+DYD67d4UF4Sk17F1vIt8puF4CQ2PIezlHh7oOnermCBNHAOGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + } + }, + "node_modules/@angular/core": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/core/-/core-22.2.3.tgz", + "integrity": "sha512-ponarFrAZsPutT+htRt3PEmkt2/IB7DyrBy2f48sxsIx3ZA6xw09i2HOEBo+VBphlN6B/ynK9q+o04HPhdJ+dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/compiler": "22.2.3", + "rxjs": "^6.5.3 || ^7.4.0", + "zone.js": "~0.15.0 || ~0.16.0" + }, + "peerDependenciesMeta": { + "@angular/compiler": { + "optional": true + }, + "zone.js": { + "optional": true + } + } + }, + "node_modules/@angular/platform-browser": { + "version": "22.2.3", + "resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-22.2.3.tgz", + "integrity": "sha512-eWZGsbG4bSrSg9UB/NE8wORGVrh1u+x6rirPGpMWqq/mTi6q+nS+pnPxAxmiEs8wmPH5nCz5b2pRKQeYUkhAGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.3.0" + }, + "engines": { + "node": "^22.22.3 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "@angular/animations": "22.2.3", + "@angular/common": "22.2.3", + "@angular/core": "22.2.3" + }, + "peerDependenciesMeta": { + "@angular/animations": { + "optional": true + } + } + }, "node_modules/@arethetypeswrong/cli": { "version": "0.18.5", "resolved": "https://registry.npmjs.org/@arethetypeswrong/cli/-/cli-0.18.5.tgz", @@ -101,6 +205,56 @@ "node": ">=14.17" } }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@braidai/lang": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@braidai/lang/-/lang-1.1.2.tgz", @@ -561,6 +715,28 @@ "node": ">=18" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -1294,6 +1470,23 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@sveltejs/acorn-typescript": { + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz", + "integrity": "sha512-wgKggnhZVL9Bfx1OaKKTrYY9BFRk6C8UAkQNUcIv1+llzYrIqy+RZm5HPKzn0NpEBvTVhTqB4kQyllZywsRBRQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^8.9.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/istanbul-lib-coverage": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", @@ -1671,6 +1864,113 @@ "node": ">=16.20.0" } }, + "node_modules/@vue/compiler-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.44.tgz", + "integrity": "sha512-k34nCytbK8PQ1gR2mGROYOw6Pnv/mumqIdrZ3x2tbyXZDWm7ViKGLTfdB+DRrWqEQsLgGJvn0WN9jBif5zCTLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/shared": "3.5.44", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.44.tgz", + "integrity": "sha512-W5AoBFWdw1lQAKiHowkfgFKFab82oSRT6sGsPfw/q1GRjsy/ZJFLHGhJyBRIFN9APLTUduDH9U2JAiPOIyKuYg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.44.tgz", + "integrity": "sha512-dzHmQ4jXTpdr5FjIQUeykVakN/uzqFbUo9/C5ikBFg3a3PeM6uRxeczx1ZbXBML0/zJstEh3ygYipzR5Z7b2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.9", + "@vue/compiler-core": "3.5.44", + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-ssr": "3.5.44", + "@vue/shared": "3.5.44", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.29", + "source-map-js": "^1.2.2" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.44.tgz", + "integrity": "sha512-xr5gb1JVHlfXJwSlRz1FeP6uxoazdzHuMTVLE7up+4Vrqxk219CvxLPxD9NXBt47yvZW7+p4DXfyp4O1fkByIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.44.tgz", + "integrity": "sha512-u+QJdKeBBEADkPdevsL+6E12QlYVfBD8Zcw5qjDQKtamI6l14KyqazJMobhCKelhWN5zpyKUiEDoTyHE93cqWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.44.tgz", + "integrity": "sha512-uEa/ndwjhrrhnOfb2p+sZy6zeoamJOISqaLSJGk/8R6nABT1ZldpzcJoPzKj+zHVomVBPcmQYyC2r4LtvDV6CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.44.tgz", + "integrity": "sha512-3fc4ARcXVhVBaJ3ujWiNZJARD6pkyRcwG+ZYhMzjKtKUp7MAY0HLwr7r3Smep3Li6ZaWT6ewdmx8XqdaD5oNZg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.44", + "@vue/runtime-core": "3.5.44", + "@vue/shared": "3.5.44", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.44.tgz", + "integrity": "sha512-R4S1UoH4h5Frrauv8Z5XE5TxV9oBiUSgWIC4TyVK1FK+RocRnOLwFA5Xcmo2011RlgJp+AbZCnw6afrhlgRuUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/shared": "3.5.44" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.44.tgz", + "integrity": "sha512-H82XLH0DjmSOdbEwO15x3QTo1f5PWEtv237eLpYKc0mqLu+B8hnq16lFWmuBmNurkeiG3wr+oM1FhOll/YVFRg==", + "dev": true, + "license": "MIT" + }, "node_modules/@yuku-core/binding-android-arm64": { "version": "0.19.0", "resolved": "https://registry.npmjs.org/@yuku-core/binding-android-arm64/-/binding-android-arm64-0.19.0.tgz", @@ -1864,6 +2164,19 @@ "dev": true, "license": "MIT" }, + "node_modules/acorn": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz", + "integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -1916,6 +2229,26 @@ "dev": true, "license": "MIT" }, + "node_modules/aria-query": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", + "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/cac": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/cac/-/cac-7.1.0.tgz", @@ -2010,6 +2343,16 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -2061,6 +2404,13 @@ "dev": true, "license": "MIT" }, + "node_modules/devalue": { + "version": "5.9.4", + "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.4.tgz", + "integrity": "sha512-sPAT4pztbu6586/hrhOnMKS17IJrvg12mXiSPSS3W5qDeN2RGgvZ0diZCm31dBbnevfVmujNO3IM2wrS4Y2Rhg==", + "dev": true, + "license": "MIT" + }, "node_modules/dts-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/dts-resolver/-/dts-resolver-3.0.0.tgz", @@ -2106,6 +2456,19 @@ "node": ">=14" } }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -2171,6 +2534,38 @@ "node": ">=6" } }, + "node_modules/esm-env": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", + "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esrap": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.4.0.tgz", + "integrity": "sha512-CQPcneEHw/czj32uwQC52nYnax8H4SXt25FCM+LIwGZ49cMIxpOj3bP8wmzcI+Z0HIy5KkClaPhRLfCySzwHPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.4.15" + }, + "peerDependencies": { + "@typescript-eslint/types": "^8.2.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/types": { + "optional": true + } + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-check": { "version": "4.10.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.10.2.tgz", @@ -2295,6 +2690,16 @@ "node": ">=8" } }, + "node_modules/is-reference": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", + "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.6" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -2305,6 +2710,13 @@ "node": ">=8" } }, + "node_modules/locate-character": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", + "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.3.tgz", @@ -2315,6 +2727,16 @@ "node": "20 || >=22" } }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/marked": { "version": "9.1.6", "resolved": "https://registry.npmjs.org/marked/-/marked-9.1.6.tgz", @@ -2385,6 +2807,25 @@ "thenify-all": "^1.0.0" } }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/node-emoji": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/node-emoji/-/node-emoji-2.2.0.tgz", @@ -2554,6 +2995,35 @@ "node": ">=20" } }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/prettier": { "version": "3.9.10", "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.10.tgz", @@ -2749,6 +3219,16 @@ } } }, + "node_modules/rxjs": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz", + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, "node_modules/sade": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz", @@ -2782,6 +3262,29 @@ "node": ">=10" } }, + "node_modules/seroval": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.6.9.tgz", + "integrity": "sha512-UeEzHwAK+yl0YLbsvROV1t5yMYEwZZ0CfKYxDvUgS2fO9Vxdf8djqVIB+OuVpuawRNPqw68LE1Du/GMsW2ZPqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/seroval-plugins": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.6.9.tgz", + "integrity": "sha512-kiDK/Tb8IWZ61WEU8pA2PDmAuw37gKph9Y77OZlD56mg0Y2g0Bg7gZ1HeHhs5d40w+nRoUq/wj08V26ilbez9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "seroval": "^1.0" + } + }, "node_modules/skin-tone": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/skin-tone/-/skin-tone-2.0.0.tgz", @@ -2795,6 +3298,28 @@ "node": ">=8" } }, + "node_modules/solid-js": { + "version": "1.9.17", + "resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.17.tgz", + "integrity": "sha512-4GAmLOWPfNV8H9Jfn2LSFTpNEG6jiOKPiwd5lnLGQtehHaX6l3TOLzJqQ7mqwB/3k1Yy3zyumFVopmc/tm34ow==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.1.0", + "seroval": "~1.6.8", + "seroval-plugins": "~1.6.8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -2863,6 +3388,33 @@ "url": "https://github.com/chalk/supports-hyperlinks?sponsor=1" } }, + "node_modules/svelte": { + "version": "5.57.2", + "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.57.2.tgz", + "integrity": "sha512-1YiPCAXT0Ct6g1vOMmSLa0Nsc3QRMvtDF32ihgglGVrugIuIK38RyDiFNXVDhtQ81TJjSO57NdMw6DyYLqSGDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "@jridgewell/sourcemap-codec": "^1.6.0", + "@sveltejs/acorn-typescript": "^1.0.13", + "@types/estree": "^1.0.9", + "acorn": "^8.18.0", + "aria-query": "5.3.1", + "axobject-query": "^4.1.0", + "clsx": "^2.1.1", + "devalue": "^5.9.2", + "esm-env": "^1.2.1", + "esrap": "^2.3.6", + "is-reference": "^3.0.3", + "locate-character": "^3.0.0", + "magic-string": "^0.30.11", + "zimmerframe": "^1.1.2" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/thenify": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", @@ -2995,6 +3547,13 @@ } } }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD" + }, "node_modules/typescript": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", @@ -3099,6 +3658,28 @@ "url": "https://github.com/sponsors/sxzz" } }, + "node_modules/vue": { + "version": "3.5.44", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.44.tgz", + "integrity": "sha512-dvvmaOCG7lAMkJTPqPwWeGMsPS6DIsFsfZDUgBp9V6mHf9b3+/wyVwYR4Es+WV6uJC0FKRxp81Obcq0ZwUcGFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.44", + "@vue/compiler-sfc": "3.5.44", + "@vue/runtime-dom": "3.5.44", + "@vue/server-renderer": "3.5.44", + "@vue/shared": "3.5.44" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -3222,6 +3803,20 @@ "engines": { "node": ">=14.18.0" } + }, + "node_modules/zimmerframe": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.5.tgz", + "integrity": "sha512-msJxIvYDYcoNL+PJsu+7qmpDWsYmAxTY+2TNYXXF0hzBzBk0BMecOqDOG/EckUoKCuKwObfbugIl8QpqHDXeFA==", + "dev": true, + "license": "MIT" + }, + "node_modules/zone.js": { + "version": "0.16.3", + "resolved": "https://registry.npmjs.org/zone.js/-/zone.js-0.16.3.tgz", + "integrity": "sha512-ihXL9+vhYyEhXz4TDNpHeAOZN9FVrbog0Il64OIEI28UP/n5AaI6gsccRuOHBfx+206agzyoK527bYIO0Foy6A==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 4369315..ad4f07f 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,19 @@ "javascript", "lerp", "nextjs", + "nuxt", "react", "scroll", + "scroll snap", "scrolling", "smooth scroll", "smooth scrolling", "smooth-scroll", "smoothscroll", + "solid", "subpixel", "svelte", + "sveltekit", "trackpad", "typescript", "vue", @@ -33,7 +37,7 @@ }, "license": "MIT", "author": "Arcwright Studio", - "type": "module", + "type": "commonjs", "sideEffects": [ "*.css" ], @@ -63,12 +67,58 @@ "default": "./dist/react.cjs" } }, + "./vue": { + "import": { + "types": "./dist/vue.d.ts", + "default": "./dist/vue.js" + }, + "require": { + "types": "./dist/vue.d.cts", + "default": "./dist/vue.cjs" + } + }, + "./svelte": { + "import": { + "types": "./dist/svelte.d.ts", + "default": "./dist/svelte.js" + }, + "require": { + "types": "./dist/svelte.d.cts", + "default": "./dist/svelte.cjs" + } + }, + "./solid": { + "import": { + "types": "./dist/solid.d.ts", + "default": "./dist/solid.js" + }, + "require": { + "types": "./dist/solid.d.cts", + "default": "./dist/solid.cjs" + } + }, + "./angular": { + "import": { + "types": "./dist/angular.d.ts", + "default": "./dist/angular.js" + }, + "require": { + "types": "./dist/angular.d.cts", + "default": "./dist/angular.cjs" + } + }, + "./astro": "./astro/SmoothScroll.astro", "./svifa.css": "./svifa.css", "./package.json": "./package.json" }, "files": [ "dist", "react", + "vue", + "svelte", + "solid", + "angular", + "astro", "svifa.css", "llms.txt" ], @@ -76,29 +126,51 @@ "node": ">=14" }, "peerDependencies": { - "react": ">=18" + "@angular/core": ">=19", + "react": ">=18", + "solid-js": ">=1.6", + "svelte": ">=4", + "vue": ">=3.3" }, "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, "react": { "optional": true + }, + "solid-js": { + "optional": true + }, + "svelte": { + "optional": true + }, + "vue": { + "optional": true } }, "scripts": { "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsdown && npm run sbom", "sbom": "node scripts/sbom.mjs", "typecheck": "tsc && tsc -p tests", - "lint": "oxlint --deny-warnings src tests scripts examples && prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.ts\" \"examples/**/*.{ts,tsx,mjs,css}\"", - "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.ts\" \"examples/**/*.{ts,tsx,mjs,css}\"", + "lint": "oxlint --deny-warnings src tests scripts examples && prettier --check \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.{ts,mts}\" \"examples/**/*.{ts,tsx,js,mjs,css,vue}\"", + "format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\" \"scripts/**/*.mjs\" \"*.{ts,mts}\" \"examples/**/*.{ts,tsx,js,mjs,css,vue}\"", "test": "playwright test", "coverage": "node scripts/coverage.mjs --clean && playwright test --project chromium && node scripts/coverage.mjs", - "check:package": "publint && attw --pack . --exclude-entrypoints svifa.css && node scripts/check-example.mjs", + "check:package": "publint && attw --pack . --exclude-entrypoints svifa.css astro && node scripts/check-example.mjs", "prepublishOnly": "npm run lint && npm run typecheck && npm run build && npm test && npm run check:package --dry-run=false", - "check:installs": "node scripts/check-installs.mjs" + "check:installs": "node scripts/check-installs.mjs", + "check:examples": "node scripts/check-examples.mjs", + "check:oldest": "node scripts/check-oldest.mjs" }, "publishConfig": { "access": "public" }, "devDependencies": { + "@angular/common": "22.2.3", + "@angular/compiler": "22.2.3", + "@angular/core": "22.2.3", + "@angular/platform-browser": "22.2.3", "@arethetypeswrong/cli": "0.18.5", "@playwright/test": "1.64.1", "@types/node": "24.19.2", @@ -112,9 +184,14 @@ "publint": "0.3.25", "react": "19.3.1", "react-dom": "19.3.1", + "rxjs": "7.8.2", + "solid-js": "1.9.17", + "svelte": "5.57.2", "tsdown": "0.23.1", "typescript": "7.0.2", - "v8-to-istanbul": "9.3.0" + "v8-to-istanbul": "9.3.0", + "vue": "3.5.44", + "zone.js": "0.16.3" }, "allowScripts": { "esbuild@0.28.2": true diff --git a/playwright.config.ts b/playwright.config.ts index 95d343f..aaf4c23 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -6,7 +6,7 @@ import { defineConfig, devices } from '@playwright/test' // A test for something only some engines have runs only there, so no engine reports it skipped. Chromium alone shows its real // scroll bars headless and has scroll-driven animations (@chromium-only); WebKit has no window.find to stand in for find in page // (@not-webkit). The package's own tests use no browser, so they run once. -const CHROMIUM_ONLY = ['**/scrollbars.spec.ts', '**/package.spec.ts'] +const CHROMIUM_ONLY = ['**/scrollbars.spec.ts', '**/package.spec.ts', '**/server.spec.ts'] export default defineConfig({ testDir: 'tests', diff --git a/scripts/check-examples.mjs b/scripts/check-examples.mjs new file mode 100644 index 0000000..9af4df6 --- /dev/null +++ b/scripts/check-examples.mjs @@ -0,0 +1,272 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// Proves every example works with Svifa as it would be published: packs the package, copies each example into a temporary folder, +// installs it there with the tarball in place of the published version (with every dependency at least a week old), builds it with +// its framework's own tools, serves the result as the framework does, and drives it in a browser. The wheel must glide the +// page, a same-page link must glide to its section below the sticky header, the reading progress must follow, and the console must +// stay clear of errors and warnings. Run after the build. +// usage: node scripts/check-examples.mjs [name ...] +import { execSync, spawn } from 'node:child_process' +import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { createServer } from 'node:http' +import { createServer as createNetServer } from 'node:net' +import { tmpdir } from 'node:os' +import { basename, extname, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { chromium } from '@playwright/test' + +const REPO = fileURLToPath(new URL('..', import.meta.url)) +const HERE = mkdtempSync(join(tmpdir(), 'svifa-examples-')) +const { version } = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')) +// the package as it would be published; a warning while packing it stops the check +const packed = execSync(`npm pack --pack-destination "${HERE}" 2>&1`, { cwd: REPO, encoding: 'utf8' }) +if (/warn|deprecat/i.test(packed)) throw new Error(`npm pack warned:\n${packed}`) +const TGZ = join(HERE, `arcwrightstudio-svifa-${version}.tgz`).split('\\').join('/') +// the newest release of each dependency that is at least a week old +const BEFORE = new Date(Date.now() - 7 * 864e5).toISOString() +// The environment every step of an example runs in: the caller's, with the frameworks' usage reporting off, and with neither colour +// setting, which a tool may set one of for its own children (Node warns when it finds both, so a NO_COLOR in the caller's shell +// would make a step warn that never warns on CI). +const ENV = { + ...process.env, + CI: '1', + ASTRO_TELEMETRY_DISABLED: '1', + NEXT_TELEMETRY_DISABLED: '1', + NUXT_TELEMETRY_DISABLED: '1', + NG_CLI_ANALYTICS: 'false', +} +delete ENV.NO_COLOR +delete ENV.FORCE_COLOR + +// How each example is served once built: a folder of static files, or the framework's own server. +const EXAMPLES = { + vite: { files: 'dist' }, + vue: { files: 'dist' }, + solid: { files: 'dist' }, + astro: { files: 'dist', next: '/more/' }, + html: { files: '.', plain: true }, + next: { server: (port) => ['node_modules/next/dist/bin/next', 'start', '-p', String(port)] }, + nuxt: { server: () => ['.output/server/index.mjs'] }, + angular: { server: () => ['dist/svifa-example/server/server.mjs'] }, + sveltekit: { server: () => ['build'] }, +} + +// what an install or a build leaves in an example's folder, never copied from it +const LEFT_BEHIND = new Set(['node_modules', '.next', '.nuxt', '.output', 'dist', 'build', '.svelte-kit', '.astro', '.angular']) + +const TYPES = { + '.html': 'text/html', + '.js': 'text/javascript', + '.mjs': 'text/javascript', + '.css': 'text/css', + '.json': 'application/json', +} + +// a port nothing listens on, as the system hands one out +const freePort = () => + new Promise((resolve) => { + const probe = createNetServer().listen(0, () => { + const { port } = probe.address() + probe.close(() => resolve(port)) + }) + }) + +// a folder of built files, served as a static host does (a folder's index.html for its path) +function serveFiles(root, port) { + const server = createServer((request, response) => { + let path = decodeURIComponent(new URL(request.url, 'http://x').pathname) + if (path.endsWith('/')) path += 'index.html' + const file = join(root, path) + if (!existsSync(file)) { + response.writeHead(404).end() + return + } + response.writeHead(200, { 'content-type': TYPES[extname(file)] ?? 'application/octet-stream' }).end(readFileSync(file)) + }) + return new Promise((resolve) => server.listen(port, () => resolve(() => server.close()))) +} + +// The framework's own server, run by Node itself (no shell between, so stopping it stops the server), ready once it answers. +// Stopping it hands back everything it printed. +async function serveWith(args, cwd, port) { + const child = spawn(process.execPath, args, { cwd, env: { ...ENV, PORT: String(port) } }) + let printed = '' + child.stdout.on('data', (chunk) => (printed += chunk)) + child.stderr.on('data', (chunk) => (printed += chunk)) + const stop = () => { + child.kill() + return printed + } + for (let i = 0; i < 120; i++) { + try { + if ((await fetch(`http://localhost:${port}/`)).ok) return stop + } catch {} + await new Promise((resolve) => setTimeout(resolve, 500)) + } + throw new Error(`${args.join(' ')} did not start: ${stop()}`) +} + +// the lines of what a step printed that warn of anything, whoever printed them +const warningsIn = (said) => said.split('\n').filter((line) => /warn|deprecat/i.test(line)) + +// Builds the plain page's copy: the tags that load Svifa from the CDN load this build instead. +function plainPage(dir) { + const page = join(dir, 'index.html') + const html = readFileSync(page, 'utf8') + .replace(/') + .replace(/') + writeFileSync(page, html) + cpSync(join(REPO, 'svifa.css'), join(dir, 'svifa.css')) + cpSync(join(REPO, 'dist', 'svifa.global.min.js'), join(dir, 'svifa.global.min.js')) +} + +// What every example must do in the browser. +async function drive(browser, url, name, spec) { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) + const said = [] + page.on( + 'console', + (message) => ['error', 'warning'].includes(message.type()) && said.push(`${message.type()}: ${message.text()}`) + ) + page.on('pageerror', (error) => said.push(`error: ${error.message}`)) + try { + await steps(page, name, spec, url) + } catch (error) { + throw new Error([error.message.split('\n')[0], ...said].join(' | '), { cause: error }) + } finally { + await page.close() + } + if (said.length) throw new Error(said.join(' | ')) + return true +} + +async function steps(page, name, spec, url) { + await page.goto(url, { waitUntil: 'networkidle' }) + await page.waitForFunction(() => document.documentElement.classList.contains('svifa'), null, { timeout: 10000 }) + const rests = () => page.waitForFunction(() => !document.documentElement.classList.contains('svifa-gliding')) + + // the wheel glides the page to where it was sent + await page.mouse.move(640, 600) + await page.mouse.wheel(0, 400) + await page.waitForFunction(() => document.documentElement.classList.contains('svifa-gliding'), null, { timeout: 2000 }) + await rests() + const wheeled = await page.evaluate(() => window.scrollY) + if (Math.abs(wheeled - 400) > 1) throw new Error(`the wheel took the page to ${wheeled}, not 400`) + + // a same-page link glides to its section, stopping below the 56px header, and the reading progress follows + await page.click('a[href="#three"]') + await page.waitForFunction(() => document.documentElement.classList.contains('svifa-gliding'), null, { timeout: 2000 }) + await rests() + const top = await page.evaluate(() => document.getElementById('three').getBoundingClientRect().top) + if (Math.abs(top - 56) > 1) throw new Error(`the link left its section at ${top}px, not 56`) + const progress = await page.evaluate(() => document.querySelector('progress')?.value ?? 1) + if (!(progress > 0.5)) throw new Error(`the reading progress reads ${progress}`) + + // the Angular example's box glides with a Svifa of its own + if (name === 'angular') { + const box = page.locator('.box') + await box.scrollIntoViewIfNeeded() + const at = await box.boundingBox() + await page.mouse.move(at.x + at.width / 2, at.y + at.height / 2) + await page.mouse.wheel(0, 120) + await page.waitForFunction(() => document.querySelector('.box').classList.contains('svifa-gliding'), null, { timeout: 2000 }) + await page.waitForFunction(() => !document.querySelector('.box').classList.contains('svifa-gliding')) + const boxed = await box.evaluate((element) => element.scrollTop) + if (Math.abs(boxed - 120) > 1) throw new Error(`the box glided to ${boxed}, not 120`) + } + + // the Astro example's next page comes in with a view transition, and its own Svifa glides it + if (spec.next) { + await page.click(`a[href="${spec.next}"]`) + await page.waitForURL(`**${spec.next}`) + await page.waitForFunction(() => document.querySelector('svifa-scroll')?.svifa != null, null, { timeout: 5000 }) + await page.mouse.wheel(0, 300) + await page.waitForFunction(() => document.documentElement.classList.contains('svifa-gliding'), null, { timeout: 2000 }) + await rests() + } +} + +const chosen = process.argv.slice(2).length ? process.argv.slice(2) : Object.keys(EXAMPLES) +const unknown = chosen.filter((name) => !Object.hasOwn(EXAMPLES, name)) +if (unknown.length) { + console.error(`No such example: ${unknown.join(', ')}. The examples are ${Object.keys(EXAMPLES).join(', ')}.`) + process.exit(1) +} +const browser = await chromium.launch() +const results = {} +for (const name of chosen) { + const spec = EXAMPLES[name] + const dir = join(HERE, name) + const row = (results[name] = {}) + const step = async (label, run) => { + if (Object.values(row).some((value) => value !== 'ok')) return + try { + row[label] = (await run()) === false ? 'FAILED' : 'ok' + } catch (error) { + // what the command printed last, the error itself included + const said = [error.stdout, error.stderr, error.message].filter(Boolean).join('\n') + row[label] = `FAILED: ${said.split('\n').filter(Boolean).slice(-12).join(' | ').slice(0, 2000)}` + } + } + cpSync(join(REPO, 'examples', name), dir, { recursive: true, filter: (from) => !LEFT_BEHIND.has(basename(from)) }) + // runs a step of the example's own; any warning it prints, whoever prints it, fails the check + const run = (command) => { + const warned = warningsIn(execSync(`${command} 2>&1`, { cwd: dir, encoding: 'utf8', env: ENV })) + if (warned.length) throw new Error(warned.join(' | ')) + return true + } + if (spec.plain) { + await step('build', () => (plainPage(dir), true)) + } else { + const manifest = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) + manifest.dependencies['@arcwrightstudio/svifa'] = `file:${TGZ}` + writeFileSync(join(dir, 'package.json'), JSON.stringify(manifest, null, 2)) + await step('install', () => run(`npm install --before=${BEFORE} --no-audit --no-fund`)) + await step('build', () => run('npm run build')) + } + const port = await freePort() + let stop = null + await step('serve', async () => { + stop = spec.server ? await serveWith(spec.server(port), dir, port) : await serveFiles(join(dir, spec.files), port) + return true + }) + // a page the server rendered arrives with its sections already in it, before any script runs + if (spec.server) { + await step('rendered on the server', async () => { + const html = await (await fetch(`http://localhost:${port}/`)).text() + if (!html.includes('id="three"')) throw new Error('the page arrived without its sections') + return true + }) + } + await step('in the browser', () => drive(browser, `http://localhost:${port}/`, name, spec)) + // what the framework's own server printed while it served the page counts too + const printed = stop?.() ?? '' + if (spec.server) { + await step('server', () => { + const warned = warningsIn(printed) + if (warned.length) throw new Error(warned.join(' | ')) + return true + }) + } +} +await browser.close() + +for (const [name, row] of Object.entries(results)) { + console.log( + name.padEnd(10), + Object.entries(row) + .map(([label, value]) => `${label} ${value === 'ok' ? 'ok' : 'FAILED'}`) + .join(', ') + ) +} +const failed = Object.entries(results).flatMap(([name, row]) => + Object.entries(row) + .filter(([, value]) => value !== 'ok') + .map(([label, value]) => `${name} ${label}: ${value}`) +) +rmSync(HERE, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }) +if (failed.length) { + console.error(failed.join('\n')) + process.exit(1) +} diff --git a/scripts/check-installs.mjs b/scripts/check-installs.mjs index 9cc6b7a..34c9442 100644 --- a/scripts/check-installs.mjs +++ b/scripts/check-installs.mjs @@ -3,8 +3,10 @@ // Before a release: packs Svifa as it would be published, installs it with each package manager (npm, pnpm, Yarn 1, Yarn 4 in // Plug'n'Play mode, Bun, Deno) into a fresh project in a temporary folder, and proves it works there: an ES module import, a CommonJS -// require, a type check and a bundle with the stylesheet. Any warning printed while the package runs, is bundled or is type checked -// fails the check. Bun, Deno and Yarn 4 are fetched through npx and corepack when missing. +// require, a type check and a bundle with the stylesheet; with npm, every framework entry beside its framework too, and every entry +// of the package linked from its folder (npm install , as while developing beside it). Any warning printed while the +// package runs, is bundled or is type checked fails the check. Bun, Deno and Yarn 4 are fetched through npx and corepack when +// missing. // usage: npm run check:installs import { execSync } from 'node:child_process' import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' @@ -16,7 +18,9 @@ const REPO = fileURLToPath(new URL('..', import.meta.url)) const HERE = mkdtempSync(join(tmpdir(), 'svifa-installs-')) const TOOLS = join(REPO, 'node_modules') const { version } = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')) -execSync(`npm pack --pack-destination "${HERE}"`, { cwd: REPO, stdio: 'ignore' }) +// the package as it would be published; a warning while packing it stops the check +const packed = execSync(`npm pack --pack-destination "${HERE}" 2>&1`, { cwd: REPO, encoding: 'utf8' }) +if (/warn|deprecat/i.test(packed)) throw new Error(`npm pack warned: ${packed}`) const TGZ = join(HERE, `arcwrightstudio-svifa-${version}.tgz`).split('\\').join('/') const TSC = `node "${TOOLS}/typescript/bin/tsc"` // TypeScript 7 has no node10 resolution any more; a project still on it uses TypeScript 5, so that check runs on TypeScript 5 @@ -35,7 +39,7 @@ export const make = () => new Svifa(options) export const go = (g: Svifa): Promise => g.scrollTo('#contact', { offset: -80 }) // @ts-expect-error an unknown option is refused export const wrong = () => new Svifa({ smooth: true })` -// the React entry, beside React (it imports the core by the package's own name, which every manager has to resolve) +// the React entry, beside React (it imports the core from the file beside it, which every manager has to keep in place) const REACT = `import { SmoothScroll, useSvifa } from '@arcwrightstudio/svifa/react' console.log(JSON.stringify({ react: typeof SmoothScroll === 'function' && typeof useSvifa === 'function' }))` const REACT_TS = `import { SmoothScroll, useSvifa, type SmoothScrollProps } from '@arcwrightstudio/svifa/react' @@ -46,22 +50,39 @@ export const read = (): Svifa | null => useSvifa()` const REACT_CJS = `const { SmoothScroll, useSvifa } = require('@arcwrightstudio/svifa/react') console.log(JSON.stringify({ react: typeof SmoothScroll === 'function' && typeof useSvifa === 'function' }))` const REACT_VERSION = '19.3.0' +// the other framework entries, each beside its framework (installed with npm only: each is an entry like the React one) +const FRAMEWORKS = ['vue@3.5.44', 'svelte@5.57.2', 'solid-js@1.9.17', '@angular/core@22.2.3', 'rxjs@7.8.2'] +const FRAMEWORK_ENTRIES = `import * as vue from '@arcwrightstudio/svifa/vue' +import * as svelte from '@arcwrightstudio/svifa/svelte' +import * as solid from '@arcwrightstudio/svifa/solid' +import * as angular from '@arcwrightstudio/svifa/angular' +const functions = [vue.useSvifa, svelte.smoothScroll, svelte.useSvifa, solid.SmoothScroll, solid.useSvifa, + angular.provideSmoothScroll, angular.injectSvifa, angular.createSvifa] +// Vue's component is an object of options around its setup function +const vueComponent = typeof vue.SmoothScroll.setup === 'function' +console.log(JSON.stringify({ frameworks: vueComponent && functions.every((part) => typeof part === 'function') }))` +const FRAMEWORK_TS = `import { SmoothScroll as VueScroll, type SmoothScrollProps as VueProps } from '@arcwrightstudio/svifa/vue' +import { smoothScroll, type SmoothScrollOptions } from '@arcwrightstudio/svifa/svelte' +import { SmoothScroll as SolidScroll, type SmoothScrollProps as SolidProps } from '@arcwrightstudio/svifa/solid' +import { createSvifa, injectSvifa, provideSmoothScroll } from '@arcwrightstudio/svifa/angular' +const vue: VueProps = { lerp: 0.08, snap: true } +const svelte: SmoothScrollOptions = { scroller: () => null, anchors: { offset: -80 } } +const solid: SolidProps = { scroller: () => undefined, axis: 'both' } +export const parts = [VueScroll, vue, smoothScroll, svelte, SolidScroll, solid, createSvifa, injectSvifa, provideSmoothScroll] as const` const ENTRY = `import Svifa from '@arcwrightstudio/svifa' import '@arcwrightstudio/svifa/svifa.css' window.svifa = new Svifa()` -const run = (cmd, cwd) => - execSync(cmd, { - cwd, - encoding: 'utf8', - stdio: ['ignore', 'pipe', 'pipe'], - env: { ...process.env, CI: '1', COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' }, - }).trim() -// runs a step that uses the package, with what it writes to stderr read too: a warning there is a failure (npm's own lines, such as -// npx saying it fetched Bun or Deno, are about the machine, not the package) +// The environment every step runs in: the caller's, without its colour settings (a tool may set one for its own children, and Node +// warns when it finds both). +const ENV = { ...process.env, CI: '1', COREPACK_ENABLE_DOWNLOAD_PROMPT: '0' } +delete ENV.NO_COLOR +delete ENV.FORCE_COLOR +const run = (cmd, cwd) => execSync(cmd, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: ENV }).trim() +// runs a step, an install included, with what it writes to stderr read too: any warning there, whoever prints it, is a failure const use = (cmd, cwd) => { const out = run(`${cmd} 2>&1`, cwd) - const warned = out.split('\n').filter((line) => /warn/i.test(line) && !/^npm warn /i.test(line)) + const warned = out.split('\n').filter((line) => /warn|deprecat/i.test(line)) if (warned.length) throw new Error(warned.join(' | ')) return out } @@ -85,10 +106,12 @@ function project(name) { writeFileSync(join(dir, 'react.mjs'), REACT) writeFileSync(join(dir, 'react.cjs'), REACT_CJS) writeFileSync(join(dir, 'react.ts'), REACT_TS) + writeFileSync(join(dir, 'frameworks.mjs'), FRAMEWORK_ENTRIES) + writeFileSync(join(dir, 'frameworks.ts'), FRAMEWORK_TS) return dir } -function typecheck(dir, resolution) { +function typecheck(dir, resolution, extra = []) { const config = { compilerOptions: { strict: true, @@ -103,7 +126,7 @@ function typecheck(dir, resolution) { node10: { module: 'CommonJS', moduleResolution: 'Node10' }, }[resolution], }, - files: ['use.ts', 'react.ts'], + files: ['use.ts', 'react.ts', ...extra], } writeFileSync(join(dir, `tsconfig.${resolution}.json`), JSON.stringify(config)) use(`${resolution === 'node10' ? TSC_5 : TSC} -p tsconfig.${resolution}.json`, dir) @@ -136,13 +159,13 @@ const attempt = (name, steps) => { // npm, pnpm and Yarn 1: a node_modules folder for (const [name, install] of [ - ['npm', `npm install "${TGZ}" react@${REACT_VERSION} @types/react@${REACT_VERSION} --no-audit --no-fund`], + ['npm', `npm install "${TGZ}" react@${REACT_VERSION} @types/react@${REACT_VERSION} ${FRAMEWORKS.join(' ')} --no-audit --no-fund`], ['pnpm', `pnpm add "${TGZ}" react@${REACT_VERSION} @types/react@${REACT_VERSION}`], ['yarn1', `yarn add "file:${TGZ}" react@${REACT_VERSION} @types/react@${REACT_VERSION} --no-lockfile`], ]) { const dir = project(name) attempt(name, [ - ['install', () => (run(install, dir), true)], + ['install', () => (use(install, dir), true)], ['esm', () => lastJson(use('node check.mjs', dir)).esm], ['cjs', () => lastJson(use('node check.cjs', dir)).cjs], ['types (bundler)', () => typecheck(dir, 'bundler')], @@ -150,6 +173,28 @@ for (const [name, install] of [ ['types (node10)', () => typecheck(dir, 'node10')], ['react entry', () => lastJson(use('node react.mjs', dir)).react], ['bundle with css', () => bundle(dir)], + ...(name === 'npm' + ? [ + ['framework entries', () => lastJson(use('node frameworks.mjs', dir)).frameworks], + ['framework types (bundler)', () => typecheck(dir, 'bundler', ['frameworks.ts'])], + ['framework types (nodenext)', () => typecheck(dir, 'nodenext', ['frameworks.ts'])], + ] + : []), + ]) +} + +// The package linked from its folder, as npm links a folder it is given: Node finds a linked package's own imports from where its +// files really are, outside the project, so every import inside it has to work from there (the frameworks are this repository's). +{ + const dir = project('npm-linked') + attempt('npm-linked', [ + // the folder without its closing separator (on Windows a closing \ would escape the quote after it) + ['install', () => (use(`npm install "${REPO.replace(/[\\/]$/, '')}" --install-links=false --no-audit --no-fund`, dir), true)], + ['esm', () => lastJson(use('node check.mjs', dir)).esm], + ['cjs', () => lastJson(use('node check.cjs', dir)).cjs], + ['react entry', () => lastJson(use('node react.mjs', dir)).react], + ['react entry (cjs)', () => lastJson(use('node react.cjs', dir)).react], + ['framework entries', () => lastJson(use('node frameworks.mjs', dir)).frameworks], ]) } @@ -159,19 +204,20 @@ for (const [name, install] of [ writeFileSync(join(dir, '.yarnrc.yml'), 'nodeLinker: pnp\nenableGlobalCache: false\n') // a project of its own, whatever package.json sits in a folder above writeFileSync(join(dir, 'yarn.lock'), '') - const yarn = 'corepack yarn@4.9.2' + const yarn = 'corepack yarn@4.18.1' attempt('yarn4-pnp', [ + // Svifa does not make Yarn turn on its experimental ES module loader (.pnp.loader.mjs), which Yarn announces with a warning [ 'install', () => ( - run(`${yarn} add "@arcwrightstudio/svifa@file:${TGZ}" react@${REACT_VERSION}`, dir), - existsSync(join(dir, '.pnp.cjs')) + use(`${yarn} add "@arcwrightstudio/svifa@file:${TGZ}" react@${REACT_VERSION}`, dir), + existsSync(join(dir, '.pnp.cjs')) && !existsSync(join(dir, '.pnp.loader.mjs')) ), ], - ['esm', () => lastJson(use(`${yarn} node check.mjs`, dir)).esm], ['cjs', () => lastJson(use(`${yarn} node check.cjs`, dir)).cjs], - // through require: Yarn's ES module loader for Plug'n'Play is experimental and cannot load React itself on current Node ['react entry', () => lastJson(use(`${yarn} node react.cjs`, dir)).react], + // an ES module through Plug'n'Play, as a bundler loads it (esbuild reads the project's .pnp data itself) + ['bundle with css', () => bundle(dir)], ]) } @@ -180,7 +226,7 @@ for (const [name, install] of [ const dir = project('bun') const bun = 'npx -y bun@1' attempt('bun', [ - ['install', () => (run(`${bun} add "${TGZ}" react@${REACT_VERSION}`, dir), true)], + ['install', () => (use(`${bun} add "${TGZ}" react@${REACT_VERSION}`, dir), true)], ['react entry', () => lastJson(use(`${bun} react.mjs`, dir)).react], ['esm', () => lastJson(use(`${bun} check.mjs`, dir)).esm], ['cjs', () => lastJson(use(`${bun} check.cjs`, dir)).cjs], @@ -194,7 +240,7 @@ for (const [name, install] of [ writeFileSync(join(dir, 'deno.json'), JSON.stringify({ nodeModulesDir: 'manual' })) const deno = 'npx -y deno@2' attempt('deno', [ - ['install', () => (run(`npm install "${TGZ}" --no-audit --no-fund`, dir), true)], + ['install', () => (use(`npm install "${TGZ}" --no-audit --no-fund`, dir), true)], ['esm', () => lastJson(use(`${deno} run check.mjs`, dir)).esm], ['types (deno check)', () => (use(`${deno} check use.ts`, dir), true)], ]) diff --git a/scripts/check-oldest.mjs b/scripts/check-oldest.mjs new file mode 100644 index 0000000..49bd1ce --- /dev/null +++ b/scripts/check-oldest.mjs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// Proves each framework entry works with the oldest version of its framework that package.json supports (peerDependencies): installs +// that version into a folder of its own and runs the entry's tests against it (SVIFA__DIR, read by tests/app.ts), in Chromium. +// Fails when a version here no longer matches the lowest one peerDependencies names. Run after the build. +// usage: node scripts/check-oldest.mjs +import { execSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +const REPO = fileURLToPath(new URL('..', import.meta.url)) +const HERE = mkdtempSync(join(tmpdir(), 'svifa-oldest-')) +const { peerDependencies } = JSON.parse(readFileSync(join(REPO, 'package.json'), 'utf8')) + +// each entry, the framework package peerDependencies names, and what is installed to test it +const OLDEST = { + react: { peer: 'react', install: ['react@18.3.1', 'react-dom@18.3.1'] }, + vue: { peer: 'vue', install: ['vue@3.3.13'] }, + svelte: { peer: 'svelte', install: ['svelte@4.2.20'] }, + solid: { peer: 'solid-js', install: ['solid-js@1.6.16'] }, + angular: { + peer: '@angular/core', + install: [ + '@angular/core@19.2.25', + '@angular/common@19.2.25', + '@angular/compiler@19.2.25', + '@angular/platform-browser@19.2.25', + 'rxjs@7.8.2', + 'zone.js@0.15.1', + ], + }, +} + +const failed = [] +for (const [name, { peer, install }] of Object.entries(OLDEST)) { + // the version tested is in the line peerDependencies starts from (">=3.3" is tested on a 3.3 release) + const lowest = peerDependencies[peer].replace(/^>=/, '') + const version = install.find((spec) => spec.startsWith(`${peer}@`)).slice(peer.length + 1) + if (!`${version}.`.startsWith(`${lowest}.`)) { + failed.push(`${name}: peerDependencies supports ${peer} from ${lowest}, but ${version} is what is tested`) + continue + } + // a folder of its own, with a package.json, so npm installs there and nowhere above it + const dir = join(HERE, name) + mkdirSync(dir) + writeFileSync(join(dir, 'package.json'), '{ "private": true }\n') + try { + run(`npm install --prefix "${dir}" --no-audit --no-fund --ignore-scripts ${install.join(' ')}`, {}) + run(`npx playwright test tests/${name}.spec.ts --project chromium --reporter=line`, { + [`SVIFA_${name.toUpperCase()}_DIR`]: join(dir, 'node_modules'), + }) + console.log(`${name.padEnd(8)} ${version} ok`) + } catch (error) { + const said = [error.stdout, error.message].filter(Boolean).join('\n') + failed.push(`${name} ${version}: ${said.split('\n').filter(Boolean).slice(-8).join(' | ')}`) + } +} + +// Runs a step in the repository with `env` added (and no colour settings, which tools may set one of for their children, and Node +// warns when it finds both); a warning in what it prints, whoever prints it, fails it. +function run(command, env) { + const environment = { ...process.env, ...env } + delete environment.NO_COLOR + delete environment.FORCE_COLOR + const said = execSync(`${command} 2>&1`, { cwd: REPO, encoding: 'utf8', env: environment }) + const warned = said.split('\n').filter((line) => /warn|deprecat/i.test(line)) + if (warned.length) throw new Error(warned.join(' | ')) +} + +rmSync(HERE, { recursive: true, force: true, maxRetries: 10, retryDelay: 500 }) +if (failed.length) { + console.error(failed.join('\n')) + process.exit(1) +} diff --git a/scripts/coverage.mjs b/scripts/coverage.mjs index d4f29c8..1d77c9f 100644 --- a/scripts/coverage.mjs +++ b/scripts/coverage.mjs @@ -19,7 +19,7 @@ if (process.argv.includes('--clean')) { } // the least the tests may cover, in percent; raised as coverage grows, never lowered -const FLOOR = { statements: 96, branches: 89, functions: 94, lines: 96 } +const FLOOR = { statements: 97, branches: 92, functions: 94, lines: 97 } if (!existsSync(raw)) { console.error('Nothing was recorded: run `npm run coverage`, which runs the tests in Chromium first.') diff --git a/solid/package.json b/solid/package.json new file mode 100644 index 0000000..0b9cdf0 --- /dev/null +++ b/solid/package.json @@ -0,0 +1,6 @@ +{ + "main": "../dist/solid.cjs", + "module": "../dist/solid.js", + "types": "../dist/solid.d.ts", + "sideEffects": false +} diff --git a/src/adapter.ts b/src/adapter.ts new file mode 100644 index 0000000..e51e04b --- /dev/null +++ b/src/adapter.ts @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// What every framework's component shares: the options that tune how Svifa feels (changed in place) as against those that shape +// what it scrolls (it is made again), and a `subpixel` a framework can hand over anew each time without that counting as a change. +import type { SvifaOptions, TunableOptions } from './types' + +/** The options that tune how Svifa feels, each as given (one left out goes back to its default). */ +export const feelOf = (options: TunableOptions): TunableOptions => ({ + lerp: options.lerp, + duration: options.duration, + easing: options.easing, + wheel: options.wheel, + wheelSpeed: options.wheelSpeed, + nested: options.nested, + native: options.native, + chain: options.chain, + anchors: options.anchors, + snap: options.snap, +}) + +/** What `subpixel` names, for telling whether it changed: a selector by its text; a list or a function is read afresh anyway. */ +export const subpixelKey = (subpixel: SvifaOptions['subpixel']): string => + typeof subpixel === 'string' ? subpixel : subpixel === undefined ? '' : 'given' + +/** The `subpixel` to make a Svifa with: a selector as it is; a list or a function read through `latest` each time Svifa asks. */ +export function subpixelOf(latest: () => SvifaOptions['subpixel']): SvifaOptions['subpixel'] { + const given = latest() + if (given === undefined || typeof given === 'string') return given + return () => { + const now = latest() + return typeof now === 'function' ? now() : typeof now === 'string' ? document.querySelectorAll(now) : (now ?? []) + } +} diff --git a/src/angular.ts b/src/angular.ts new file mode 100644 index 0000000..1e9f56f --- /dev/null +++ b/src/angular.ts @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { Svifa, type SvifaOptions } from '@arcwrightstudio/svifa' +import { + DestroyRef, + InjectionToken, + NgZone, + afterNextRender, + effect, + inject, + makeEnvironmentProviders, + provideEnvironmentInitializer, + signal, + type EnvironmentProviders, + type Signal, + type WritableSignal, +} from '@angular/core' + +// the Svifa made by provideSmoothScroll, for anything in the app to reach +const PAGE = new InjectionToken>('svifa', { providedIn: 'root', factory: () => signal(null) }) + +// Makes a Svifa once the app has first rendered (only in the browser), outside Angular's zone so a glide's frames do not run change +// detection, and takes it off when `destroyRef` is destroyed. +function make(into: WritableSignal, options: SvifaOptions | (() => SvifaOptions)) { + const zone = inject(NgZone) + let made: Svifa | null = null + afterNextRender(() => { + const given = typeof options === 'function' ? options() : options + made = zone.runOutsideAngular(() => new Svifa(given)) + into.set(made) + }) + inject(DestroyRef).onDestroy(() => { + made?.destroy() + made = null + into.set(null) + }) +} + +/** + * Smooth scrolling for the whole page, in the app's providers (`app.config.ts`). Anything in the app reaches the Svifa with + * `injectSvifa`. With server rendering, it is made only in the browser. + * + * @example + * export const appConfig: ApplicationConfig = { providers: [provideSmoothScroll({ anchors: { offset: -80 } })] } + */ +export function provideSmoothScroll(options: SvifaOptions = {}): EnvironmentProviders { + return makeEnvironmentProviders([provideEnvironmentInitializer(() => make(inject(PAGE), options))]) +} + +/** + * The Svifa made by provideSmoothScroll, as a signal: null on the server and until it is made. Given `onScroll`, calls it with the + * Svifa on every change of the scroll position, whatever moved it, until the component (or service) is destroyed. Call it in an + * injection context: a constructor or a field. + * + * @example + * private svifa = injectSvifa() + * toTop() { this.svifa()?.scrollTo('top') } + */ +export function injectSvifa(onScroll?: (svifa: Svifa) => void): Signal { + const svifa = inject(PAGE).asReadonly() + if (onScroll) { + effect((onCleanup) => { + const current = svifa() + if (current) onCleanup(current.on('scroll', onScroll)) + }) + } + return svifa +} + +/** + * A Svifa of a component's own, for a scrolling element in it: made once the component has first rendered, taken off when it is + * destroyed, as a signal (null until then). `options` may be a function, read once rendered, so it can name an element from a view + * query. Call it in an injection context: a constructor or a field. + * + * @example + * private box = viewChild.required>('box') + * private svifa = createSvifa(() => ({ scroller: this.box().nativeElement })) + */ +export function createSvifa(options: SvifaOptions | (() => SvifaOptions) = {}): Signal { + const svifa = signal(null) + make(svifa, options) + return svifa.asReadonly() +} diff --git a/src/react.ts b/src/react.ts index 07eca49..135f8fe 100644 --- a/src/react.ts +++ b/src/react.ts @@ -2,6 +2,7 @@ // Copyright (c) 2026 Arcwright Studio import { Svifa, type SvifaOptions } from '@arcwrightstudio/svifa' +import { feelOf, subpixelKey, subpixelOf } from './adapter' import { createContext, createElement, @@ -26,8 +27,9 @@ const SvifaContext = createContext(null) /** * Smooth scrolling as a component. Render it once, near the root (in Next.js, in the root layout); its props are Svifa's options. - * Changing `lerp`, `duration`, `easing`, `wheel`, `wheelSpeed`, `nested`, `native`, `chain` or `anchors` retunes the Svifa it made; - * changing `scroller`, `axis`, `subpixel` (as a selector), `reducedMotion` or `autoRaf` makes it again. Anything rendered inside it + * Changing `lerp`, `duration`, `easing`, `wheel`, `wheelSpeed`, `nested`, `native`, `chain`, `anchors` or `snap` retunes the Svifa it + * made, and one taken off goes back to its default; changing `scroller`, `axis`, `subpixel` (as a selector), `reducedMotion` or + * `autoRaf` makes it again. Anything rendered inside it * reaches the Svifa with `useSvifa`. * * @example @@ -61,40 +63,30 @@ export function useSvifa(onScroll?: (svifa: Svifa) => void): Svifa | null { } // Makes the Svifa once the component is in the page, and takes it off when it leaves. What it scrolls makes it again; how it feels is -// retuned in place. -function useMadeSvifa({ scroller, axis, subpixel, reducedMotion, autoRaf, ...feel }: Omit) { +// retuned in place. A `subpixel` list or function is read through the latest one given, so a new one on each render (an inline +// arrow) is not a change. +function useMadeSvifa(options: Omit) { + const { scroller, axis, subpixel, reducedMotion, autoRaf } = options const [svifa, setSvifa] = useState(null) - const latest = useRef({ feel, subpixel }) + const latest = useRef(options) useEffect(() => { - latest.current = { feel, subpixel } + latest.current = options }) - // A selector names its elements. A list or a function is read through the latest one given, so a new one on each render (an - // inline arrow) is not a change. - const subpixelKey = typeof subpixel === 'string' ? subpixel : subpixel === undefined ? '' : 'given' + const named = subpixelKey(subpixel) useEffect(() => { const target = scroller && 'current' in scroller ? (scroller.current ?? undefined) : scroller - const source: SvifaOptions['subpixel'] = - subpixelKey === '' || typeof latest.current.subpixel === 'string' - ? (latest.current.subpixel as string | undefined) - : () => { - const given = latest.current.subpixel - return typeof given === 'function' - ? given() - : typeof given === 'string' - ? document.querySelectorAll(given) - : (given ?? []) - } - const made = new Svifa({ ...latest.current.feel, scroller: target, axis, subpixel: source, reducedMotion, autoRaf }) + const source = subpixelOf(() => latest.current.subpixel) + const made = new Svifa({ ...feelOf(latest.current), scroller: target, axis, subpixel: source, reducedMotion, autoRaf }) setSvifa(made) return () => { made.destroy() setSvifa(null) } - }, [scroller, axis, subpixelKey, reducedMotion, autoRaf]) + }, [scroller, axis, named, reducedMotion, autoRaf]) useEffect(() => { - svifa?.configure(feel) + svifa?.configure(feelOf(options)) }) return svifa } diff --git a/src/scroller.ts b/src/scroller.ts index 358990e..d3ef883 100644 --- a/src/scroller.ts +++ b/src/scroller.ts @@ -29,6 +29,9 @@ export class Scroller { private restoreBehavior: string | null = null // the scroller's own inline scrollbar-gutter while Svifa holds a scrollbar's room (see holdGutter) private gutter: string | null = null + // while Svifa holds snapping off for a glide: the scroller's own inline scroll-snap-type, and the snapping it held off + private ownSnap: string | null = null + private heldSnap = '' /** The last move was held short of the place written: the scroller cannot go that far (its end is nearer than measured). */ short = false @@ -120,6 +123,23 @@ export class Scroller { return false } + /** Puts the scroller at `place` at once, as written: no grid, no learning (see moveTo for a glide's moves). */ + put(place: number): void { + this.write(place) + } + + /** Runs `read` with the page's own snapping in place, also while Svifa holds it off for a glide, and returns what it returns. */ + withSnap(read: () => T): T { + if (this.ownSnap === null) return read() + const style = this.root.style + style.scrollSnapType = this.ownSnap + try { + return read() + } finally { + style.scrollSnapType = 'none' + } + } + private write(place: number) { const target = this.isPage ? window : this.root const to = this.reversed ? -place : place @@ -156,10 +176,31 @@ export class Scroller { /** Whether the scroller snaps (`scroll-snap-type`): the browser would put every one of Svifa's moves back on a snap point. */ get snaps(): boolean { - const type = getComputedStyle(this.root).scrollSnapType + const type = this.snapType return Boolean(type) && type !== 'none' } + /** The scroller's `scroll-snap-type`: the one in its styles, also while Svifa holds it off for a glide. */ + get snapType(): string { + return this.ownSnap === null ? getComputedStyle(this.root).scrollSnapType : this.heldSnap + } + + /** + * Holds the browser's snapping off while Svifa glides (each frame's position would be put on a snap point) and puts it back when it + * rests, on the snap point the glide was aimed at; false puts it back. + */ + holdSnap(hold: boolean): void { + const style = this.root.style + if (hold && this.ownSnap === null) { + this.heldSnap = getComputedStyle(this.root).scrollSnapType + this.ownSnap = style.scrollSnapType + style.scrollSnapType = 'none' + } else if (!hold && this.ownSnap !== null) { + style.scrollSnapType = this.ownSnap + this.ownSnap = null + } + } + /** Where the scroller's visible area begins on the screen, along its axis (its right edge in right-to-left content). */ get edge(): number { if (this.isPage) return this.reversed ? document.documentElement.clientWidth : 0 @@ -195,6 +236,7 @@ export class Scroller { destroy(): void { if (this.restoreBehavior !== null) this.root.style.scrollBehavior = this.restoreBehavior this.holdGutter(false) + this.holdSnap(false) } } diff --git a/src/snap.ts b/src/snap.ts new file mode 100644 index 0000000..f4c4796 --- /dev/null +++ b/src/snap.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import type { Scroller } from './scroller' +import { SCROLLS, lengthOf } from './target' + +/** + * A place a snapping scroller may rest along one axis, read from the page's CSS as the browser reads it: one position (`from` and + * `to` the same), or a stretch it may rest anywhere in (an area longer than the visible area may be read from end to end). `stop` + * is `scroll-snap-stop: always`: a movement may not pass over it. + */ +export interface Spot { + from: number + to: number + stop: boolean +} + +/** How a scroller snaps: whether every rest must be on a snap point (`mandatory`), and along which axes. */ +export interface SnapType { + strict: boolean + x: boolean + y: boolean +} + +// A proximity snap point within this share of the visible area pulls a rest onto it. Browsers reach 0.3 (Firefox, Safari) or a +// third (Chrome) of it; the farthest of them is taken, so once a glide rests no browser snaps it somewhere else. +const PROXIMITY = 1 / 3 + +// Within this far of a position, in pixels, a rest counts as at it: less than the half pixel a browser rounds to. +const NEAR = 0.5 + +/** + * How a scroller snaps, from its `scroll-snap-type` and writing mode, or null when it does not, or does so in a vertical writing mode + * (where the browser's own snapping stands). + */ +export function snapTypeOf(type: string, root: Element): SnapType | null { + const words = type.split(' ') + if (!type || words[0] === 'none' || getComputedStyle(root).writingMode !== 'horizontal-tb') return null + const axis = words[0] + return { + strict: words[1] === 'mandatory', + x: axis === 'x' || axis === 'inline' || axis === 'both', + y: axis === 'y' || axis === 'block' || axis === 'both', + } +} + +/** + * Every place the scroller may rest along its axis, from the `scroll-snap-align` of the elements it holds (those whose nearest + * scrolling ancestor it is), their `scroll-margin` and its own `scroll-padding`. `drawnOffset` says how far an element is + * drawn from where the browser alone puts it, so a measurement mid-glide is exact. + */ +export function spotsOf(scroller: Scroller, drawnOffset: (element: Element) => number): Spot[] { + const { root, horizontal, reversed } = scroller + const style = getComputedStyle(root) + const viewport = scroller.viewport + const [start, end] = sides(horizontal, reversed) + const padStart = lengthOf(style[`scrollPadding${start}`], viewport) + const port = viewport - padStart - lengthOf(style[`scrollPadding${end}`], viewport) + const limit = scroller.measure() + const position = scroller.position + const edge = scroller.edge + // how far a point on the screen is from the start of the scroller's content along its axis + const along = (point: number, shift: number) => position + (reversed ? edge - point : point - shift - edge) + const spots: Spot[] = [] + + // the elements as the page draws them: each one's children, and those of its open shadow root, where a component draws its own + const visit = (parent: Element | ShadowRoot) => { + for (const element of Array.from(parent.children)) { + const own = getComputedStyle(element) + if (own.display === 'none') continue + if (own.display === 'contents') { + visit(element) + if (element.shadowRoot) visit(element.shadowRoot) + continue + } + const align = alignOf(own.scrollSnapAlign, horizontal) + // an element with no box (a component's child that no slot shows) is not drawn, so it is no snap area + if (align !== 'none' && own.position !== 'fixed' && element.getClientRects().length > 0) { + const box = element.getBoundingClientRect() + const shift = drawnOffset(element) + const areaStart = along(reversed ? box.right : horizontal ? box.left : box.top, shift) - marginOf(own, start) + const areaEnd = along(reversed ? box.left : horizontal ? box.right : box.bottom, shift) + marginOf(own, end) + spots.push(spotOf(areaStart, areaEnd, align, padStart, port, limit, own.scrollSnapStop === 'always')) + } + // what an element of its own scrolls snaps to that element, not to this scroller + if (SCROLLS.includes(own.overflowX) || SCROLLS.includes(own.overflowY)) continue + visit(element) + if (element.shadowRoot) visit(element.shadowRoot) + } + } + visit(root) + return spots +} + +// Where an area puts the scroller: aligned in the snapport (the visible area less scroll-padding) as its align says, or, when the area +// is longer than the snapport, anywhere from its start at the snapport's start to its end at the snapport's end. Kept to what the +// scroller can reach, as the browser keeps it. +function spotOf(start: number, end: number, align: string, padStart: number, port: number, limit: number, stop: boolean): Spot { + const clamp = (position: number) => Math.min(Math.max(position, 0), limit) + if (end - start > port) return { from: clamp(start - padStart), to: clamp(end - padStart - port), stop } + const at = align === 'start' ? start - padStart : align === 'end' ? end - padStart - port : (start + end - port) / 2 - padStart + return { from: clamp(at), to: clamp(at), stop } +} + +// An area's scroll-margin at one edge. +function marginOf(style: CSSStyleDeclaration, edge: 'Top' | 'Bottom' | 'Left' | 'Right') { + return Number.parseFloat(style[`scrollMargin${edge}`]) || 0 +} + +/** The position within `spot` nearest `at`. */ +const within = (spot: Spot, at: number) => Math.min(Math.max(at, spot.from), spot.to) + +/** The place nearest `at` where the scroller may rest, or null when there is none. */ +export function nearest(spots: readonly Spot[], at: number): number | null { + let best: number | null = null + for (const spot of spots) { + const place = within(spot, at) + if (best === null || Math.abs(place - at) < Math.abs(best - at)) best = place + } + return best +} + +/** + * The place nearest `at` among those beyond `from` toward `way` (1 toward the end, -1 toward the start), or null when there is none + * that way. + */ +export function ahead(spots: readonly Spot[], from: number, at: number, way: 1 | -1): number | null { + let best: number | null = null + for (const spot of spots) { + let place = within(spot, at) + if ((place - from) * way <= NEAR) { + // the part of the spot beyond `from`, if it reaches that far + place = way > 0 ? Math.max(spot.from, from + NEAR) : Math.min(spot.to, from - NEAR) + if (place > spot.to || place < spot.from) continue + } + if (best === null || Math.abs(place - at) < Math.abs(best - at)) best = place + } + return best +} + +/** + * Where a movement from `from` to `to` has to stop instead: the first `scroll-snap-stop: always` place passed over on the way, or + * `to` itself when it passes over none. + */ +export function stopOn(spots: readonly Spot[], from: number, to: number): number { + const way = to > from ? 1 : -1 + let stop = to + for (const spot of spots) { + if (!spot.stop) continue + const place = way > 0 ? spot.from : spot.to + if ((place - from) * way > NEAR && (stop - place) * way > NEAR) stop = place + } + return stop +} + +/** + * The places a movement from `from` toward `way` could rest at, nearest first: every snap position beyond it, and for an area it may + * rest anywhere in, the first place of it beyond `from` and its far end. + */ +export function placesAhead(spots: readonly Spot[], from: number, way: 1 | -1): number[] { + const places: number[] = [] + const add = (place: number) => { + const distance = (place - from) * way + if (distance < NEAR || places.some((each) => same(each, place))) return + let at = places.length + while (at > 0 && (places[at - 1] - from) * way > distance) at-- + places.splice(at, 0, place) + } + for (const spot of spots) { + add(spot.from) + add(spot.to) + const first = from + 2 * NEAR * way + if (first > spot.from && first < spot.to) add(first) + } + return places +} + +/** Whether a proximity snap place is near enough `at` to pull a rest onto it. */ +export const inReach = (place: number, at: number, viewport: number): boolean => Math.abs(place - at) <= viewport * PROXIMITY + +/** Whether two positions are the same place to rest at. */ +export const same = (a: number, b: number): boolean => Math.abs(a - b) < NEAR + +// The start and end edges of an axis, as CSS property suffixes: the right edge starts a right-to-left horizontal scroller. +function sides(horizontal: boolean, reversed: boolean) { + if (!horizontal) return ['Top', 'Bottom'] as const + return reversed ? (['Right', 'Left'] as const) : (['Left', 'Right'] as const) +} + +// A scroll-snap-align value's keyword for one axis: the first value is the block axis (up and down here), the second, or the only +// one, the inline axis. +function alignOf(value: string, horizontal: boolean) { + const [block, inline = block] = (value || 'none').split(' ') + return horizontal ? inline : block +} diff --git a/src/solid.ts b/src/solid.ts new file mode 100644 index 0000000..32a533a --- /dev/null +++ b/src/solid.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { Svifa, type SvifaOptions } from '@arcwrightstudio/svifa' +import { + createComponent, + createContext, + createEffect, + createSignal, + on, + onCleanup, + untrack, + useContext, + type Accessor, + type JSX, +} from 'solid-js' +import { feelOf, subpixelKey, subpixelOf } from './adapter' + +/** SmoothScroll's props: Svifa's options (with `scroller` also taking a function) and what renders inside. */ +export type SmoothScrollProps = Omit & { + /** What scrolls: leave it out for the page, or pass an element or a function that returns one (an element set with `ref`). */ + scroller?: Window | Element | (() => Window | Element | null | undefined) + /** What renders inside: anything there reaches the Svifa with `useSvifa`. */ + children?: JSX.Element +} + +const SvifaContext = createContext>(() => null) + +/** + * Smooth scrolling as a component. Render it once, near the root; its props are Svifa's options. Changing `lerp`, `duration`, + * `easing`, `wheel`, `wheelSpeed`, `nested`, `native`, `chain`, `anchors` or `snap` retunes the Svifa it made; changing `scroller`, + * `axis`, `subpixel` (as a selector), `reducedMotion` or `autoRaf` makes it again. Anything inside it reaches the Svifa with + * `useSvifa`. On the server it renders only what it holds. + * + * @example + * {props.children} + */ +export function SmoothScroll(props: SmoothScrollProps): JSX.Element { + const [svifa, setSvifa] = createSignal(null) + const scroller = () => (typeof props.scroller === 'function' ? props.scroller() : props.scroller) + // what it scrolls makes it again (an effect runs only in the browser, once the component is in the page) + createEffect( + on( + [scroller, () => props.axis, () => subpixelKey(props.subpixel), () => props.reducedMotion, () => props.autoRaf], + ([target, axis, , reducedMotion, autoRaf]) => { + if (target === null) return + // a subpixel list or function is read through the props as they are, so a new one each time is no change + const made = untrack( + () => + new Svifa({ + ...feelOf(props), + scroller: target, + axis, + subpixel: subpixelOf(() => props.subpixel), + reducedMotion, + autoRaf, + }) + ) + setSvifa(made) + onCleanup(() => { + made.destroy() + setSvifa(null) + }) + } + ) + ) + // how it feels is retuned in place + createEffect(() => { + const feel = feelOf(props) + svifa()?.configure(feel) + }) + return createComponent(SvifaContext.Provider, { + value: svifa, + get children() { + return props.children + }, + }) +} + +/** + * The Svifa made by the nearest SmoothScroll, as an accessor: null on the server and until it is made. Given `onScroll`, calls it with + * the Svifa on every change of the scroll position, whatever moved it, while the component is in the page. + * + * @example + * const svifa = useSvifa() + * return + */ +export function useSvifa(onScroll?: (svifa: Svifa) => void): Accessor { + const svifa = useContext(SvifaContext) + if (onScroll) { + createEffect(() => { + const current = svifa() + if (current) onCleanup(current.on('scroll', onScroll)) + }) + } + return svifa +} diff --git a/src/svelte.ts b/src/svelte.ts new file mode 100644 index 0000000..0b4e1ce --- /dev/null +++ b/src/svelte.ts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +import { Svifa, type SvifaOptions } from '@arcwrightstudio/svifa' +import { getContext, onMount, setContext } from 'svelte' +import { readable, writable, type Readable } from 'svelte/store' + +/** smoothScroll's options: Svifa's, with `scroller` also taking a function, read once the component is in the page. */ +export type SmoothScrollOptions = Omit & { + /** What scrolls: leave it out for the page, or pass an element or a function that returns one (an element bound with `bind:this`). */ + scroller?: Window | Element | (() => Window | Element | null | undefined) +} + +const SvifaKey = Symbol('svifa') + +/** + * Smooth scrolling for a component and everything inside it: call it while the component starts (in its ` +

{$svifa === null && $seen === null ? 'held' : 'made'}

`, + { filename: 'Root.svelte', generate: 'server' } + ).js.code + // the component compiled for the server, as the Svelte plugin of a build tool hands it on + const svelte: Plugin = { + name: 'svelte', + setup(build) { + build.onResolve({ filter: /^\.\/Root\.svelte$/ }, () => ({ path: 'Root.svelte', namespace: 'svelte' })) + build.onLoad({ filter: /.*/, namespace: 'svelte' }, () => ({ contents: component, resolveDir: TESTS, loader: 'js' })) + }, + } + const html = await render( + 'svelte', + `import { render } from 'svelte/server' + import Root from './Root.svelte' + export default render(Root).body`, + { plugins: [svelte] } + ) + expect(html).toContain('

held

') +}) + +test('Solid: SmoothScroll renders what it holds', async () => { + const html = await render( + 'solid', + `import { createComponent } from 'solid-js' + import { renderToString } from 'solid-js/web' + import { SmoothScroll } from '../src/solid' + export default renderToString(() => createComponent(SmoothScroll, { lerp: 0.08, get children() { return 'held' } }))` + ) + expect(html).toBe('held') +}) diff --git a/tests/snap.spec.ts b/tests/snap.spec.ts new file mode 100644 index 0000000..c9d8563 --- /dev/null +++ b/tests/snap.spec.ts @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// Snapping (snap: true): a scroller with scroll-snap-type glides to the snap points its CSS sets, and rests where the browser itself +// would let it rest. +import type { Page } from '@playwright/test' +import { expect, test } from './fixtures' +import { open, settle } from './setup' + +const SECTIONS = '
'.repeat(8) +const SECTION_CSS = 'section { height: 800px; scroll-snap-align: start; box-sizing: border-box; border-bottom: 1px solid #999; }' + +// Opens eight 800px sections on a page that snaps as `type` says, with Svifa snapping it (window.g). +async function sections(page: Page, type = 'y mandatory', css = '', options = '{}') { + await open(page, SECTIONS, `html { scroll-snap-type: ${type}; } ${SECTION_CSS} ${css}`) + await page.evaluate((options) => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false, snap: true, ...JSON.parse(options) }) + w.frame(w.g) + }, options) +} + +// A movement of the wheel made of `turns` turns of `delta` pixels, a frame apart, as a trackpad sends them; returns where it is heading. +async function turns(page: Page, delta: number, count: number, target = 'window') { + return page.evaluate( + ({ delta, count, target }) => { + const w = window as any + const on = target === 'window' ? window : document.querySelector(target) + for (let i = 0; i < count; i++) { + w.wheel(on, delta) + w.frame(w.g) + } + return w.g.target + }, + { delta, count, target } + ) +} + +// Where the scroller is a moment after the glide rests, once the browser has had time to snap it itself. +async function restingY(page: Page) { + await page.waitForTimeout(150) + return page.evaluate(() => window.scrollY) +} + +test.describe('snapping', () => { + test('a notch glides to the next snap point, with the browser snapping held off until it rests there', async ({ page }) => { + await sections(page) + const start = await page.evaluate(() => { + const w = window as any + const prevented = w.wheel(window, 100) + return { prevented, target: w.g.target, held: document.documentElement.style.scrollSnapType } + }) + expect(start).toEqual({ prevented: true, target: 800, held: 'none' }) + const path = await settle(page) + expect(path.length).toBeGreaterThan(20) + for (let i = 1; i < path.length; i++) expect(path[i]).toBeGreaterThanOrEqual(path[i - 1]) + expect(path.at(-1)).toBe(800) + expect(await page.evaluate(() => document.documentElement.style.scrollSnapType)).toBe('') + expect(await restingY(page)).toBe(800) + }) + + test("puts the page's own inline scroll-snap-type back when it rests, and when it is taken off mid-glide", async ({ page }) => { + await open(page, SECTIONS, SECTION_CSS) + const result = await page.evaluate(() => { + const w = window as any + const root = document.documentElement + root.style.scrollSnapType = 'y mandatory' + w.g = new w.Svifa({ autoRaf: false, snap: true }) + w.frame(w.g) + w.wheel(window, 100) + const during = root.style.scrollSnapType + while (w.g.isGliding) w.frame(w.g) + const rested = root.style.scrollSnapType + w.wheel(window, 100) + w.frame(w.g, 60, 3) + w.g.destroy() + return { during, rested, destroyed: root.style.scrollSnapType } + }) + expect(result).toEqual({ during: 'none', rested: 'y mandatory', destroyed: 'y mandatory' }) + }) + + test('a trackpad swipe moves one snap point; turns after a pause each move one more', async ({ page }) => { + await sections(page) + expect(await turns(page, 6, 40)).toBe(800) + expect((await settle(page)).at(-1)).toBe(800) + for (const expected of [1600, 2400]) { + await page.waitForTimeout(200) + expect(await turns(page, 100, 1)).toBe(expected) + await page.evaluate(() => (window as any).frame((window as any).g, 60, 4)) + } + await page.waitForTimeout(200) + expect(await turns(page, -100, 1)).toBe(1600) + expect((await settle(page)).at(-1)).toBe(1600) + }) + + test('a long movement goes as far as the wheel would, and rests on the snap point nearest', async ({ page }) => { + await sections(page) + // the first turn goes on to 800, and the movement carries on from there: 800 + 1500 is nearest 2400 + expect(await turns(page, 100, 16)).toBe(2400) + expect((await settle(page)).at(-1)).toBe(2400) + expect(await restingY(page)).toBe(2400) + }) + + test('with proximity, a small turn moves on to the next point, and one far from any point rests where the wheel takes it', async ({ + page, + }) => { + await sections(page, 'y proximity') + expect(await turns(page, 100, 1)).toBe(800) + await settle(page) + expect(await restingY(page)).toBe(800) + await page.waitForTimeout(200) + // 800 + 1150 = 1950: more than a third of the visible area from 1600 and 2400, so no browser snaps it there + expect(await turns(page, 50, 23)).toBe(1950) + await settle(page) + expect(await restingY(page)).toBe(1950) + }) + + test('never passes over a scroll-snap-stop: always point', async ({ page }) => { + await sections(page, 'y mandatory', 'section:nth-child(3) { scroll-snap-stop: always; }') + expect(await turns(page, 100, 40)).toBe(1600) + expect((await settle(page)).at(-1)).toBe(1600) + }) + + test('reads an area longer than the visible area from end to end where the browser lets it, then goes on', async ({ page }) => { + await sections(page, 'y mandatory', 'section:nth-child(2) { height: 2000px; }') + // section 2 runs from 800 to 2800, so the visible area may rest anywhere from 800 to 2000 inside it, where the browser allows that + // (one that rests only on aligned edges goes on to the next section instead) + const inside = await page.evaluate(() => { + window.scrollTo({ top: 900, behavior: 'instant' }) + const kept = window.scrollY === 900 + ;(window as any).g.scrollTo(800, { immediate: true }) + return kept + }) + await page.waitForTimeout(200) + const first = inside ? 900 : 2800 + expect(await turns(page, 100, 1)).toBe(first) + await settle(page) + expect(await restingY(page)).toBe(first) + await page.evaluate(() => (window as any).g.scrollTo(2000, { immediate: true })) + await page.waitForTimeout(200) + expect(await turns(page, 100, 1)).toBe(2800) + await settle(page) + expect(await restingY(page)).toBe(2800) + }) + + // mixed alignments, scroll-padding and scroll-margin, a snap area inside display: contents, and one that belongs to a scroller of its + // own (it snaps there, not on the page) + const MIXED = `
+
+
+
+
+
+
+
+
` + const MIXED_CSS = 'html { scroll-snap-type: y mandatory; scroll-padding: 60px 0 30px; }' + + test('scrollTo rests exactly where the browser itself would snap, whatever the alignment, padding and margins', async ({ + page, + }) => { + await open(page, `${MIXED}
`, MIXED_CSS) + const results = await page.evaluate(() => { + const w = window as any + const g = new w.Svifa({ autoRaf: false, snap: true }) + const root = document.documentElement + const limit = root.scrollHeight - root.clientHeight + const seen: { at: number; svifa: number; browser: number }[] = [] + for (let at = 0; at <= limit; at += 37) { + // the browser's own: written with its snapping on, it puts the scroller on the snap position it picks + window.scrollTo({ top: at, behavior: 'instant' }) + const browser = window.scrollY + root.style.scrollSnapType = 'none' + window.scrollTo({ top: 0, behavior: 'instant' }) + root.style.scrollSnapType = '' + // Svifa's own, as the place it aims at: moved there at once, the browser would snap a wrong place to its own + g.scrollTo(at) + const svifa = g.isGliding ? g.target : window.scrollY + g.stop() + g.start() + seen.push({ at, svifa, browser }) + } + return seen + }) + expect(results.filter(({ svifa, browser }) => Math.abs(svifa - browser) >= 0.5)).toEqual([]) + }) + + // Every place the browser itself rests the page at, read pixel by pixel; then single notches from the top, each after a pause, must + // visit exactly those places in order, and the browser must leave each rest where it is. + async function visitsEveryPlace(page: Page) { + const places = await page.evaluate(() => { + const root = document.documentElement + const found: number[] = [] + for (let at = 0; at <= root.scrollHeight - root.clientHeight; at++) { + window.scrollTo({ top: at, behavior: 'instant' }) + if (!found.includes(window.scrollY)) found.push(window.scrollY) + } + window.scrollTo({ top: 0, behavior: 'instant' }) + return found.sort((a, b) => a - b) + }) + expect(places.length).toBeGreaterThan(3) + await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false, snap: true }) + w.frame(w.g) + }) + const visited = [await page.evaluate(() => window.scrollY)] + while (visited.length <= places.length) { + await page.waitForTimeout(150) + await turns(page, 100, 1) + await settle(page) + const at = await restingY(page) + if (at === visited.at(-1)) break + visited.push(at) + } + expect(visited).toEqual(places) + } + + test('one notch at a time visits every snap point in order, and the browser leaves each rest as it is', async ({ page }) => { + await open(page, MIXED, MIXED_CSS) + await visitsEveryPlace(page) + }) + + test('reads the snap points inside a component, and leaves out a child no slot shows', async ({ page }) => { + await open( + page, + `
+
not shown
+
+
`, + 'html { scroll-snap-type: y mandatory; }' + ) + await page.evaluate(() => { + customElements.define( + 'x-panels', + class extends HTMLElement { + constructor() { + super() + this.attachShadow({ mode: 'open' }).innerHTML = + '
' + } + } + ) + }) + await visitsEveryPlace(page) + }) + + test('scrollTo and same-page links rest on the nearest snap point; with proximity, only within reach', async ({ page }) => { + await sections(page, 'y mandatory', '', '{"anchors":true}') + const mandatory = await page.evaluate(async () => { + const w = window as any + const going = w.g.scrollTo(1250) + const target = w.g.target + while (w.g.isGliding) w.frame(w.g) + return { target, arrived: await going, y: window.scrollY } + }) + expect(mandatory).toEqual({ target: 1600, arrived: true, y: 1600 }) + + await sections(page, 'y proximity') + const proximity = await page.evaluate(() => { + const w = window as any + w.g.scrollTo(1250, { immediate: true }) + const far = window.scrollY + w.g.scrollTo(1500, { immediate: true }) + return { far, near: window.scrollY } + }) + expect(proximity).toEqual({ far: 1250, near: 1600 }) + }) + + test('a horizontal carousel moves one item a notch, and hands the wheel to the page at its last item', async ({ page }) => { + await open( + page, + `
${'
'.repeat(4)}
` + ) + const result = await page.evaluate(() => { + const w = window as any + const track = document.getElementById('track') as HTMLElement + w.g = new w.Svifa({ scroller: track, axis: 'horizontal', autoRaf: false, snap: true }) + w.frame(w.g) + w.wheel(track, 100) + return w.g.target + }) + expect(result).toBe(600) + expect((await settle(page)).at(-1)).toBe(600) + const last = await page.evaluate(() => { + const w = window as any + w.g.scrollTo('end', { immediate: true }) + return { at: w.g.scroll, prevented: w.wheel(document.getElementById('track'), 100) } + }) + expect(last).toEqual({ at: 1800, prevented: false }) + }) + + test('a right-to-left carousel snaps from its own start', async ({ page }) => { + await open( + page, + `
${'
'.repeat(4)}
` + ) + const result = await page.evaluate(() => { + const w = window as any + const track = document.getElementById('track') as HTMLElement + w.g = new w.Svifa({ scroller: track, axis: 'horizontal', autoRaf: false, snap: true }) + w.frame(w.g) + w.wheel(track, 100) + return w.g.target + }) + expect(result).toBe(600) + await settle(page) + expect(await page.evaluate(() => document.getElementById('track')!.scrollLeft)).toBe(-600) + }) + + test('both ways, each axis snaps on its own', async ({ page }) => { + await open( + page, + `
+
${'
'.repeat(16)}
+
` + ) + const result = await page.evaluate(() => { + const w = window as any + const board = document.getElementById('board') as HTMLElement + w.g = new w.Svifa({ scroller: board, axis: 'both', autoRaf: false, snap: true }) + w.frame(w.g) + w.wheel(board, 60, { deltaX: 40 }) + return { x: w.g.x.target, y: w.g.y.target } + }) + expect(result).toEqual({ x: 500, y: 400 }) + await settle(page) + expect( + await page.evaluate(() => [document.getElementById('board')!.scrollLeft, document.getElementById('board')!.scrollTop]) + ).toEqual([500, 400]) + }) + + test('a key taking over mid-glide gives the scroller back to the browser, which snaps it', async ({ page }) => { + await sections(page) + const result = await page.evaluate(() => { + const w = window as any + w.wheel(window, 100) + w.frame(w.g, 60, 4) + document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })) + return { gliding: w.g.isGliding, style: document.documentElement.style.scrollSnapType } + }) + expect(result).toEqual({ gliding: false, style: '' }) + expect([0, 800]).toContain(await restingY(page)) + }) + + test('off by default, and switched on and off with configure', async ({ page }) => { + await open(page, SECTIONS, `html { scroll-snap-type: y mandatory; } ${SECTION_CSS}`) + const result = await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false }) + w.frame(w.g) + const off = w.wheel(window, 100) + w.g.configure({ snap: true }) + const on = w.wheel(window, 100) + const option = w.g.options.snap + while (w.g.isGliding) w.frame(w.g) + w.g.configure({ snap: false }) + return { off, on, option, y: window.scrollY, again: w.wheel(window, 100), after: w.g.options.snap } + }) + expect(result).toEqual({ off: false, on: true, option: true, y: 800, again: false, after: false }) + }) + + test('a scroller in a vertical writing mode is left to the browser', async ({ page }) => { + await open(page, SECTIONS, `html { scroll-snap-type: y mandatory; writing-mode: vertical-lr; } ${SECTION_CSS}`) + const prevented = await page.evaluate(() => { + const w = window as any + w.g = new w.Svifa({ autoRaf: false, snap: true }) + return w.wheel(window, 100) + }) + expect(prevented).toBe(false) + }) +}) diff --git a/tests/solid.spec.ts b/tests/solid.spec.ts new file mode 100644 index 0000000..cf35ebd --- /dev/null +++ b/tests/solid.spec.ts @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// The Solid entry, in a real Solid app. +import type { Page } from '@playwright/test' +import { glide, mountApp, otherVersion } from './app' +import { expect, test } from './fixtures' + +const APP = ` +import { createComponent, createEffect, createSignal } from 'solid-js' +import { render } from 'solid-js/web' +import { SmoothScroll, useSvifa } from '../src/solid' + +const w = window as any +w.reports = 0 + +function Probe() { + const svifa = useSvifa(() => w.reports++) + createEffect(() => (w.probe = svifa())) + return null +} + +const [lerp, setLerp] = createSignal(0.1) +const [duration, setDuration] = createSignal(undefined) +const [axis, setAxis] = createSignal('vertical') +const [on, setOn] = createSignal(true) +Object.assign(w, { setLerp, setDuration, setAxis, setOn }) + +function Page() { + return () => + on() + ? createComponent(SmoothScroll, { + get lerp() { return lerp() }, + get duration() { return duration() }, + get axis() { return axis() as any }, + autoRaf: false, + get children() { return createComponent(Probe, {}) }, + }) + : null +} + +function Box() { + const box = document.createElement('div') + box.id = 'box' + box.style.cssText = 'height: 300px; overflow: auto' + box.append(Object.assign(document.createElement('div'), { style: 'height: 3000px' })) + return [box, createComponent(SmoothScroll, { scroller: () => box, autoRaf: false, get children() { return createComponent(Probe, {}) } })] +} + +render(() => createComponent(w.which === 'box' ? Box : Page, {}), document.getElementById('root')!) +` + +const mount = (page: Page, which: 'page' | 'box') => + mountApp(page, 'solid', APP, { globals: { which }, esbuild: { plugins: otherVersion('solid', ['solid-js']) } }) + +test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scrolls changes, and takes it off when it leaves', async ({ + page, +}) => { + const errors = await mount(page, 'page') + const first = await page.evaluate(() => { + const w = window as any + w.made = w.probe + const prevented = w.wheel(window, 200) + return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + }) + expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) + await glide(page) + expect(await page.evaluate(() => [window.scrollY, (window as any).reports > 0])).toEqual([200, true]) + + await page.evaluate(() => (window as any).setLerp(0.3)) + await page.waitForFunction(() => (window as any).probe.options.lerp === 0.3) + await page.evaluate(() => (window as any).setDuration(1.5)) + await page.waitForFunction(() => (window as any).probe.options.duration === 1.5) + await page.evaluate(() => (window as any).setDuration(undefined)) + await page.waitForFunction(() => (window as any).probe.options.duration === undefined) + expect(await page.evaluate(() => (window as any).probe === (window as any).made)).toBe(true) + + await page.evaluate(() => (window as any).setAxis('horizontal')) + await page.waitForFunction(() => (window as any).probe?.axis === 'horizontal') + expect(await page.evaluate(() => (window as any).probe !== (window as any).made)).toBe(true) + + await page.evaluate(() => (window as any).setOn(false)) + await page.waitForFunction(() => document.documentElement.className === '') + expect(errors).toEqual([]) +}) + +test('SmoothScroll takes a function that returns a scrolling element as its scroller', async ({ page }) => { + const errors = await mount(page, 'box') + const result = await page.evaluate(() => { + const w = window as any + return { element: w.probe.element.id, prevented: w.wheel(document.getElementById('box'), 150) } + }) + expect(result).toEqual({ element: 'box', prevented: true }) + await glide(page) + expect(await page.evaluate(() => [document.getElementById('box')!.scrollTop, document.documentElement.className])).toEqual([ + 150, + '', + ]) + expect(errors).toEqual([]) +}) diff --git a/tests/svelte.spec.ts b/tests/svelte.spec.ts new file mode 100644 index 0000000..2517226 --- /dev/null +++ b/tests/svelte.spec.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// The Svelte entry, in a real Svelte app (5, or 4 with SVIFA_SVELTE_DIR), its components compiled here by that Svelte's own compiler. +// They are written as both Svelte 4 and 5 read them. +import { createRequire } from 'node:module' +import { join } from 'node:path' +import type { Page } from '@playwright/test' +import type { Plugin } from 'esbuild' +import { glide, mountApp, otherDir, otherVersion } from './app' +import { expect, test } from './fixtures' + +const other = otherDir('svelte') +const require = createRequire(other ? join(other, 'here.js') : import.meta.url) +const { compile, VERSION } = require('svelte/compiler') as typeof import('svelte/compiler') +const svelte5 = Number(VERSION.split('.')[0]) >= 5 + +const COMPONENTS: Record = { + 'Probe.svelte': ``, + 'Alone.svelte': ``, + 'Page.svelte': ` + `, + 'Box.svelte': ` +
+ `, +} + +// compiles the components above as the Svelte plugin of a build tool would +const svelte: Plugin = { + name: 'svelte', + setup(build) { + build.onResolve({ filter: /\.svelte$/ }, (args) => ({ path: args.path.replace('./', ''), namespace: 'svelte' })) + build.onLoad({ filter: /.*/, namespace: 'svelte' }, (args) => ({ + contents: compile(COMPONENTS[args.path], { + filename: args.path, + generate: (svelte5 ? 'client' : 'dom') as 'client', + dev: true, + }).js.code, + resolveDir: new URL('.', import.meta.url).pathname.replace(/^\/(\w:)/, '$1'), + loader: 'js', + })) + }, +} + +const APP = ` +import * as svelte from 'svelte' +import Page from './Page.svelte' +import Box from './Box.svelte' +import Alone from './Alone.svelte' + +const w = window as any +w.reports = 0 +const Root: any = { page: Page, box: Box, alone: Alone }[w.which as 'page' | 'box' | 'alone'] +const target = document.getElementById('root')! +// Svelte 5 mounts a component with mount(); Svelte 4 makes it with new +const app = 'mount' in svelte ? (svelte as any).mount(Root, { target }) : new Root({ target }) +w.leave = () => ('unmount' in svelte ? (svelte as any).unmount(app) : app.$destroy()) +` + +const mountSvelte = (page: Page, which: 'page' | 'box' | 'alone') => + mountApp(page, 'svelte', APP, { + globals: { which }, + esbuild: { plugins: [...otherVersion('svelte', ['svelte']), svelte], conditions: ['browser', 'development'] }, + }) + +test('smoothScroll makes one Svifa that components inside reach, and takes it off when its component leaves', async ({ page }) => { + const errors = await mountSvelte(page, 'page') + const first = await page.evaluate(() => { + const w = window as any + let shared = null + w.pageStore.subscribe((svifa: unknown) => (shared = svifa))() + const prevented = w.wheel(window, 200) + return { + same: shared === w.probe, + classes: document.documentElement.className, + prevented, + lerp: w.probe.options.lerp, + } + }) + expect(first).toEqual({ same: true, classes: 'svifa svifa-gliding', prevented: true, lerp: 0.12 }) + await glide(page) + expect(await page.evaluate(() => [window.scrollY, (window as any).reports > 0])).toEqual([200, true]) + + await page.evaluate(() => (window as any).leave()) + await page.waitForFunction(() => document.documentElement.className === '') + expect(errors).toEqual([]) +}) + +test('smoothScroll takes a function that returns an element bound with bind:this as its scroller', async ({ page }) => { + const errors = await mountSvelte(page, 'box') + const result = await page.evaluate(() => { + const w = window as any + return { element: w.probe.element.id, prevented: w.wheel(document.getElementById('box'), 150) } + }) + expect(result).toEqual({ element: 'box', prevented: true }) + await glide(page) + expect(await page.evaluate(() => [document.getElementById('box')!.scrollTop, document.documentElement.className])).toEqual([ + 150, + '', + ]) + expect(errors).toEqual([]) +}) + +test('useSvifa with no smoothScroll around it is a store of null', async ({ page }) => { + const errors = await mountSvelte(page, 'alone') + const value = await page.evaluate(() => { + let seen: unknown = 'unset' + ;(window as any).alone.subscribe((svifa: unknown) => (seen = svifa))() + return seen + }) + expect(value).toBeNull() + expect(errors).toEqual([]) +}) diff --git a/tests/tsconfig.json b/tests/tsconfig.json index c6ce64a..27ff404 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -7,5 +7,5 @@ "isolatedDeclarations": false, "declaration": false }, - "include": [".", "../playwright.config.ts", "../tsdown.config.ts"] + "include": [".", "../playwright.config.ts", "../tsdown.config.mts"] } diff --git a/tests/vue.spec.ts b/tests/vue.spec.ts new file mode 100644 index 0000000..46ff652 --- /dev/null +++ b/tests/vue.spec.ts @@ -0,0 +1,131 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Arcwright Studio + +// The Vue entry, in a real Vue 3 app. +import type { Page } from '@playwright/test' +import { glide, mountApp, otherVersion } from './app' +import { expect, test } from './fixtures' + +const APP = ` +import { createApp, defineComponent, h, ref, watchEffect } from 'vue' +import { SmoothScroll, useSvifa } from '../src/vue' + +const w = window as any +w.reports = 0 + +const Probe = defineComponent(() => { + const svifa = useSvifa(() => w.reports++) + watchEffect(() => (w.probe = svifa.value)) + return () => null +}) + +const lerp = ref(0.1) +const duration = ref(undefined) +const axis = ref('vertical') +const on = ref(true) +Object.assign(w, { + setLerp: (value: number) => (lerp.value = value), + setDuration: (value: number | undefined) => (duration.value = value), + setAxis: (value: string) => (axis.value = value), + setOn: (value: boolean) => (on.value = value), +}) + +const Page = defineComponent(() => () => + on.value ? h(SmoothScroll, { lerp: lerp.value, duration: duration.value, axis: axis.value, autoRaf: false }, () => h(Probe)) : null +) + +const Box = defineComponent(() => { + const box = ref(null) + return () => + h('div', null, [ + h('div', { id: 'box', ref: box, style: 'height: 300px; overflow: auto' }, h('div', { style: 'height: 3000px' })), + h(SmoothScroll, { scroller: box, autoRaf: false }, () => h(Probe)), + ]) +}) + +// subpixel as a list of elements +const Drawn = defineComponent(() => () => h(SmoothScroll, { subpixel: [document.querySelector('.tall')!], autoRaf: false }, () => h(Probe))) + +// a flag written alone in a template arrives as an empty string +const Flags = defineComponent(() => () => h(SmoothScroll, { anchors: '', autoRaf: false }, () => h(Probe))) + +createApp({ Page, Box, Flags, Drawn }[w.which as 'Page' | 'Box' | 'Flags' | 'Drawn']).mount('#root') +` + +const VUE = { + plugins: otherVersion('vue', ['vue']), + define: { __VUE_OPTIONS_API__: 'true', __VUE_PROD_DEVTOOLS__: 'false', __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false' }, +} +const mount = (page: Page, which: 'Page' | 'Box' | 'Flags' | 'Drawn') => + mountApp(page, 'vue', APP, { globals: { which }, esbuild: VUE }) + +test('SmoothScroll makes one Svifa, retunes it, makes it again when what it scrolls changes, and takes it off when it leaves', async ({ + page, +}) => { + const errors = await mount(page, 'Page') + const first = await page.evaluate(() => { + const w = window as any + w.made = w.probe + const prevented = w.wheel(window, 200) + return { classes: document.documentElement.className, prevented, lerp: w.made.options.lerp } + }) + expect(first).toEqual({ classes: 'svifa svifa-gliding', prevented: true, lerp: 0.1 }) + await glide(page) + expect(await page.evaluate(() => [window.scrollY, (window as any).reports > 0])).toEqual([200, true]) + + await page.evaluate(() => (window as any).setLerp(0.3)) + await page.waitForFunction(() => (window as any).probe.options.lerp === 0.3) + // a prop taken off goes back to its default + await page.evaluate(() => (window as any).setDuration(1.5)) + await page.waitForFunction(() => (window as any).probe.options.duration === 1.5) + await page.evaluate(() => (window as any).setDuration(undefined)) + await page.waitForFunction(() => (window as any).probe.options.duration === undefined) + expect(await page.evaluate(() => (window as any).probe === (window as any).made)).toBe(true) + + await page.evaluate(() => (window as any).setAxis('horizontal')) + await page.waitForFunction(() => (window as any).probe?.axis === 'horizontal') + expect(await page.evaluate(() => (window as any).probe !== (window as any).made)).toBe(true) + + await page.evaluate(() => (window as any).setOn(false)) + await page.waitForFunction(() => document.documentElement.className === '') + expect(errors).toEqual([]) +}) + +test('SmoothScroll takes a template ref to a scrolling element as its scroller', async ({ page }) => { + const errors = await mount(page, 'Box') + const result = await page.evaluate(() => { + const w = window as any + return { element: w.probe.element.id, prevented: w.wheel(document.getElementById('box'), 150) } + }) + expect(result).toEqual({ element: 'box', prevented: true }) + await glide(page) + expect(await page.evaluate(() => [document.getElementById('box')!.scrollTop, document.documentElement.className])).toEqual([ + 150, + '', + ]) + expect(errors).toEqual([]) +}) + +test('a flag written alone is on, and one left out keeps its default', async ({ page }) => { + const errors = await mount(page, 'Flags') + const options = await page.evaluate(() => { + const { anchors, wheel, nested, chain, reducedMotion, snap } = (window as any).probe.options + return { anchors, wheel, nested, chain, reducedMotion, snap } + }) + expect(options).toEqual({ anchors: true, wheel: true, nested: true, chain: true, reducedMotion: true, snap: false }) + expect(errors).toEqual([]) +}) + +test('SmoothScroll draws the elements of a subpixel list between pixels', async ({ page }) => { + const errors = await mount(page, 'Drawn') + const drawn = await page.evaluate(() => { + const w = window as any + w.probe.raf(1000) + w.wheel(window, 101) + w.probe.raf(1016) + w.probe.raf(1032) + return (document.querySelector('.tall') as HTMLElement).style.translate + }) + expect(drawn).not.toBe('') + expect(errors).toEqual([]) +}) diff --git a/tsdown.config.ts b/tsdown.config.mts similarity index 51% rename from tsdown.config.ts rename to tsdown.config.mts index 3a632ac..3b8fbc8 100644 --- a/tsdown.config.ts +++ b/tsdown.config.mts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Arcwright Studio -import { readFileSync } from 'node:fs' +import { readFileSync, writeFileSync } from 'node:fs' import { defineConfig, type UserConfig } from 'tsdown' const { version } = JSON.parse(readFileSync('./package.json', 'utf8')) as { version: string } @@ -14,11 +14,25 @@ const shared: UserConfig = { define: { __VERSION__: JSON.stringify(version) }, // each build writes its own files into dist/, which the build script empties first clean: false, + // An ES module is a .js file and a CommonJS module a .cjs file, as since 1.0.0. The package itself is "type": "commonjs": Yarn's + // Plug'n'Play turns on its experimental ES module loader, and says so, for every project that installs a package marked + // "type": "module". dist/package.json marks what is built as ES modules for Node instead. + outExtensions: ({ format }) => + format === 'es' ? { js: '.js', dts: '.d.ts' } : format === 'cjs' ? { js: '.cjs', dts: '.d.cts' } : undefined, } // Declarations are written from the source alone (it states every exported type: isolatedDeclarations in tsconfig.json). const dts = { generator: 'oxc' } as const +// The entry for each framework, and the modules of that framework it imports. +const FRAMEWORKS: Record = { + react: /^react(\/|$)/, + vue: /^vue(\/|$)/, + svelte: /^svelte(\/|$)/, + solid: /^solid-js(\/|$)/, + angular: /^@angular\//, +} + export default defineConfig([ // for bundlers and Node: an ES module and a CommonJS module, each with its types (the CommonJS module carries the class both as // Svifa and as default, as the ES module does) @@ -29,18 +43,25 @@ export default defineConfig([ platform: 'neutral', dts, outputOptions: (options, format) => (format === 'cjs' ? { ...options, exports: 'named' } : options), + hooks: { 'build:done': () => writeFileSync('dist/package.json', '{\n "type": "module"\n}\n') }, }, - // the React entry: the core stays one import of '@arcwrightstudio/svifa', so a project holds one Svifa class whichever entry it - // uses; and it is a client component for frameworks with server components (Next.js) - { + // Each framework's entry: the core stays out of it and is imported from the file beside it, the one the package's own export names + // (./svifa.js, ./svifa.cjs), so a project holds one Svifa class whichever entry it uses and no lookup of the package by its name is + // needed; the framework is the project's own. The React entry is a client component for frameworks with server components + // (Next.js). + ...Object.entries(FRAMEWORKS).map(([name, framework]): UserConfig => ({ ...shared, - entry: { react: 'src/react.ts' }, + entry: { [name]: `src/${name}.ts` }, format: ['esm', 'cjs'], platform: 'neutral', dts, - deps: { neverBundle: ['react', '@arcwrightstudio/svifa'] }, - banner: { js: "'use client'" }, - }, + deps: { neverBundle: [framework, '@arcwrightstudio/svifa'] }, + outputOptions: (options, format) => ({ + ...options, + paths: { '@arcwrightstudio/svifa': format === 'cjs' ? './svifa.cjs' : './svifa.js' }, + }), + banner: name === 'react' ? { js: "'use client'" } : undefined, + })), // for a plain