@vit-foundation/ui 0.28.0 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +51 -7
- package/dist/components/scrolly/CrossfadeVideo.svelte +246 -0
- package/dist/components/scrolly/CrossfadeVideo.svelte.d.ts +85 -0
- package/dist/components/scrolly/GlassCard.svelte +39 -0
- package/dist/components/scrolly/GlassCard.svelte.d.ts +23 -0
- package/dist/components/scrolly/ScrollyStepIndicator.svelte +93 -0
- package/dist/components/scrolly/ScrollyStepIndicator.svelte.d.ts +35 -0
- package/dist/components/scrolly/ScrollySteps.svelte +112 -0
- package/dist/components/scrolly/ScrollySteps.svelte.d.ts +87 -0
- package/dist/components/scrolly/stepStyle.d.ts +53 -0
- package/dist/components/scrolly/stepStyle.js +64 -0
- package/dist/components/scrolly/svelte-scroller.d.ts +23 -0
- package/dist/scrolly.d.ts +15 -0
- package/dist/scrolly.js +14 -0
- package/dist/styles/tokens.css +14 -0
- package/package.json +15 -3
package/README.md
CHANGED
|
@@ -9,7 +9,10 @@ content editable in place.
|
|
|
9
9
|
npm install @vit-foundation/ui
|
|
10
10
|
```
|
|
11
11
|
|
|
12
|
-
Requires `svelte` ^5.0.0 as a peer.
|
|
12
|
+
Requires `svelte` ^5.0.0 as a peer. `ScrollySteps`, alone in the package,
|
|
13
|
+
also needs the optional peer `@sveltejs/svelte-scroller` — see
|
|
14
|
+
[scrolly](./docs/components/scrolly.md#installing-the-peer). Import the two
|
|
15
|
+
stylesheets once:
|
|
13
16
|
|
|
14
17
|
```svelte
|
|
15
18
|
<script>
|
|
@@ -29,6 +32,8 @@ Everything exports flat from the root, and again grouped by role:
|
|
|
29
32
|
| [`/chrome`](./docs/components/chrome.md) | PageShell, Nav, Footer |
|
|
30
33
|
| [`/content`](./docs/components/content.md) | WeeklieCard, ProjectCard, Timeline, TimelineMilestone, TeamMemberCard, CollaboratorList, JobList, SortSelect, the nine page modules (HomePage … WeeklyPage) — plus the data shapes, the page-copy vocabulary, helpers and the two list rules (createWeeklyList, createUrlFilters) |
|
|
31
34
|
| [`/community`](./docs/components/community.md) | AuthPageShell, LoginForm, SignupForm, GoogleAuthForm, AccountPanel, NewsletterSignup, CommentSection, ReactionBar, ContactForm |
|
|
35
|
+
| [`/admin`](./docs/components/admin.md) | DecorMosaic, PageHeading, Sidebar — shell-level composition for the foundation's internal tools |
|
|
36
|
+
| [`/scrolly`](./docs/components/scrolly.md) | ScrollySteps, ScrollyStepIndicator, CrossfadeVideo, GlassCard, the stepStyle ramp — the only entry point that does NOT re-export from the root, and the only one with a peer of its own |
|
|
32
37
|
| [`/edit`](./docs/edit-mode.md) | Editable, setEditAdapter(adapter, EDIT_CHROME)/getEditAdapter, descriptors and helpers, collectionEditing, LocalizedText |
|
|
33
38
|
| [`/config`](./docs/getting-started.md#wiring-an-app-uiprovider) | UiProvider, UiConfig, the locale set, the default Catalan messages |
|
|
34
39
|
| `/contract` | The component-free half: LOCALES, BASE_LOCALE, localize, REACTIONS, PAGE_COPY_KEYS and the edit-descriptor types — the one subpath a host may import from SERVER code |
|
|
@@ -50,7 +55,11 @@ integrates its i18n and router through one `UiProvider` in the root layout.
|
|
|
50
55
|
- **Component reference** — [primitives](./docs/components/primitives.md) ·
|
|
51
56
|
[chrome](./docs/components/chrome.md) ·
|
|
52
57
|
[content](./docs/components/content.md) ·
|
|
53
|
-
[community](./docs/components/community.md)
|
|
58
|
+
[community](./docs/components/community.md) ·
|
|
59
|
+
[admin](./docs/components/admin.md) ·
|
|
60
|
+
[scrolly](./docs/components/scrolly.md)
|
|
61
|
+
- **[Changelog](./docs/changelog/index.md)** — one page per version, newest
|
|
62
|
+
first; [unreleased](./docs/changelog/unreleased.md) is what is on `main`
|
|
54
63
|
- **Storybook** — `npm run storybook`: every component has a story;
|
|
55
64
|
`Edit mode/EditMode` demos the whole editing loop against an in-memory
|
|
56
65
|
adapter
|
|
@@ -84,8 +93,43 @@ diffable against history. The public structure is the semantic entry points
|
|
|
84
93
|
above, assembled in `src/lib/{primitives,chrome,content-components,community}.ts`
|
|
85
94
|
and `src/lib/forms/index.ts`.
|
|
86
95
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
96
|
+
## Cutting a release
|
|
97
|
+
|
|
98
|
+
Releases are MANUAL. The workflow this section used to describe fired on every
|
|
99
|
+
`v*` tag, had no `NPM_TOKEN` and no lint, check or test step, so it failed
|
|
100
|
+
every time and was deleted; `prepublishOnly` (lint, check, unit tests) is the
|
|
101
|
+
only publish gate now.
|
|
102
|
+
|
|
103
|
+
**While you work**, add the entry to
|
|
104
|
+
[`docs/changelog/unreleased.md`](./docs/changelog/unreleased.md) in the same PR
|
|
105
|
+
as the change, under one of Keep a Changelog's types — `Added`, `Changed`,
|
|
106
|
+
`Deprecated`, `Removed`, `Fixed`, `Security` — or
|
|
107
|
+
`Other (dependencies, CI, tools…)` for what a consumer never sees. One fact,
|
|
108
|
+
one entry: if a type already covers the thing you changed, edit that entry so
|
|
109
|
+
it describes the end state. A change a consumer must act on before upgrading
|
|
110
|
+
carries an **Upgrading:** paragraph.
|
|
111
|
+
|
|
112
|
+
**On release day**, in one commit (`chore(release): x.y.z`):
|
|
113
|
+
|
|
114
|
+
1. Read `unreleased.md` once as a whole and merge what the individual PRs
|
|
115
|
+
restated.
|
|
116
|
+
2. `git mv docs/changelog/unreleased.md docs/changelog/x.y.z.md`. Retitle it
|
|
117
|
+
`# x.y.z`, date it, and point its compare link at the tag range rather than
|
|
118
|
+
at `main`.
|
|
119
|
+
3. Write a fresh `unreleased.md` with the same header and no entries — copy the
|
|
120
|
+
one you just renamed rather than inventing a new shape.
|
|
121
|
+
4. Add the row to [`docs/changelog/index.md`](./docs/changelog/index.md).
|
|
122
|
+
5. `npm version x.y.z` — MINOR for new exports and features, PATCH for fixes
|
|
123
|
+
alone, MAJOR for anything that breaks a consumer's imports or markup.
|
|
124
|
+
6. `npm publish`. Check `npm view @vit-foundation/ui version` first: the
|
|
125
|
+
registry has been ahead of `main` before.
|
|
126
|
+
7. `git push && git push origin vx.y.z`. Nothing automated reads the tag, but
|
|
127
|
+
the compare links in the changelog do.
|
|
128
|
+
|
|
129
|
+
**Publish from a clean checkout of `main`, never from a tree with uncommitted
|
|
130
|
+
work.** 0.28.0 was published from one: it carried an icon that reached npm and
|
|
131
|
+
nothing else, and the next release would have silently removed it again if the
|
|
132
|
+
tarball had not been diffed against the source first.
|
|
133
|
+
|
|
134
|
+
A released page is history: never rewrite one to match later code. If it is
|
|
135
|
+
wrong, say so on the next version's page.
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component CrossfadeVideo
|
|
3
|
+
|
|
4
|
+
One responsive video: a desktop element and a mobile element, each with the
|
|
5
|
+
same three `<source>`s (webm → "safe" mp4 → mp4), plus the play / ended /
|
|
6
|
+
replay state that every scrolly video on the site used to re-implement.
|
|
7
|
+
|
|
8
|
+
Only one of the two elements is ever displayed (`desktopClass` /
|
|
9
|
+
`mobileClass` carry the breakpoint), but both exist so the browser picks the
|
|
10
|
+
right encode without a JS media query — the arrangement the feature
|
|
11
|
+
components already used.
|
|
12
|
+
|
|
13
|
+
Playback comes in two flavours:
|
|
14
|
+
- **managed** (default): the component plays from the first frame whenever
|
|
15
|
+
`active` turns true and pauses when it turns false.
|
|
16
|
+
- **native** (`autoplay`): playback is the browser's, and `active` only
|
|
17
|
+
matters for whatever classes the caller crossfades with.
|
|
18
|
+
|
|
19
|
+
Swapping the `<source>` children of a live `<video>` has no effect, so the
|
|
20
|
+
elements are keyed on their sources and recreated when those change.
|
|
21
|
+
|
|
22
|
+
@prop {string} [srcBase] - Folder the standard file names expand from:
|
|
23
|
+
`<srcBase>/desktop.webm`, `/desktop-safe.mp4`, `/desktop.mp4` and the
|
|
24
|
+
matching `mobile.*`. Ignored when `sources` is given.
|
|
25
|
+
@prop {CrossfadeVideoSources} [sources] - Explicit per-breakpoint sources,
|
|
26
|
+
for folders that don't follow the `desktop.*` / `mobile.*` naming.
|
|
27
|
+
@prop {boolean} [active=true] - Whether this video is the one on screen.
|
|
28
|
+
Drives managed playback.
|
|
29
|
+
@prop {boolean} [autoplay=false] - Hand playback to the browser (`autoplay`
|
|
30
|
+
on both elements) instead of managing it.
|
|
31
|
+
@prop {boolean} [loop=false] - Loop both elements.
|
|
32
|
+
@prop {string} [poster] - Poster frame for both elements.
|
|
33
|
+
@prop {string} [class] - Classes shared by both `<video>` elements.
|
|
34
|
+
@prop {string} [desktopClass='hidden lg:block'] - Extra classes on the
|
|
35
|
+
desktop element, carrying its breakpoint.
|
|
36
|
+
@prop {string} [mobileClass='block lg:hidden'] - Extra classes on the
|
|
37
|
+
mobile element, carrying its breakpoint.
|
|
38
|
+
@prop {boolean} [replayable=true] - Whether reaching the end offers a replay.
|
|
39
|
+
@prop {string} [replayClass] - Extra classes on the replay button, which is
|
|
40
|
+
already `absolute inset-0` over the caller's positioned wrapper.
|
|
41
|
+
@prop {string} [replayLabel='Replay video'] - Replay button aria-label.
|
|
42
|
+
@prop {Snippet} [overlay] - Replay button content. Defaults to the shared
|
|
43
|
+
reload badge.
|
|
44
|
+
@prop {() => void} [onended] - Called once when the video reaches its end.
|
|
45
|
+
-->
|
|
46
|
+
<script lang="ts" module>
|
|
47
|
+
/** The three encodes one breakpoint offers, in `<source>` order. */
|
|
48
|
+
export type CrossfadeVideoTrack = {
|
|
49
|
+
/** VP9/webm, preferred where it decodes. */
|
|
50
|
+
webm: string;
|
|
51
|
+
/** Conservatively encoded mp4, for players that choke on the main one. */
|
|
52
|
+
mp4Safe: string;
|
|
53
|
+
/** Main h.264 mp4. */
|
|
54
|
+
mp4: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
/** Both breakpoints' encodes. */
|
|
58
|
+
export type CrossfadeVideoSources = {
|
|
59
|
+
desktop: CrossfadeVideoTrack;
|
|
60
|
+
mobile: CrossfadeVideoTrack;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/** What `bind:this` on a `<CrossfadeVideo>` exposes. */
|
|
64
|
+
export type CrossfadeVideoHandle = { replay: () => void };
|
|
65
|
+
</script>
|
|
66
|
+
|
|
67
|
+
<script lang="ts">
|
|
68
|
+
import type { Snippet } from 'svelte';
|
|
69
|
+
|
|
70
|
+
let {
|
|
71
|
+
srcBase = '',
|
|
72
|
+
sources,
|
|
73
|
+
active = true,
|
|
74
|
+
autoplay = false,
|
|
75
|
+
loop = false,
|
|
76
|
+
poster,
|
|
77
|
+
class: className = '',
|
|
78
|
+
desktopClass = 'hidden lg:block',
|
|
79
|
+
mobileClass = 'block lg:hidden',
|
|
80
|
+
replayable = true,
|
|
81
|
+
replayClass = '',
|
|
82
|
+
replayLabel = 'Replay video',
|
|
83
|
+
overlay,
|
|
84
|
+
onended
|
|
85
|
+
}: {
|
|
86
|
+
srcBase?: string;
|
|
87
|
+
sources?: CrossfadeVideoSources;
|
|
88
|
+
active?: boolean;
|
|
89
|
+
autoplay?: boolean;
|
|
90
|
+
loop?: boolean;
|
|
91
|
+
poster?: string;
|
|
92
|
+
class?: string;
|
|
93
|
+
desktopClass?: string;
|
|
94
|
+
mobileClass?: string;
|
|
95
|
+
replayable?: boolean;
|
|
96
|
+
replayClass?: string;
|
|
97
|
+
replayLabel?: string;
|
|
98
|
+
overlay?: Snippet;
|
|
99
|
+
onended?: () => void;
|
|
100
|
+
} = $props();
|
|
101
|
+
|
|
102
|
+
const resolved: CrossfadeVideoSources = $derived(
|
|
103
|
+
sources ?? {
|
|
104
|
+
desktop: {
|
|
105
|
+
webm: `${srcBase}/desktop.webm`,
|
|
106
|
+
mp4Safe: `${srcBase}/desktop-safe.mp4`,
|
|
107
|
+
mp4: `${srcBase}/desktop.mp4`
|
|
108
|
+
},
|
|
109
|
+
mobile: {
|
|
110
|
+
webm: `${srcBase}/mobile.webm`,
|
|
111
|
+
mp4Safe: `${srcBase}/mobile-safe.mp4`,
|
|
112
|
+
mp4: `${srcBase}/mobile.mp4`
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
/** Recreating the elements is the only way a `<source>` swap takes effect. */
|
|
118
|
+
const srcKey = $derived(`${resolved.desktop.webm}|${resolved.mobile.webm}`);
|
|
119
|
+
|
|
120
|
+
let desktopEl = $state<HTMLVideoElement | null>(null);
|
|
121
|
+
let mobileEl = $state<HTMLVideoElement | null>(null);
|
|
122
|
+
let ended = $state(false);
|
|
123
|
+
|
|
124
|
+
/** Both elements, in the order they are played/paused. */
|
|
125
|
+
const elements = $derived([desktopEl, mobileEl]);
|
|
126
|
+
|
|
127
|
+
/** Rewinds and plays both elements. Autoplay rejections are expected. */
|
|
128
|
+
function playFromStart() {
|
|
129
|
+
ended = false;
|
|
130
|
+
for (const el of elements) {
|
|
131
|
+
if (!el) continue;
|
|
132
|
+
el.currentTime = 0;
|
|
133
|
+
// `play()` returns undefined in non-browser DOMs; normalize before catching
|
|
134
|
+
void Promise.resolve(el.play()).catch(() => {
|
|
135
|
+
// Autoplay may be blocked by the browser — silently fail
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Restarts playback from the first frame.
|
|
142
|
+
*
|
|
143
|
+
* Exposed on the component instance so a host that plays on its own trigger
|
|
144
|
+
* (a section scrolling back into view, say) can ask for a replay.
|
|
145
|
+
*/
|
|
146
|
+
export function replay() {
|
|
147
|
+
playFromStart();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Leaves the video on its last frame and offers the replay affordance. */
|
|
151
|
+
function handleEnded() {
|
|
152
|
+
if (ended) return; // already handled by the other element
|
|
153
|
+
ended = true;
|
|
154
|
+
onended?.();
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
$effect(() => {
|
|
158
|
+
if (autoplay) return; // native playback: the browser owns it
|
|
159
|
+
if (active) playFromStart();
|
|
160
|
+
else for (const el of elements) el?.pause();
|
|
161
|
+
});
|
|
162
|
+
</script>
|
|
163
|
+
|
|
164
|
+
{#key srcKey}
|
|
165
|
+
<!-- Desktop -->
|
|
166
|
+
<video
|
|
167
|
+
bind:this={desktopEl}
|
|
168
|
+
muted
|
|
169
|
+
playsinline
|
|
170
|
+
{autoplay}
|
|
171
|
+
{loop}
|
|
172
|
+
{poster}
|
|
173
|
+
onended={handleEnded}
|
|
174
|
+
class="{className} {desktopClass}"
|
|
175
|
+
>
|
|
176
|
+
<source src={resolved.desktop.webm} type="video/webm" />
|
|
177
|
+
<source src={resolved.desktop.mp4Safe} type="video/mp4" />
|
|
178
|
+
<source src={resolved.desktop.mp4} type="video/mp4" />
|
|
179
|
+
</video>
|
|
180
|
+
|
|
181
|
+
<!-- Mobile -->
|
|
182
|
+
<video
|
|
183
|
+
bind:this={mobileEl}
|
|
184
|
+
muted
|
|
185
|
+
playsinline
|
|
186
|
+
{autoplay}
|
|
187
|
+
{loop}
|
|
188
|
+
{poster}
|
|
189
|
+
onended={handleEnded}
|
|
190
|
+
class="{className} {mobileClass}"
|
|
191
|
+
>
|
|
192
|
+
<source src={resolved.mobile.webm} type="video/webm" />
|
|
193
|
+
<source src={resolved.mobile.mp4Safe} type="video/mp4" />
|
|
194
|
+
<source src={resolved.mobile.mp4} type="video/mp4" />
|
|
195
|
+
</video>
|
|
196
|
+
|
|
197
|
+
{#if ended && replayable}
|
|
198
|
+
<button onclick={replay} class="vit-video__replay {replayClass}" aria-label={replayLabel}>
|
|
199
|
+
{#if overlay}
|
|
200
|
+
{@render overlay()}
|
|
201
|
+
{:else}
|
|
202
|
+
<!-- The default badge draws its own icon rather than fetching one:
|
|
203
|
+
a package cannot assume a host ships `/common/reload.svg`. Pass
|
|
204
|
+
the `overlay` snippet to replace it entirely. -->
|
|
205
|
+
<span class="vit-video__replay-badge">
|
|
206
|
+
<svg viewBox="0 0 24 24" width="28" height="28" aria-hidden="true" focusable="false">
|
|
207
|
+
<path d="M12 5V2L8 6l4 4V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7Z" fill="currentColor" />
|
|
208
|
+
</svg>
|
|
209
|
+
</span>
|
|
210
|
+
{/if}
|
|
211
|
+
</button>
|
|
212
|
+
{/if}
|
|
213
|
+
{/key}
|
|
214
|
+
|
|
215
|
+
<style>
|
|
216
|
+
/*
|
|
217
|
+
* The replay affordance covers the finished video. Presentation is plain CSS
|
|
218
|
+
* so the component needs no framework; pass `replayClass` or the `overlay`
|
|
219
|
+
* snippet to restyle it.
|
|
220
|
+
*/
|
|
221
|
+
.vit-video__replay {
|
|
222
|
+
position: absolute;
|
|
223
|
+
inset: 0;
|
|
224
|
+
z-index: 10;
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
justify-content: center;
|
|
228
|
+
padding: 0;
|
|
229
|
+
border: 0;
|
|
230
|
+
background: none;
|
|
231
|
+
cursor: pointer;
|
|
232
|
+
pointer-events: auto;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
.vit-video__replay-badge {
|
|
236
|
+
display: inline-flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
justify-content: center;
|
|
239
|
+
padding: var(--video-badge-padding, 1rem);
|
|
240
|
+
border-radius: 9999px;
|
|
241
|
+
color: var(--video-badge-color, #111827);
|
|
242
|
+
background: var(--video-badge-bg, rgb(255 255 255 / 0.5));
|
|
243
|
+
backdrop-filter: blur(4px);
|
|
244
|
+
-webkit-backdrop-filter: blur(4px);
|
|
245
|
+
}
|
|
246
|
+
</style>
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** The three encodes one breakpoint offers, in `<source>` order. */
|
|
2
|
+
export type CrossfadeVideoTrack = {
|
|
3
|
+
/** VP9/webm, preferred where it decodes. */
|
|
4
|
+
webm: string;
|
|
5
|
+
/** Conservatively encoded mp4, for players that choke on the main one. */
|
|
6
|
+
mp4Safe: string;
|
|
7
|
+
/** Main h.264 mp4. */
|
|
8
|
+
mp4: string;
|
|
9
|
+
};
|
|
10
|
+
/** Both breakpoints' encodes. */
|
|
11
|
+
export type CrossfadeVideoSources = {
|
|
12
|
+
desktop: CrossfadeVideoTrack;
|
|
13
|
+
mobile: CrossfadeVideoTrack;
|
|
14
|
+
};
|
|
15
|
+
/** What `bind:this` on a `<CrossfadeVideo>` exposes. */
|
|
16
|
+
export type CrossfadeVideoHandle = {
|
|
17
|
+
replay: () => void;
|
|
18
|
+
};
|
|
19
|
+
import type { Snippet } from 'svelte';
|
|
20
|
+
type $$ComponentProps = {
|
|
21
|
+
srcBase?: string;
|
|
22
|
+
sources?: CrossfadeVideoSources;
|
|
23
|
+
active?: boolean;
|
|
24
|
+
autoplay?: boolean;
|
|
25
|
+
loop?: boolean;
|
|
26
|
+
poster?: string;
|
|
27
|
+
class?: string;
|
|
28
|
+
desktopClass?: string;
|
|
29
|
+
mobileClass?: string;
|
|
30
|
+
replayable?: boolean;
|
|
31
|
+
replayClass?: string;
|
|
32
|
+
replayLabel?: string;
|
|
33
|
+
overlay?: Snippet;
|
|
34
|
+
onended?: () => void;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* CrossfadeVideo
|
|
38
|
+
*
|
|
39
|
+
* One responsive video: a desktop element and a mobile element, each with the
|
|
40
|
+
* same three `<source>`s (webm → "safe" mp4 → mp4), plus the play / ended /
|
|
41
|
+
* replay state that every scrolly video on the site used to re-implement.
|
|
42
|
+
*
|
|
43
|
+
* Only one of the two elements is ever displayed (`desktopClass` /
|
|
44
|
+
* `mobileClass` carry the breakpoint), but both exist so the browser picks the
|
|
45
|
+
* right encode without a JS media query — the arrangement the feature
|
|
46
|
+
* components already used.
|
|
47
|
+
*
|
|
48
|
+
* Playback comes in two flavours:
|
|
49
|
+
* - **managed** (default): the component plays from the first frame whenever
|
|
50
|
+
* `active` turns true and pauses when it turns false.
|
|
51
|
+
* - **native** (`autoplay`): playback is the browser's, and `active` only
|
|
52
|
+
* matters for whatever classes the caller crossfades with.
|
|
53
|
+
*
|
|
54
|
+
* Swapping the `<source>` children of a live `<video>` has no effect, so the
|
|
55
|
+
* elements are keyed on their sources and recreated when those change.
|
|
56
|
+
*
|
|
57
|
+
* @prop {string} [srcBase] - Folder the standard file names expand from:
|
|
58
|
+
* `<srcBase>/desktop.webm`, `/desktop-safe.mp4`, `/desktop.mp4` and the
|
|
59
|
+
* matching `mobile.*`. Ignored when `sources` is given.
|
|
60
|
+
* @prop {CrossfadeVideoSources} [sources] - Explicit per-breakpoint sources,
|
|
61
|
+
* for folders that don't follow the `desktop.*` / `mobile.*` naming.
|
|
62
|
+
* @prop {boolean} [active=true] - Whether this video is the one on screen.
|
|
63
|
+
* Drives managed playback.
|
|
64
|
+
* @prop {boolean} [autoplay=false] - Hand playback to the browser (`autoplay`
|
|
65
|
+
* on both elements) instead of managing it.
|
|
66
|
+
* @prop {boolean} [loop=false] - Loop both elements.
|
|
67
|
+
* @prop {string} [poster] - Poster frame for both elements.
|
|
68
|
+
* @prop {string} [class] - Classes shared by both `<video>` elements.
|
|
69
|
+
* @prop {string} [desktopClass='hidden lg:block'] - Extra classes on the
|
|
70
|
+
* desktop element, carrying its breakpoint.
|
|
71
|
+
* @prop {string} [mobileClass='block lg:hidden'] - Extra classes on the
|
|
72
|
+
* mobile element, carrying its breakpoint.
|
|
73
|
+
* @prop {boolean} [replayable=true] - Whether reaching the end offers a replay.
|
|
74
|
+
* @prop {string} [replayClass] - Extra classes on the replay button, which is
|
|
75
|
+
* already `absolute inset-0` over the caller's positioned wrapper.
|
|
76
|
+
* @prop {string} [replayLabel='Replay video'] - Replay button aria-label.
|
|
77
|
+
* @prop {Snippet} [overlay] - Replay button content. Defaults to the shared
|
|
78
|
+
* reload badge.
|
|
79
|
+
* @prop {() => void} [onended] - Called once when the video reaches its end.
|
|
80
|
+
*/
|
|
81
|
+
declare const CrossfadeVideo: import("svelte").Component<$$ComponentProps, {
|
|
82
|
+
replay: () => void;
|
|
83
|
+
}, "">;
|
|
84
|
+
type CrossfadeVideo = ReturnType<typeof CrossfadeVideo>;
|
|
85
|
+
export default CrossfadeVideo;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component GlassCard
|
|
3
|
+
|
|
4
|
+
A frosted panel: padding plus a backdrop blur, and nothing else. It exists
|
|
5
|
+
because the same two declarations were being written at ten call sites.
|
|
6
|
+
|
|
7
|
+
| property | default |
|
|
8
|
+
|----------------------|----------|
|
|
9
|
+
| `--glass-padding`| `2.25rem`|
|
|
10
|
+
| `--glass-blur` | `4px` |
|
|
11
|
+
|
|
12
|
+
@prop {string} [class] - Extra classes appended to the panel
|
|
13
|
+
@prop {Snippet} children - The panel's contents
|
|
14
|
+
-->
|
|
15
|
+
<script lang="ts">
|
|
16
|
+
import type { Snippet } from 'svelte';
|
|
17
|
+
|
|
18
|
+
let {
|
|
19
|
+
class: className = '',
|
|
20
|
+
children,
|
|
21
|
+
...rest
|
|
22
|
+
}: {
|
|
23
|
+
class?: string;
|
|
24
|
+
children: Snippet;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
} = $props();
|
|
27
|
+
</script>
|
|
28
|
+
|
|
29
|
+
<div class="vit-glass {className}" {...rest}>
|
|
30
|
+
{@render children()}
|
|
31
|
+
</div>
|
|
32
|
+
|
|
33
|
+
<style>
|
|
34
|
+
.vit-glass {
|
|
35
|
+
padding: var(--glass-padding, 2.25rem);
|
|
36
|
+
backdrop-filter: blur(var(--glass-blur, 4px));
|
|
37
|
+
-webkit-backdrop-filter: blur(var(--glass-blur, 4px));
|
|
38
|
+
}
|
|
39
|
+
</style>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
class?: string;
|
|
4
|
+
children: Snippet;
|
|
5
|
+
[key: string]: unknown;
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* GlassCard
|
|
9
|
+
*
|
|
10
|
+
* A frosted panel: padding plus a backdrop blur, and nothing else. It exists
|
|
11
|
+
* because the same two declarations were being written at ten call sites.
|
|
12
|
+
*
|
|
13
|
+
* | property | default |
|
|
14
|
+
* |----------------------|----------|
|
|
15
|
+
* | `--glass-padding`| `2.25rem`|
|
|
16
|
+
* | `--glass-blur` | `4px` |
|
|
17
|
+
*
|
|
18
|
+
* @prop {string} [class] - Extra classes appended to the panel
|
|
19
|
+
* @prop {Snippet} children - The panel's contents
|
|
20
|
+
*/
|
|
21
|
+
declare const GlassCard: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
22
|
+
type GlassCard = ReturnType<typeof GlassCard>;
|
|
23
|
+
export default GlassCard;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ScrollyStepIndicator
|
|
3
|
+
|
|
4
|
+
The row (or column) of dots that shows which step of a scrolly a reader is on,
|
|
5
|
+
optionally clickable to jump.
|
|
6
|
+
|
|
7
|
+
Colours arrive as CSS custom properties rather than class-name props. The
|
|
8
|
+
version this was extracted from took `activeColor="bg-[#3E2A12] ring-1 …"` —
|
|
9
|
+
Tailwind class strings with a brand hex baked into the default — which meant a
|
|
10
|
+
consumer had to have Tailwind *and* had to restate the whole class list to
|
|
11
|
+
change one colour.
|
|
12
|
+
|
|
13
|
+
| property | default |
|
|
14
|
+
|------------------------|-----------|
|
|
15
|
+
| `--dot-size` | `0.375rem`|
|
|
16
|
+
| `--dot-gap` | `0.5rem` |
|
|
17
|
+
| `--dot-color` | `currentColor` |
|
|
18
|
+
| `--dot-active` | `var(--dot-color, currentColor)` |
|
|
19
|
+
|
|
20
|
+
@prop {number} total - How many steps
|
|
21
|
+
@prop {number} current - Index of the active step
|
|
22
|
+
@prop {(index: number) => void} [onSelect] - Makes the dots buttons that jump
|
|
23
|
+
@prop {'vertical' | 'horizontal'} [orientation] - Layout direction
|
|
24
|
+
@prop {string} [class] - Extra classes appended to the container
|
|
25
|
+
-->
|
|
26
|
+
<script lang="ts">
|
|
27
|
+
let {
|
|
28
|
+
total,
|
|
29
|
+
current,
|
|
30
|
+
onSelect,
|
|
31
|
+
orientation = 'vertical',
|
|
32
|
+
class: className = ''
|
|
33
|
+
}: {
|
|
34
|
+
total: number;
|
|
35
|
+
current: number;
|
|
36
|
+
onSelect?: (index: number) => void;
|
|
37
|
+
orientation?: 'vertical' | 'horizontal';
|
|
38
|
+
class?: string;
|
|
39
|
+
} = $props();
|
|
40
|
+
|
|
41
|
+
const isHorizontal = $derived(orientation === 'horizontal');
|
|
42
|
+
</script>
|
|
43
|
+
|
|
44
|
+
<div class="vit-dots {className}" class:vit-dots--horizontal={isHorizontal}>
|
|
45
|
+
{#each Array.from({ length: total }, (_, k) => k) as i (i)}
|
|
46
|
+
{#if onSelect}
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
aria-label="Go to slide {i + 1}"
|
|
50
|
+
aria-current={i === current ? 'true' : undefined}
|
|
51
|
+
onclick={() => onSelect(i)}
|
|
52
|
+
class="vit-dots__dot vit-dots__dot--button"
|
|
53
|
+
class:vit-dots__dot--active={i === current}
|
|
54
|
+
></button>
|
|
55
|
+
{:else}
|
|
56
|
+
<div class="vit-dots__dot" class:vit-dots__dot--active={i === current}></div>
|
|
57
|
+
{/if}
|
|
58
|
+
{/each}
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<style>
|
|
62
|
+
.vit-dots {
|
|
63
|
+
display: flex;
|
|
64
|
+
flex-direction: column;
|
|
65
|
+
gap: var(--dot-gap, 0.5rem);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
.vit-dots--horizontal {
|
|
69
|
+
flex-direction: row;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.vit-dots__dot {
|
|
73
|
+
width: var(--dot-size, 0.375rem);
|
|
74
|
+
height: var(--dot-size, 0.375rem);
|
|
75
|
+
border-radius: 9999px;
|
|
76
|
+
background: transparent;
|
|
77
|
+
box-shadow: inset 0 0 0 1px var(--dot-color, currentColor);
|
|
78
|
+
transition:
|
|
79
|
+
background-color 300ms,
|
|
80
|
+
box-shadow 300ms;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.vit-dots__dot--active {
|
|
84
|
+
background: var(--dot-active, var(--dot-color, currentColor));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.vit-dots__dot--button {
|
|
88
|
+
padding: 0;
|
|
89
|
+
border: 0;
|
|
90
|
+
cursor: pointer;
|
|
91
|
+
appearance: none;
|
|
92
|
+
}
|
|
93
|
+
</style>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
type $$ComponentProps = {
|
|
2
|
+
total: number;
|
|
3
|
+
current: number;
|
|
4
|
+
onSelect?: (index: number) => void;
|
|
5
|
+
orientation?: 'vertical' | 'horizontal';
|
|
6
|
+
class?: string;
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* ScrollyStepIndicator
|
|
10
|
+
*
|
|
11
|
+
* The row (or column) of dots that shows which step of a scrolly a reader is on,
|
|
12
|
+
* optionally clickable to jump.
|
|
13
|
+
*
|
|
14
|
+
* Colours arrive as CSS custom properties rather than class-name props. The
|
|
15
|
+
* version this was extracted from took `activeColor="bg-[#3E2A12] ring-1 …"` —
|
|
16
|
+
* Tailwind class strings with a brand hex baked into the default — which meant a
|
|
17
|
+
* consumer had to have Tailwind *and* had to restate the whole class list to
|
|
18
|
+
* change one colour.
|
|
19
|
+
*
|
|
20
|
+
* | property | default |
|
|
21
|
+
* |------------------------|-----------|
|
|
22
|
+
* | `--dot-size` | `0.375rem`|
|
|
23
|
+
* | `--dot-gap` | `0.5rem` |
|
|
24
|
+
* | `--dot-color` | `currentColor` |
|
|
25
|
+
* | `--dot-active` | `var(--dot-color, currentColor)` |
|
|
26
|
+
*
|
|
27
|
+
* @prop {number} total - How many steps
|
|
28
|
+
* @prop {number} current - Index of the active step
|
|
29
|
+
* @prop {(index: number) => void} [onSelect] - Makes the dots buttons that jump
|
|
30
|
+
* @prop {'vertical' | 'horizontal'} [orientation] - Layout direction
|
|
31
|
+
* @prop {string} [class] - Extra classes appended to the container
|
|
32
|
+
*/
|
|
33
|
+
declare const ScrollyStepIndicator: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
34
|
+
type ScrollyStepIndicator = ReturnType<typeof ScrollyStepIndicator>;
|
|
35
|
+
export default ScrollyStepIndicator;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
@component ScrollySteps
|
|
3
|
+
|
|
4
|
+
The one scrolly mechanism: a sticky background with a column of scrolling
|
|
5
|
+
step sections over it.
|
|
6
|
+
|
|
7
|
+
Wraps `@sveltejs/svelte-scroller` (a Svelte 4 component, driven through its
|
|
8
|
+
legacy `background`/`foreground` slots) and owns everything every scrolly
|
|
9
|
+
section on the site used to re-implement by hand: the `index`/`offset`
|
|
10
|
+
state, the `<Scroller>` element, the `scrolly-bg` sticky wrapper, and the
|
|
11
|
+
per-step opacity/translateY ramp (see `./scrollySteps.ts`).
|
|
12
|
+
|
|
13
|
+
Content stays in the feature component: `background` paints whatever sits
|
|
14
|
+
behind, `step` paints one section's card. One `<section>` is rendered per
|
|
15
|
+
entry of `steps` — including spacer/buffer entries, since the scroller
|
|
16
|
+
counts `<section>` elements to decide `index`. Sections that are not steps
|
|
17
|
+
at all (a trailing "hold" section, say) go in `before`/`after`.
|
|
18
|
+
|
|
19
|
+
@prop {T[]} steps - One entry per `<section>`, in scroll order. Spacer
|
|
20
|
+
sections are entries too, so `index` lines up with the array.
|
|
21
|
+
@prop {number} [top=0] - Scroller `top`, as a fraction of the viewport.
|
|
22
|
+
@prop {number} [bottom=1] - Scroller `bottom`, as a fraction of the viewport.
|
|
23
|
+
@prop {number} [threshold=0.5] - Viewport fraction at which a section
|
|
24
|
+
becomes the current step.
|
|
25
|
+
@prop {number} [index=0] - Bindable. Step index the scroller reports.
|
|
26
|
+
@prop {number} [offset=0] - Bindable. Scroll progress within that step, 0 → 1.
|
|
27
|
+
@prop {Snippet} background - Sticky background, rendered inside the
|
|
28
|
+
`scrolly-bg` wrapper. Receives `{ index, offset, progress }`.
|
|
29
|
+
@prop {Snippet} step - One step's content, rendered inside its `<section>`.
|
|
30
|
+
Receives `{ item, i, active, opacity, translateY }`.
|
|
31
|
+
@prop {Snippet} [before] - Raw sections rendered before the step sections.
|
|
32
|
+
@prop {Snippet} [after] - Raw sections rendered after the step sections
|
|
33
|
+
(trailing buffer / hold sections).
|
|
34
|
+
@prop {string} [class='w-full'] - Classes on the foreground column.
|
|
35
|
+
@prop {string} [backgroundClass] - Extra classes next to `scrolly-bg`.
|
|
36
|
+
@prop {string | ((item: T, i: number) => string)} [sectionClass] - Classes
|
|
37
|
+
on each step `<section>`; a function when they differ per step.
|
|
38
|
+
-->
|
|
39
|
+
<script lang="ts" generics="T">
|
|
40
|
+
import type { Snippet } from 'svelte';
|
|
41
|
+
import Scroller from '@sveltejs/svelte-scroller';
|
|
42
|
+
import { stepStyle } from './stepStyle.js';
|
|
43
|
+
|
|
44
|
+
let {
|
|
45
|
+
steps,
|
|
46
|
+
top = 0,
|
|
47
|
+
bottom = 1,
|
|
48
|
+
threshold = 0.5,
|
|
49
|
+
index = $bindable(0),
|
|
50
|
+
offset = $bindable(0),
|
|
51
|
+
background,
|
|
52
|
+
step,
|
|
53
|
+
before,
|
|
54
|
+
after,
|
|
55
|
+
class: className = 'w-full',
|
|
56
|
+
backgroundClass = '',
|
|
57
|
+
sectionClass = ''
|
|
58
|
+
}: {
|
|
59
|
+
steps: T[];
|
|
60
|
+
top?: number;
|
|
61
|
+
bottom?: number;
|
|
62
|
+
threshold?: number;
|
|
63
|
+
index?: number;
|
|
64
|
+
offset?: number;
|
|
65
|
+
background: Snippet<[{ index: number; offset: number; progress: number }]>;
|
|
66
|
+
step: Snippet<[{ item: T; i: number; active: boolean; opacity: number; translateY: number }]>;
|
|
67
|
+
before?: Snippet;
|
|
68
|
+
after?: Snippet;
|
|
69
|
+
class?: string;
|
|
70
|
+
backgroundClass?: string;
|
|
71
|
+
sectionClass?: string | ((item: T, i: number) => string);
|
|
72
|
+
} = $props();
|
|
73
|
+
|
|
74
|
+
/** Scroller progress across the whole sequence, 0 → 1. Handed to `background`. */
|
|
75
|
+
let progress = $state(0);
|
|
76
|
+
|
|
77
|
+
/** Resolves the per-section class list, which may depend on the step. */
|
|
78
|
+
function classFor(item: T, i: number): string {
|
|
79
|
+
return typeof sectionClass === 'function' ? sectionClass(item, i) : sectionClass;
|
|
80
|
+
}
|
|
81
|
+
</script>
|
|
82
|
+
|
|
83
|
+
<Scroller {top} {bottom} {threshold} bind:index bind:offset bind:progress>
|
|
84
|
+
<!-- BACKGROUND: sticky, full viewport height -->
|
|
85
|
+
<div slot="background" class="vit-scrolly__bg {backgroundClass}">
|
|
86
|
+
{@render background({ index, offset, progress })}
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- FOREGROUND: the step sections, scrolling over the background -->
|
|
90
|
+
<div slot="foreground" class={className}>
|
|
91
|
+
{@render before?.()}
|
|
92
|
+
{#each steps as item, i (i)}
|
|
93
|
+
<section class={classFor(item, i)}>
|
|
94
|
+
{@render step({ item, i, ...stepStyle(i, index, offset) })}
|
|
95
|
+
</section>
|
|
96
|
+
{/each}
|
|
97
|
+
{@render after?.()}
|
|
98
|
+
</div>
|
|
99
|
+
</Scroller>
|
|
100
|
+
|
|
101
|
+
<style>
|
|
102
|
+
/*
|
|
103
|
+
* The sticky background fills the viewport. `--device-h` lets a host pin a
|
|
104
|
+
* measured height instead of `100vh`, which is what mobile browsers need
|
|
105
|
+
* when the URL bar collapses and `vh` jumps.
|
|
106
|
+
*/
|
|
107
|
+
.vit-scrolly__bg {
|
|
108
|
+
position: relative;
|
|
109
|
+
width: 100%;
|
|
110
|
+
height: var(--device-h, 100vh);
|
|
111
|
+
}
|
|
112
|
+
</style>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
declare function $$render<T>(): {
|
|
3
|
+
props: {
|
|
4
|
+
steps: T[];
|
|
5
|
+
top?: number;
|
|
6
|
+
bottom?: number;
|
|
7
|
+
threshold?: number;
|
|
8
|
+
index?: number;
|
|
9
|
+
offset?: number;
|
|
10
|
+
background: Snippet<[{
|
|
11
|
+
index: number;
|
|
12
|
+
offset: number;
|
|
13
|
+
progress: number;
|
|
14
|
+
}]>;
|
|
15
|
+
step: Snippet<[{
|
|
16
|
+
item: T;
|
|
17
|
+
i: number;
|
|
18
|
+
active: boolean;
|
|
19
|
+
opacity: number;
|
|
20
|
+
translateY: number;
|
|
21
|
+
}]>;
|
|
22
|
+
before?: Snippet;
|
|
23
|
+
after?: Snippet;
|
|
24
|
+
class?: string;
|
|
25
|
+
backgroundClass?: string;
|
|
26
|
+
sectionClass?: string | ((item: T, i: number) => string);
|
|
27
|
+
};
|
|
28
|
+
exports: {};
|
|
29
|
+
bindings: "index" | "offset";
|
|
30
|
+
slots: {};
|
|
31
|
+
events: {};
|
|
32
|
+
};
|
|
33
|
+
declare class __sveltets_Render<T> {
|
|
34
|
+
props(): ReturnType<typeof $$render<T>>['props'];
|
|
35
|
+
events(): ReturnType<typeof $$render<T>>['events'];
|
|
36
|
+
slots(): ReturnType<typeof $$render<T>>['slots'];
|
|
37
|
+
bindings(): "index" | "offset";
|
|
38
|
+
exports(): {};
|
|
39
|
+
}
|
|
40
|
+
interface $$IsomorphicComponent {
|
|
41
|
+
new <T>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
|
|
42
|
+
$$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
|
|
43
|
+
} & ReturnType<__sveltets_Render<T>['exports']>;
|
|
44
|
+
<T>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
|
|
45
|
+
z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* ScrollySteps
|
|
49
|
+
*
|
|
50
|
+
* The one scrolly mechanism: a sticky background with a column of scrolling
|
|
51
|
+
* step sections over it.
|
|
52
|
+
*
|
|
53
|
+
* Wraps `@sveltejs/svelte-scroller` (a Svelte 4 component, driven through its
|
|
54
|
+
* legacy `background`/`foreground` slots) and owns everything every scrolly
|
|
55
|
+
* section on the site used to re-implement by hand: the `index`/`offset`
|
|
56
|
+
* state, the `<Scroller>` element, the `scrolly-bg` sticky wrapper, and the
|
|
57
|
+
* per-step opacity/translateY ramp (see `./scrollySteps.ts`).
|
|
58
|
+
*
|
|
59
|
+
* Content stays in the feature component: `background` paints whatever sits
|
|
60
|
+
* behind, `step` paints one section's card. One `<section>` is rendered per
|
|
61
|
+
* entry of `steps` — including spacer/buffer entries, since the scroller
|
|
62
|
+
* counts `<section>` elements to decide `index`. Sections that are not steps
|
|
63
|
+
* at all (a trailing "hold" section, say) go in `before`/`after`.
|
|
64
|
+
*
|
|
65
|
+
* @prop {T[]} steps - One entry per `<section>`, in scroll order. Spacer
|
|
66
|
+
* sections are entries too, so `index` lines up with the array.
|
|
67
|
+
* @prop {number} [top=0] - Scroller `top`, as a fraction of the viewport.
|
|
68
|
+
* @prop {number} [bottom=1] - Scroller `bottom`, as a fraction of the viewport.
|
|
69
|
+
* @prop {number} [threshold=0.5] - Viewport fraction at which a section
|
|
70
|
+
* becomes the current step.
|
|
71
|
+
* @prop {number} [index=0] - Bindable. Step index the scroller reports.
|
|
72
|
+
* @prop {number} [offset=0] - Bindable. Scroll progress within that step, 0 → 1.
|
|
73
|
+
* @prop {Snippet} background - Sticky background, rendered inside the
|
|
74
|
+
* `scrolly-bg` wrapper. Receives `{ index, offset, progress }`.
|
|
75
|
+
* @prop {Snippet} step - One step's content, rendered inside its `<section>`.
|
|
76
|
+
* Receives `{ item, i, active, opacity, translateY }`.
|
|
77
|
+
* @prop {Snippet} [before] - Raw sections rendered before the step sections.
|
|
78
|
+
* @prop {Snippet} [after] - Raw sections rendered after the step sections
|
|
79
|
+
* (trailing buffer / hold sections).
|
|
80
|
+
* @prop {string} [class='w-full'] - Classes on the foreground column.
|
|
81
|
+
* @prop {string} [backgroundClass] - Extra classes next to `scrolly-bg`.
|
|
82
|
+
* @prop {string | ((item: T, i: number) => string)} [sectionClass] - Classes
|
|
83
|
+
* on each step `<section>`; a function when they differ per step.
|
|
84
|
+
*/
|
|
85
|
+
declare const ScrollySteps: $$IsomorphicComponent;
|
|
86
|
+
type ScrollySteps<T> = InstanceType<typeof ScrollySteps<T>>;
|
|
87
|
+
export default ScrollySteps;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scrolly/stepStyle
|
|
3
|
+
*
|
|
4
|
+
* The per-step style a scrolly card is drawn with, derived from the scroller's
|
|
5
|
+
* `index` / `offset` pair. No DOM, no Svelte — the ramp is assertable as
|
|
6
|
+
* numbers, which is the whole reason it is not inline in the component.
|
|
7
|
+
*
|
|
8
|
+
* In the app this came from, the same arithmetic lived in two modules:
|
|
9
|
+
* `scrollyUtils.ts` held `calcOpacity` / `calcTranslateY`, and a six-line
|
|
10
|
+
* `scrollySteps.ts` re-exported them zipped into one record. Two modules for one
|
|
11
|
+
* three-field value is a pass-through, not a seam, so they are one module here.
|
|
12
|
+
* Both halves stay exported: a caller that wants only the opacity ramp should
|
|
13
|
+
* not have to take the record.
|
|
14
|
+
*/
|
|
15
|
+
/** Everything one step needs to draw itself for the current scroll position. */
|
|
16
|
+
export type StepStyle = {
|
|
17
|
+
/** True when this step is the one the scroller currently reports. */
|
|
18
|
+
active: boolean;
|
|
19
|
+
/** Card opacity: 0 → 1 → 1 → 0 across the step's own scroll progress. */
|
|
20
|
+
opacity: number;
|
|
21
|
+
/** Card translateY in px: 30 → 0 → 0 → -30 across that same progress. */
|
|
22
|
+
translateY: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Card opacity across a step's scroll progress: fade in over the first quarter,
|
|
26
|
+
* hold through the middle half, fade out over the last quarter.
|
|
27
|
+
*
|
|
28
|
+
* @param stepIndex - Index of the step being drawn.
|
|
29
|
+
* @param currentIndex - Step index the scroller currently reports.
|
|
30
|
+
* @param currentOffset - Scroll progress within the current step, 0 → 1.
|
|
31
|
+
* @returns Opacity in `[0, 1]`; `0` for any step that is not current.
|
|
32
|
+
*/
|
|
33
|
+
export declare function calcOpacity(stepIndex: number, currentIndex: number, currentOffset: number): number;
|
|
34
|
+
/**
|
|
35
|
+
* Card translateY in px across the same progress: slides up on entry, holds
|
|
36
|
+
* through the plateau, slides further up on exit.
|
|
37
|
+
*
|
|
38
|
+
* @param stepIndex - Index of the step being drawn.
|
|
39
|
+
* @param currentIndex - Step index the scroller currently reports.
|
|
40
|
+
* @param currentOffset - Scroll progress within the current step, 0 → 1.
|
|
41
|
+
* @returns Offset in px; the full travel for any step that is not current, so
|
|
42
|
+
* an inactive card is parked below rather than mid-animation.
|
|
43
|
+
*/
|
|
44
|
+
export declare function calcTranslateY(stepIndex: number, currentIndex: number, currentOffset: number): number;
|
|
45
|
+
/**
|
|
46
|
+
* Projects the scroller's position onto one step.
|
|
47
|
+
*
|
|
48
|
+
* @param i - Index of the step being drawn.
|
|
49
|
+
* @param index - Step index the scroller currently reports.
|
|
50
|
+
* @param offset - Scroll progress within the current step, 0 → 1.
|
|
51
|
+
* @returns The step's `active` flag plus its card opacity and translateY.
|
|
52
|
+
*/
|
|
53
|
+
export declare function stepStyle(i: number, index: number, offset: number): StepStyle;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module scrolly/stepStyle
|
|
3
|
+
*
|
|
4
|
+
* The per-step style a scrolly card is drawn with, derived from the scroller's
|
|
5
|
+
* `index` / `offset` pair. No DOM, no Svelte — the ramp is assertable as
|
|
6
|
+
* numbers, which is the whole reason it is not inline in the component.
|
|
7
|
+
*
|
|
8
|
+
* In the app this came from, the same arithmetic lived in two modules:
|
|
9
|
+
* `scrollyUtils.ts` held `calcOpacity` / `calcTranslateY`, and a six-line
|
|
10
|
+
* `scrollySteps.ts` re-exported them zipped into one record. Two modules for one
|
|
11
|
+
* three-field value is a pass-through, not a seam, so they are one module here.
|
|
12
|
+
* Both halves stay exported: a caller that wants only the opacity ramp should
|
|
13
|
+
* not have to take the record.
|
|
14
|
+
*/
|
|
15
|
+
/** How far the card slides, in px, at the extremes of a step. */
|
|
16
|
+
const TRAVEL_PX = 30;
|
|
17
|
+
/**
|
|
18
|
+
* Card opacity across a step's scroll progress: fade in over the first quarter,
|
|
19
|
+
* hold through the middle half, fade out over the last quarter.
|
|
20
|
+
*
|
|
21
|
+
* @param stepIndex - Index of the step being drawn.
|
|
22
|
+
* @param currentIndex - Step index the scroller currently reports.
|
|
23
|
+
* @param currentOffset - Scroll progress within the current step, 0 → 1.
|
|
24
|
+
* @returns Opacity in `[0, 1]`; `0` for any step that is not current.
|
|
25
|
+
*/
|
|
26
|
+
export function calcOpacity(stepIndex, currentIndex, currentOffset) {
|
|
27
|
+
if (stepIndex !== currentIndex)
|
|
28
|
+
return 0;
|
|
29
|
+
return Math.max(0, Math.min(currentOffset * 4, 1, (1 - currentOffset) * 4));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Card translateY in px across the same progress: slides up on entry, holds
|
|
33
|
+
* through the plateau, slides further up on exit.
|
|
34
|
+
*
|
|
35
|
+
* @param stepIndex - Index of the step being drawn.
|
|
36
|
+
* @param currentIndex - Step index the scroller currently reports.
|
|
37
|
+
* @param currentOffset - Scroll progress within the current step, 0 → 1.
|
|
38
|
+
* @returns Offset in px; the full travel for any step that is not current, so
|
|
39
|
+
* an inactive card is parked below rather than mid-animation.
|
|
40
|
+
*/
|
|
41
|
+
export function calcTranslateY(stepIndex, currentIndex, currentOffset) {
|
|
42
|
+
if (stepIndex !== currentIndex)
|
|
43
|
+
return TRAVEL_PX;
|
|
44
|
+
if (currentOffset < 0.25)
|
|
45
|
+
return (1 - currentOffset * 4) * TRAVEL_PX;
|
|
46
|
+
if (currentOffset < 0.75)
|
|
47
|
+
return 0;
|
|
48
|
+
return -(currentOffset - 0.75) * 4 * TRAVEL_PX;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Projects the scroller's position onto one step.
|
|
52
|
+
*
|
|
53
|
+
* @param i - Index of the step being drawn.
|
|
54
|
+
* @param index - Step index the scroller currently reports.
|
|
55
|
+
* @param offset - Scroll progress within the current step, 0 → 1.
|
|
56
|
+
* @returns The step's `active` flag plus its card opacity and translateY.
|
|
57
|
+
*/
|
|
58
|
+
export function stepStyle(i, index, offset) {
|
|
59
|
+
return {
|
|
60
|
+
active: i === index,
|
|
61
|
+
opacity: calcOpacity(i, index, offset),
|
|
62
|
+
translateY: calcTranslateY(i, index, offset)
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@sveltejs/svelte-scroller` ships no type declarations, so the peer it is
|
|
3
|
+
* declared as is untyped without this. It lives under `src/lib` deliberately:
|
|
4
|
+
* `svelte-package` emits it into `dist`, so a consumer of this package inherits
|
|
5
|
+
* the declaration instead of having to write their own — which is what the app
|
|
6
|
+
* this was extracted from had to do.
|
|
7
|
+
*/
|
|
8
|
+
declare module '@sveltejs/svelte-scroller' {
|
|
9
|
+
import type { SvelteComponent } from 'svelte';
|
|
10
|
+
|
|
11
|
+
export default class Scroller extends SvelteComponent<{
|
|
12
|
+
top?: number;
|
|
13
|
+
bottom?: number;
|
|
14
|
+
threshold?: number;
|
|
15
|
+
query?: string;
|
|
16
|
+
parallax?: boolean;
|
|
17
|
+
index?: number;
|
|
18
|
+
count?: number;
|
|
19
|
+
offset?: number;
|
|
20
|
+
progress?: number;
|
|
21
|
+
visible?: boolean;
|
|
22
|
+
}> {}
|
|
23
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollytelling primitives: the scroller and its step ramp, a responsive
|
|
3
|
+
* crossfading video, step dots, and a frosted panel. Domain-free, and themed
|
|
4
|
+
* with CSS custom properties rather than utility classes.
|
|
5
|
+
*
|
|
6
|
+
* `<ScrollySteps>` needs the optional peer `@sveltejs/svelte-scroller`; nothing
|
|
7
|
+
* else here does, so a consumer that only wants `CrossfadeVideo` or the
|
|
8
|
+
* `stepStyle` ramp can skip it.
|
|
9
|
+
*/
|
|
10
|
+
export { default as ScrollySteps } from './components/scrolly/ScrollySteps.svelte';
|
|
11
|
+
export { default as ScrollyStepIndicator } from './components/scrolly/ScrollyStepIndicator.svelte';
|
|
12
|
+
export { default as CrossfadeVideo } from './components/scrolly/CrossfadeVideo.svelte';
|
|
13
|
+
export type { CrossfadeVideoHandle } from './components/scrolly/CrossfadeVideo.svelte';
|
|
14
|
+
export { default as GlassCard } from './components/scrolly/GlassCard.svelte';
|
|
15
|
+
export { calcOpacity, calcTranslateY, stepStyle, type StepStyle } from './components/scrolly/stepStyle.js';
|
package/dist/scrolly.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scrollytelling primitives: the scroller and its step ramp, a responsive
|
|
3
|
+
* crossfading video, step dots, and a frosted panel. Domain-free, and themed
|
|
4
|
+
* with CSS custom properties rather than utility classes.
|
|
5
|
+
*
|
|
6
|
+
* `<ScrollySteps>` needs the optional peer `@sveltejs/svelte-scroller`; nothing
|
|
7
|
+
* else here does, so a consumer that only wants `CrossfadeVideo` or the
|
|
8
|
+
* `stepStyle` ramp can skip it.
|
|
9
|
+
*/
|
|
10
|
+
export { default as ScrollySteps } from './components/scrolly/ScrollySteps.svelte';
|
|
11
|
+
export { default as ScrollyStepIndicator } from './components/scrolly/ScrollyStepIndicator.svelte';
|
|
12
|
+
export { default as CrossfadeVideo } from './components/scrolly/CrossfadeVideo.svelte';
|
|
13
|
+
export { default as GlassCard } from './components/scrolly/GlassCard.svelte';
|
|
14
|
+
export { calcOpacity, calcTranslateY, stepStyle } from './components/scrolly/stepStyle.js';
|
package/dist/styles/tokens.css
CHANGED
|
@@ -65,4 +65,18 @@
|
|
|
65
65
|
|
|
66
66
|
/* Motion */
|
|
67
67
|
--transition-fast: 150ms ease;
|
|
68
|
+
|
|
69
|
+
/* Scrollytelling (the `./scrolly` subpath) */
|
|
70
|
+
/* The sticky background's height. Mobile browsers change `100vh` when the
|
|
71
|
+
URL bar collapses, so a host that measures the real viewport sets this. */
|
|
72
|
+
--device-h: 100vh;
|
|
73
|
+
--dot-size: 0.375rem;
|
|
74
|
+
--dot-gap: var(--space-2);
|
|
75
|
+
--dot-color: currentColor;
|
|
76
|
+
--dot-active: var(--dot-color);
|
|
77
|
+
--glass-padding: var(--space-5);
|
|
78
|
+
--glass-blur: 4px;
|
|
79
|
+
--video-badge-padding: var(--space-3);
|
|
80
|
+
--video-badge-color: var(--color-ink);
|
|
81
|
+
--video-badge-bg: rgb(255 255 255 / 50%);
|
|
68
82
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vit-foundation/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.30.0",
|
|
4
4
|
"scripts": {
|
|
5
5
|
"dev": "vite dev",
|
|
6
6
|
"build": "vite build && npm run prepack",
|
|
@@ -91,10 +91,16 @@
|
|
|
91
91
|
"default": "./dist/testing/remote-form.js"
|
|
92
92
|
},
|
|
93
93
|
"./tokens.css": "./dist/styles/tokens.css",
|
|
94
|
-
"./base.css": "./dist/styles/base.css"
|
|
94
|
+
"./base.css": "./dist/styles/base.css",
|
|
95
|
+
"./scrolly": {
|
|
96
|
+
"types": "./dist/scrolly.d.ts",
|
|
97
|
+
"svelte": "./dist/scrolly.js",
|
|
98
|
+
"default": "./dist/scrolly.js"
|
|
99
|
+
}
|
|
95
100
|
},
|
|
96
101
|
"peerDependencies": {
|
|
97
|
-
"svelte": "^5.0.0"
|
|
102
|
+
"svelte": "^5.0.0",
|
|
103
|
+
"@sveltejs/svelte-scroller": "^2.0.0"
|
|
98
104
|
},
|
|
99
105
|
"devDependencies": {
|
|
100
106
|
"@chromatic-com/storybook": "^4.1.2",
|
|
@@ -109,6 +115,7 @@
|
|
|
109
115
|
"@sveltejs/adapter-auto": "^7.0.0",
|
|
110
116
|
"@sveltejs/kit": "^2.47.1",
|
|
111
117
|
"@sveltejs/package": "^2.5.4",
|
|
118
|
+
"@sveltejs/svelte-scroller": "^2.0.7",
|
|
112
119
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
|
113
120
|
"@types/node": "^22",
|
|
114
121
|
"@vitest/browser-playwright": "^4.0.5",
|
|
@@ -142,5 +149,10 @@
|
|
|
142
149
|
},
|
|
143
150
|
"publishConfig": {
|
|
144
151
|
"access": "public"
|
|
152
|
+
},
|
|
153
|
+
"peerDependenciesMeta": {
|
|
154
|
+
"@sveltejs/svelte-scroller": {
|
|
155
|
+
"optional": true
|
|
156
|
+
}
|
|
145
157
|
}
|
|
146
158
|
}
|