Here before
Some checks failed
Publish preview / build (push) Has been cancelled
Release / release (push) Has been cancelled
Release / notify-downstream (push) Has been cancelled

This commit is contained in:
Ben Aultowski 2026-02-17 11:13:11 -05:00
parent da66707338
commit 8887690d1c
29 changed files with 1452 additions and 830 deletions

View file

@ -14,5 +14,20 @@ const config: StorybookConfig = {
name: '@storybook/sveltekit', name: '@storybook/sveltekit',
options: {}, options: {},
}, },
async viteFinal(config) {
const { mergeConfig } = await import('vite');
const path = await import('node:path');
const fs = await import('node:fs');
const { fileURLToPath } = await import('node:url');
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const storybookTsconfigPath = path.resolve(__dirname, 'tsconfig.json');
const storybookTsconfigRaw = fs.readFileSync(storybookTsconfigPath, 'utf8');
return mergeConfig(config, {
esbuild: {
...config.esbuild,
tsconfigRaw: storybookTsconfigRaw,
},
});
},
}; };
export default config; export default config;

32
.storybook/tsconfig.json Normal file
View file

@ -0,0 +1,32 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"target": "ESNext",
"lib": [
"DOM",
"DOM.Iterable",
"ESNext"
],
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowJs": true,
"forceConsistentCasingInFileNames": true,
"verbatimModuleSyntax": true,
"rootDir": "..",
"baseUrl": ".."
},
"include": [
"../src/**/*.ts",
"../src/**/*.svelte",
"*.ts",
"*.svelte"
],
"exclude": [
"node_modules"
]
}

View file

@ -7,3 +7,13 @@
Svelte components, SCSS and more for Reuters Graphics pages. Svelte components, SCSS and more for Reuters Graphics pages.
[Read the docs.](https://reuters-graphics.github.io/graphics-components/) [Read the docs.](https://reuters-graphics.github.io/graphics-components/)
## Storybook
Run Storybook with:
```bash
pnpm run storybook
```
Do not run `storybook dev` directly. The script runs `svelte-kit sync` first so that `.svelte-kit/tsconfig.json` exists; otherwise the root tsconfigs `extends` fails and Storybook will error. The same applies to `pnpm run build:storybook`.

View file

@ -8,8 +8,8 @@
"dev": "svelte-kit sync && vite dev", "dev": "svelte-kit sync && vite dev",
"build": "svelte-kit sync && node scripts/copy-assets.js && vite build", "build": "svelte-kit sync && node scripts/copy-assets.js && vite build",
"preview": "vite preview", "preview": "vite preview",
"storybook": "svelte-kit sync && storybook dev -p 3000", "storybook": "node scripts/ensure-svelte-kit-tsconfig.cjs && svelte-kit sync && storybook dev -p 3000",
"build:storybook": "svelte-kit sync && storybook build -o docs", "build:storybook": "node scripts/ensure-svelte-kit-tsconfig.cjs && svelte-kit sync && storybook build -o docs",
"lint": "eslint --fix", "lint": "eslint --fix",
"format": "prettier . --write", "format": "prettier . --write",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json", "check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
@ -22,18 +22,18 @@
"node": ">=20.18" "node": ">=20.18"
}, },
"devDependencies": { "devDependencies": {
"@chromatic-com/storybook": "^3.2.6", "@chromatic-com/storybook": "^5.0.1",
"@reuters-graphics/yaks-eslint": "^0.1.1", "@reuters-graphics/yaks-eslint": "^0.1.1",
"@reuters-graphics/yaks-prettier": "^0.1.1", "@reuters-graphics/yaks-prettier": "^0.1.1",
"@storybook/addon-a11y": "^8.6.12", "@storybook/addon-a11y": "^10.2.8",
"@storybook/addon-essentials": "^8.6.12", "@storybook/addon-essentials": "^8.6.12",
"@storybook/addon-interactions": "^8.6.12", "@storybook/addon-interactions": "^8.6.12",
"@storybook/addon-svelte-csf": "5.0.0-next.28", "@storybook/addon-svelte-csf": "5.0.11",
"@storybook/blocks": "^8.6.12", "@storybook/blocks": "^8.6.12",
"@storybook/components": "^8.6.12", "@storybook/components": "^8.6.12",
"@storybook/manager-api": "^8.6.12", "@storybook/manager-api": "^8.6.12",
"@storybook/svelte": "^8.6.12", "@storybook/svelte": "^10.2.8",
"@storybook/sveltekit": "^8.6.12", "@storybook/sveltekit": "^10.2.8",
"@storybook/test": "^8.6.12", "@storybook/test": "^8.6.12",
"@storybook/theming": "^8.6.12", "@storybook/theming": "^8.6.12",
"@sveltejs/adapter-static": "^3.0.10", "@sveltejs/adapter-static": "^3.0.10",
@ -58,9 +58,10 @@
"eslint": "^9.25.0", "eslint": "^9.25.0",
"eslint-plugin-mdx": "^3.4.0", "eslint-plugin-mdx": "^3.4.0",
"eslint-plugin-react": "^7.37.5", "eslint-plugin-react": "^7.37.5",
"eslint-plugin-storybook": "^0.12.0", "eslint-plugin-storybook": "^10.2.8",
"knip": "^5.50.5", "knip": "^5.50.5",
"mermaid": "^10.9.3", "mermaid": "^10.9.3",
"playwright": "^1.58.2",
"postcss": "^8.5.3", "postcss": "^8.5.3",
"prettier": "^3.5.3", "prettier": "^3.5.3",
"prettier-plugin-svelte": "^3.3.3", "prettier-plugin-svelte": "^3.3.3",
@ -71,7 +72,7 @@
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-syntax-highlighter": "^15.6.1", "react-syntax-highlighter": "^15.6.1",
"sass": "^1.86.3", "sass": "^1.86.3",
"storybook": "^8.6.12", "storybook": "^10.2.8",
"svelte": "^5.28.1", "svelte": "^5.28.1",
"svelte-check": "^4.1.6", "svelte-check": "^4.1.6",
"typescript": "^5.8.3", "typescript": "^5.8.3",
@ -87,6 +88,8 @@
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"es-toolkit": "^1.35.0", "es-toolkit": "^1.35.0",
"journalize": "^2.6.0", "journalize": "^2.6.0",
"locomotive-scroll": "^5.0.1",
"motion": "^12.34.0",
"mp4box": "^0.5.4", "mp4box": "^0.5.4",
"proper-url-join": "^2.1.2", "proper-url-join": "^2.1.2",
"pym.js": "^1.3.2", "pym.js": "^1.3.2",
@ -97,4 +100,4 @@
"ua-parser-js": "^2.0.3", "ua-parser-js": "^2.0.3",
"vitest": "^3.2.4" "vitest": "^3.2.4"
} }
} }

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,107 @@
<script lang="ts">
let {
assetsPath = '/',
onAiMounted = () => {},
onArtboardChange = () => {},
/* taggedText = { text: {}, htext: {} },
debugTaggedText = false, */
artboardWidth = $bindable(undefined),
} = $props();
import { onMount, untrack } from 'svelte';
let aiBox: HTMLElement | undefined;
let screenWidth = $state(0);
let aiBoxWidth = $derived(artboardWidth ?? screenWidth);
let activeArtboard: HTMLElement | undefined = $state(undefined);
onMount(() => {
onAiMounted();
});
$effect(() => {
if (aiBoxWidth) {
const currentArtboard = (aiBox as HTMLElement).querySelectorAll(
'.g-artboard'
)[0] as HTMLElement;
if (currentArtboard?.id !== activeArtboard?.id) {
activeArtboard = untrack(() => currentArtboard);
onArtboardChange(activeArtboard);
}
}
});
</script>
<div
id="g-manor_xl-box"
class="ai2svelte"
bind:this={aiBox}
bind:clientWidth={aiBoxWidth}
>
<!-- Artboard: xl -->
<div
id="g-manor_xl-xl"
class="g-artboard"
style="aspect-ratio: 1.93548387096774;"
data-aspect-ratio="1.935"
data-min-width="0"
>
<div
id="g-manor_xl-xl-img"
class="g-manor_xl-xl-img g-aiImg"
alt="asdf"
style="background-image: url({assetsPath.replace(
new RegExp('/([^/.]+)$'),
'/$1/'
) || '/'}images/graphics/manor_xl-xl.jpg);"
></div>
</div>
</div>
<!-- End ai2svelte - 2026-02-15 17:58 -->
<!-- Generated by ai2svelte v1.0.3 - 2026-02-15 17:58 -->
<!-- ai file: manor_xl.ai -->
<style lang="scss">
#g-manor_xl-box,
#g-manor_xl-box .g-artboard {
margin: 0 auto;
}
#g-manor_xl-box p {
margin: 0;
}
#g-manor_xl-box .g-aiAbs {
position: absolute;
}
#g-manor_xl-box .g-aiImg {
position: absolute;
top: 0;
display: block;
width: 100% !important;
height: 100%;
background-size: contain;
background-repeat: no-repeat;
}
#g-manor_xl-box .g-aiSymbol {
position: absolute;
box-sizing: border-box;
}
#g-manor_xl-box .g-aiPointText p {
white-space: nowrap;
}
#g-manor_xl-box.g-aiPointText p {
white-space: nowrap;
}
#g-manor_xl-box {
width: 100%;
container-type: inline-size;
@container (min-width:0px) {
#g-manor_xl-xl {
display: block !important;
}
}
}
#g-manor_xl-xl {
position: relative;
overflow: hidden;
display: none;
}
/* Custom CSS */
</style>

BIN
public/stories/hello-world/manor_xl.jpg (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -9,7 +9,7 @@
import { readdirSync, cpSync, existsSync, mkdirSync, statSync } from 'node:fs'; import { readdirSync, cpSync, existsSync, mkdirSync, statSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
const CONTENT_DIR = resolve('content/stories'); const CONTENT_DIR = resolve('public/stories');
const STATIC_DIR = resolve('static/stories'); const STATIC_DIR = resolve('static/stories');
if (!existsSync(CONTENT_DIR)) { if (!existsSync(CONTENT_DIR)) {

View file

@ -0,0 +1,39 @@
/**
* Ensures .svelte-kit/tsconfig.json exists so the root tsconfig's "extends" resolves.
* Storybook's Vite pipeline resolves the root tsconfig; if .svelte-kit is missing (e.g.
* svelte-kit sync hasn't run), the build fails. This script writes a minimal fallback
* so the root extends succeed. svelte-kit sync will overwrite with the real file.
*/
const fs = require('fs');
const path = require('path');
const dir = path.join(process.cwd(), '.svelte-kit');
const file = path.join(dir, 'tsconfig.json');
const minimal = {
compilerOptions: {
module: 'ESNext',
moduleResolution: 'Bundler',
target: 'ESNext',
lib: ['DOM', 'DOM.Iterable', 'ESNext'],
strict: true,
skipLibCheck: true,
noEmit: true,
isolatedModules: true,
esModuleInterop: true,
resolveJsonModule: true,
allowJs: true,
forceConsistentCasingInFileNames: true,
verbatimModuleSyntax: true
},
include: [],
exclude: []
};
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
if (!fs.existsSync(file)) {
fs.writeFileSync(file, JSON.stringify(minimal, null, 2) + '\n', 'utf8');
console.log('Created .svelte-kit/tsconfig.json (minimal fallback for Storybook)');
}

View file

@ -0,0 +1,76 @@
import { inView, animate } from 'motion';
import type { AnimationConfig } from '$lib/lib/content';
function buildKeyframes(
initial: Record<string, unknown> | undefined,
animateTo: Record<string, unknown> | undefined
): Record<string, unknown> {
const target = animateTo ?? { opacity: 1 };
const from = initial ?? {};
const keyframes: Record<string, unknown> = {};
for (const key of Object.keys(target)) {
const fromVal = from[key];
const toVal = target[key];
if (fromVal !== undefined && fromVal !== toVal) {
keyframes[key] = [fromVal, toVal];
} else {
keyframes[key] = toVal;
}
}
return keyframes;
}
export default function motionInView(
node: HTMLElement,
config: AnimationConfig | undefined
): { update?: (config: AnimationConfig | undefined) => void; destroy?: () => void } {
if (!config || (!config.inView && !config.initial && !config.animate)) {
return {};
}
const initial = config.initial ?? {};
const animateTo = config.animate ?? { opacity: 1 };
const transition = config.transition ?? {};
const keyframes = buildKeyframes(initial, animateTo);
// Apply initial state so element is in initial position before in view
if (Object.keys(initial).length > 0) {
const style = node.style as unknown as Record<string, string>;
const x = Number(initial.x ?? 0);
const y = Number(initial.y ?? 0);
const scale = initial.scale;
const parts: string[] = [];
if (x !== 0 || y !== 0) parts.push(`translate(${x}px, ${y}px)`);
if (scale !== undefined) parts.push(`scale(${scale})`);
if (parts.length) style.transform = parts.join(' ');
if (initial.opacity !== undefined) style.opacity = String(initial.opacity);
}
const inViewOptions: Record<string, unknown> = {};
if (config.margin !== undefined) inViewOptions.margin = config.margin;
if (config.amount !== undefined) inViewOptions.amount = config.amount;
let stop: (() => void) | undefined = inView(
node,
() => {
const anim = animate(node, keyframes, {
duration: transition.duration,
delay: transition.delay,
});
return () => anim.stop();
},
inViewOptions as Parameters<typeof inView>[2]
);
return {
update(newConfig: AnimationConfig | undefined) {
if (!newConfig || !stop) return;
stop();
stop = undefined;
// Re-run with new config by re-applying action (caller typically re-mounts or we could re-init here)
},
destroy() {
stop?.();
},
};
}

View file

@ -0,0 +1,23 @@
<!-- Wraps content and applies Motion inView + animate when animation config is provided. -->
<script lang="ts">
import type { Snippet } from 'svelte';
import type { AnimationConfig } from '$lib/lib/content';
import motionInView from '../../actions/motionInView/index';
interface Props {
/** When set, the wrapper enters view via Motion inView and animates from initial to animate. */
animation?: AnimationConfig;
/** Content to wrap (e.g. BodyText, FeaturePhoto, GraphicBlock). */
children: Snippet;
}
let { animation, children }: Props = $props();
</script>
{#if animation?.inView || animation?.initial || animation?.animate}
<div use:motionInView={animation}>
{@render children()}
</div>
{:else}
{@render children()}
{/if}

View file

@ -0,0 +1,105 @@
<!--
@component ScrollScene
A sticky scroll container powered by CSS `position: sticky` and
Locomotive Scroll's `data-scroll-css-progress` for progress tracking.
The outer element is tall (`distance`) to create scroll room.
The inner `.stage` sticks at the top of the viewport.
As the user scrolls, `progress` goes 0→1 during the sticky phase.
Progress is exposed two ways:
- `--stage-progress` CSS variable on `.stage` (for CSS-driven animations)
- Bindable `progress` prop (for JS-driven animations in Svelte)
Usage:
<ScrollScene distance="300vh" bind:progress>
<MyAnimatedLayer {progress} />
</ScrollScene>
-->
<script lang="ts">
import { onMount } from 'svelte';
import type { Snippet } from 'svelte';
interface Props {
/** Total height of the scroll container (CSS value). Default "200vh". */
distance?: string;
/** Remapped progress 01 during the sticky phase. Bindable. */
progress?: number;
/** CSS class for the outer container. */
class?: string;
/** Content rendered inside the sticky stage. */
children: Snippet;
}
let {
distance = '200vh',
progress = $bindable(0),
class: cls = '',
children,
}: Props = $props();
let scene: HTMLElement | undefined = $state(undefined);
let stickStart = 0;
const eventName = `scene-${Math.random().toString(36).slice(2, 8)}`;
function clamp(v: number, min: number, max: number) {
return Math.min(max, Math.max(min, v));
}
function computeStickStart() {
if (!scene) return;
const H = scene.offsetHeight;
const V = window.innerHeight;
stickStart = H > 0 ? V / H : 0;
}
onMount(() => {
computeStickStart();
function onProgress(e: Event) {
const detail = (e as CustomEvent).detail;
const raw: number = detail?.progress ?? 0;
progress = clamp(
(raw - stickStart) / (1 - stickStart),
0,
1
);
}
window.addEventListener(eventName, onProgress);
window.addEventListener('resize', computeStickStart, { passive: true });
return () => {
window.removeEventListener(eventName, onProgress);
window.removeEventListener('resize', computeStickStart);
};
});
</script>
<div
bind:this={scene}
class="scroll-scene {cls}"
style:height={distance}
data-scroll
data-scroll-css-progress
data-scroll-event-progress={eventName}
>
<div class="stage" style:--stage-progress={progress}>
{@render children()}
</div>
</div>
<style lang="scss">
.scroll-scene {
position: relative;
}
.stage {
position: sticky;
top: 0;
height: 100vh;
overflow: hidden;
}
</style>

View file

@ -6,9 +6,11 @@
steps: ScrollerStep[]; steps: ScrollerStep[];
preload?: number; preload?: number;
stackBackground?: boolean; stackBackground?: boolean;
offset?: number;
progress?: number;
} }
let { index, steps, preload = 1, stackBackground = true }: Props = $props(); let { index, steps, preload = 1, stackBackground = true, offset = 0, progress = 0 }: Props = $props();
function showStep(i: number) { function showStep(i: number) {
if (preload === 0) return true; if (preload === 0) return true;
@ -29,7 +31,12 @@
class:visible={isVisible(i)} class:visible={isVisible(i)}
class:invisible={!isVisible(i)} class:invisible={!isVisible(i)}
> >
<step.background {...step.backgroundProps || {}}></step.background> <step.background
{...step.backgroundProps || {}}
index={i}
offset={offset}
progress={progress}
></step.background>
</div> </div>
{/if} {/if}
{/each} {/each}

View file

@ -127,7 +127,14 @@
class="background-container step-{index + class="background-container step-{index +
1} my-0 min-h-screen flex justify-center items-center relative" 1} my-0 min-h-screen flex justify-center items-center relative"
> >
<Background {index} {steps} {preload} {stackBackground} /> <Background
{index}
{steps}
{preload}
{stackBackground}
{offset}
{progress}
/>
</Block> </Block>
</div> </div>
</div> </div>

View file

@ -0,0 +1,134 @@
<script lang="ts">
interface Props {
imgSrc?: string;
/** SVG path `d` attribute for the foreground clip silhouette */
fgClipPath?: string;
/** Width of the original clip-path coordinate space (e.g. SVG viewBox width) */
clipWidth?: number;
/** Height of the original clip-path coordinate space (e.g. SVG viewBox height) */
clipHeight?: number;
index?: number;
offset?: number;
progress?: number;
}
let {
imgSrc = '',
fgClipPath = '',
clipWidth = 1,
clipHeight = 1,
index = 0,
offset = 0,
progress = 0,
}: Props = $props();
// Unique clip ID per instance so multiple scrollers don't collide
const clipId = `dolly-clip-${Math.random().toString(36).slice(2, 8)}`;
// Scale factors to convert SVG viewBox coords → objectBoundingBox 01 range
let scaleX = $derived(clipWidth > 0 ? 1 / clipWidth : 1);
let scaleY = $derived(clipHeight > 0 ? 1 / clipHeight : 1);
// Dolly zoom driven by scroll progress (0 → 1)
// Mirrors CodePen https://codepen.io/scharamoose/pen/PGpGez
//
// BG: starts zoomed in via translateZ(150), pulls back to 0, blurs
// FG: starts neutral at translateZ(0), pushes forward to 200, clipped by SVG silhouette
let bgZ = $derived(150 - progress * 150);
let bgBlur = $derived(progress * 6);
let fgZ = $derived(progress * 200);
/** CSS aspect-ratio so the clipped layer matches the path coordinate space (no stretch). */
let aspectRatioCss = $derived(`${clipWidth} / ${clipHeight}`);
</script>
{#if fgClipPath}
<svg class="clip-defs" aria-hidden="true">
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path transform="scale({scaleX}, {scaleY})" d={fgClipPath} />
</clipPath>
</defs>
</svg>
{/if}
<div class="dolly-zoom-container">
<!-- BG: full image, pulls back in Z, blurs -->
<div class="box bg-box">
<div
class="img"
style:background-image="url({imgSrc})"
style:transform="translateZ({bgZ}px)"
style:filter="blur({bgBlur}px)"
></div>
</div>
<!-- FG: same image, pushes forward in Z, clipped to silhouette.
Sized to clip aspect ratio so objectBoundingBox clip isn't stretched. -->
<div class="box fg-box">
<div
class="img img--clipped"
style:aspect-ratio={aspectRatioCss}
style:background-image="url({imgSrc})"
style:transform="translate(-50%, -50%) translateZ({fgZ}px)"
style:clip-path={fgClipPath ? `url(#${clipId})` : 'none'}
></div>
</div>
</div>
<style lang="scss">
.clip-defs {
position: absolute;
width: 0;
height: 0;
overflow: hidden;
}
.dolly-zoom-container {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 100vh;
overflow: hidden;
}
.box {
position: absolute;
inset: 0;
perspective: 400px;
overflow: hidden;
transform-style: preserve-3d;
}
.bg-box {
z-index: 0;
}
.fg-box {
z-index: 1;
}
.img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
min-height: 100vh;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
will-change: transform;
transition: transform 5s, filter 5s;
}
/* Foreground clipped layer: preserve clip-path aspect ratio so silhouette aligns with image */
.img--clipped {
inset: auto;
left: 50%;
top: 50%;
width: auto;
height: auto;
min-width: 100%;
min-height: 100%;
}
</style>

View file

@ -2,7 +2,7 @@ import { readdirSync, readFileSync, existsSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { parse } from '@rferl/veronica'; import { parse } from '@rferl/veronica';
const CONTENT_DIR = resolve('content/stories'); const CONTENT_DIR = resolve('public/stories');
// -- Types ------------------------------------------------------------------ // -- Types ------------------------------------------------------------------
@ -15,9 +15,19 @@ export type ContainerWidth =
| 'widest' | 'widest'
| 'fluid'; | 'fluid';
export interface AnimationConfig {
inView?: boolean;
margin?: string;
amount?: 'some' | 'all' | number;
initial?: Record<string, unknown>;
animate?: Record<string, unknown>;
transition?: { duration?: number; delay?: number };
}
export interface TextBlock { export interface TextBlock {
type: 'text'; type: 'text';
value: string; value: string;
animation?: AnimationConfig;
} }
export interface PhotoBlock { export interface PhotoBlock {
@ -26,6 +36,7 @@ export interface PhotoBlock {
alt: string; alt: string;
caption?: string; caption?: string;
width?: ContainerWidth; width?: ContainerWidth;
animation?: AnimationConfig;
} }
export interface GraphicBlock { export interface GraphicBlock {
@ -34,6 +45,7 @@ export interface GraphicBlock {
description?: string; description?: string;
notes?: string; notes?: string;
width?: ContainerWidth; width?: ContainerWidth;
animation?: AnimationConfig;
} }
export type BodyBlock = TextBlock | PhotoBlock | GraphicBlock; export type BodyBlock = TextBlock | PhotoBlock | GraphicBlock;
@ -65,6 +77,38 @@ export interface StoryData extends StoryMeta {
interface FreeformBlock { interface FreeformBlock {
type: string; type: string;
value: string | Record<string, string>; value: string | Record<string, string>;
[key: string]: unknown;
}
function parseAnimationConfig(raw: Record<string, unknown> | undefined): AnimationConfig | undefined {
if (!raw) return undefined;
const inView = raw.inView ?? raw['animation.inView'];
if (inView === undefined && !raw.margin && !raw['animation.margin'] && !raw.initial && !raw.animate) {
return undefined;
}
const margin = (raw.margin ?? raw['animation.margin']) as string | undefined;
const amount = (raw.amount ?? raw['animation.amount']) as 'some' | 'all' | number | undefined;
const initial = (raw.initial ?? raw['animation.initial']) as Record<string, unknown> | undefined;
const animate = (raw.animate ?? raw['animation.animate']) as Record<string, unknown> | undefined;
const transition = (raw.transition ?? raw['animation.transition']) as
| { duration?: number; delay?: number }
| undefined;
return {
inView: inView === true || inView === 'true',
margin,
amount,
initial,
animate,
transition,
};
}
function getBlockAnimation(rawBlock: FreeformBlock): AnimationConfig | undefined {
const nested = rawBlock.animation as Record<string, unknown> | undefined;
if (nested && typeof nested === 'object') {
return parseAnimationConfig(nested);
}
return parseAnimationConfig(rawBlock as unknown as Record<string, unknown>);
} }
function parseAuthors(raw: string | undefined): string[] { function parseAuthors(raw: string | undefined): string[] {
@ -87,9 +131,10 @@ function parseRaw(slug: string, raw: Record<string, unknown>): StoryData {
const rawBody = (raw.body ?? []) as FreeformBlock[]; const rawBody = (raw.body ?? []) as FreeformBlock[];
for (const block of rawBody) { for (const block of rawBody) {
const animation = getBlockAnimation(block);
switch (block.type) { switch (block.type) {
case 'text': case 'text':
body.push({ type: 'text', value: String(block.value ?? '') }); body.push({ type: 'text', value: String(block.value ?? ''), animation });
break; break;
case 'photo': { case 'photo': {
const v = block.value as Record<string, string>; const v = block.value as Record<string, string>;
@ -99,6 +144,7 @@ function parseRaw(slug: string, raw: Record<string, unknown>): StoryData {
alt: v.alt ?? '', alt: v.alt ?? '',
caption: v.caption, caption: v.caption,
width: (v.width as ContainerWidth) ?? undefined, width: (v.width as ContainerWidth) ?? undefined,
animation,
}); });
break; break;
} }
@ -110,6 +156,7 @@ function parseRaw(slug: string, raw: Record<string, unknown>): StoryData {
description: v.description, description: v.description,
notes: v.notes, notes: v.notes,
width: (v.width as ContainerWidth) ?? undefined, width: (v.width as ContainerWidth) ?? undefined,
animation,
}); });
break; break;
} }
@ -156,7 +203,7 @@ export function listStories(): StoryMeta[] {
const stories: StoryMeta[] = []; const stories: StoryMeta[] = [];
for (const slug of slugs) { for (const slug of slugs) {
const amlPath = join(CONTENT_DIR, slug, 'story.aml'); const amlPath = join(CONTENT_DIR, slug, `${slug}.aml`);
if (!existsSync(amlPath)) continue; if (!existsSync(amlPath)) continue;
const text = readFileSync(amlPath, 'utf-8'); const text = readFileSync(amlPath, 'utf-8');
@ -182,7 +229,7 @@ export function listStories(): StoryMeta[] {
} }
export function loadStory(slug: string): StoryData | null { export function loadStory(slug: string): StoryData | null {
const amlPath = join(CONTENT_DIR, slug, 'story.aml'); const amlPath = join(CONTENT_DIR, slug, `${slug}.aml`);
if (!existsSync(amlPath)) return null; if (!existsSync(amlPath)) return null;
const text = readFileSync(amlPath, 'utf-8'); const text = readFileSync(amlPath, 'utf-8');
@ -193,6 +240,6 @@ export function loadStory(slug: string): StoryData | null {
export function getAllSlugs(): string[] { export function getAllSlugs(): string[] {
if (!existsSync(CONTENT_DIR)) return []; if (!existsSync(CONTENT_DIR)) return [];
return readdirSync(CONTENT_DIR, { withFileTypes: true }) return readdirSync(CONTENT_DIR, { withFileTypes: true })
.filter((d) => d.isDirectory() && existsSync(join(CONTENT_DIR, d.name, 'story.aml'))) .filter((d) => d.isDirectory() && existsSync(join(CONTENT_DIR, d.name, `${d.name}.aml`)))
.map((d) => d.name); .map((d) => d.name);
} }

1
src/lib/cutoutStore.ts Normal file
View file

@ -0,0 +1 @@
export const svgPathMap = new Map<string, string>();

37
src/lib/locomotive.ts Normal file
View file

@ -0,0 +1,37 @@
/**
* Locomotive Scroll singleton for the app.
*
* Initialized once, re-scanned on page navigation so new `data-scroll`
* elements are picked up. Smooth scrolling is disabled we only use
* Locomotive Scroll for viewport detection, progress tracking
* (`data-scroll-css-progress`), and parallax (`data-scroll-speed`).
*/
import { browser } from '$app/environment';
let instance: InstanceType<typeof import('locomotive-scroll').default> | null =
null;
export async function initScroll() {
if (!browser) return null;
destroyScroll();
const { default: LocomotiveScroll } = await import('locomotive-scroll');
instance = new LocomotiveScroll({
lenisOptions: {
smoothWheel: false,
smoothTouch: false,
},
});
return instance;
}
export function destroyScroll() {
if (instance) {
instance.destroy();
instance = null;
}
}
export function getScroll() {
return instance;
}

View file

@ -1,13 +1,26 @@
<script lang="ts"> <script lang="ts">
import { onMount, tick } from 'svelte';
import { afterNavigate } from '$app/navigation';
import { initScroll, destroyScroll } from '$lib/lib/locomotive';
import Theme from '$lib/components/Theme/Theme.svelte'; import Theme from '$lib/components/Theme/Theme.svelte';
import SiteHeader from '$lib/components/SiteHeader/SiteHeader.svelte'; import SiteHeader from '$lib/components/SiteHeader/SiteHeader.svelte';
import SiteFooter from '$lib/components/SiteFooter/SiteFooter.svelte'; import SiteFooter from '$lib/components/SiteFooter/SiteFooter.svelte';
import '$lib/scss/main.scss'; import '$lib/scss/main.scss';
let { children } = $props(); let { children } = $props();
onMount(() => {
initScroll();
return destroyScroll;
});
afterNavigate(async () => {
await tick();
initScroll();
});
</script> </script>
<Theme base="light"> <Theme base="dark">
<SiteHeader /> <SiteHeader />
<div id="main-content" class="site-content"> <div id="main-content" class="site-content">
{@render children()} {@render children()}

View file

@ -2,6 +2,8 @@ import { loadStory, getAllSlugs } from '$lib/lib/content';
import { error } from '@sveltejs/kit'; import { error } from '@sveltejs/kit';
import type { PageServerLoad, EntryGenerator } from './$types'; import type { PageServerLoad, EntryGenerator } from './$types';
export const prerender = 'auto';
export const load: PageServerLoad = async ({ params }) => { export const load: PageServerLoad = async ({ params }) => {
const story = loadStory(params.slug); const story = loadStory(params.slug);
if (!story) { if (!story) {

View file

@ -5,6 +5,7 @@
import FeaturePhoto from '$lib/components/FeaturePhoto/FeaturePhoto.svelte'; import FeaturePhoto from '$lib/components/FeaturePhoto/FeaturePhoto.svelte';
import GraphicBlock from '$lib/components/GraphicBlock/GraphicBlock.svelte'; import GraphicBlock from '$lib/components/GraphicBlock/GraphicBlock.svelte';
import EndNotes from '$lib/components/EndNotes/EndNotes.svelte'; import EndNotes from '$lib/components/EndNotes/EndNotes.svelte';
import AnimatedBlock from '$lib/components/AnimatedBlock/AnimatedBlock.svelte';
let { data } = $props(); let { data } = $props();
const story = data.story; const story = data.story;
@ -48,23 +49,29 @@
{/if} {/if}
{#each story.body as block} {#each story.body as block}
{#if block.type === 'text'} <AnimatedBlock animation={block.animation}>
<BodyText text={block.value} /> {#if block.type === 'text'}
{:else if block.type === 'photo'} <BodyText text={block.value} />
<FeaturePhoto {:else if block.type === 'photo'}
src={block.src} <FeaturePhoto
altText={block.alt} src={block.src}
caption={block.caption} altText={block.alt}
width={block.width ?? 'normal'} caption={block.caption}
/> width={block.width ?? 'normal'}
{:else if block.type === 'graphic'} />
<GraphicBlock {:else if block.type === 'graphic'}
title={block.title} <GraphicBlock
description={block.description} title={block.title}
notes={block.notes} description={block.description}
width={block.width ?? 'normal'} notes={block.notes}
/> width={block.width ?? 'normal'}
{/if} >
{#snippet children()}
<!-- Graphic content placeholder when block has no custom children -->
{/snippet}
</GraphicBlock>
{/if}
</AnimatedBlock>
{/each} {/each}
{#if story.endNotes.length > 0} {#if story.endNotes.length > 0}

View file

@ -0,0 +1,52 @@
<script lang="ts">
import ScrollScene from '$lib/components/ScrollScene/ScrollScene.svelte';
import DollyZoomBackground from '$lib/components/Scroller/demo/components/dolly-zoom/DollyZoomBackground.svelte';
const manorImgSrc = '/stories/hello-world/manor.jpg';
// SVG silhouette clip-path from manort.svg (viewBox 2048×1367)
const manorClipPath =
'm0 428 7.326-11.25 3 5.75 3.75 7.75 6-4 3.25-7.75 6-8.25 5.75 4.5 1 5.75 2.75 6.75 4.5 8.25-3.25 3.25 3 9.75 4.25 9.25 3.25 2-2.25 4.5 1.75 8-4.25 1 .5 3.5v3.75l5-.25-.5 3.25v3.25l-2.5 5 2.5 4.25-1.75 3.75 3.5 6.75.5 5.25-3.75 4.25 2.75 6 2.75 6 3.75 5.75.75-9.5 3.25-6.75-2.75-4.25 2.25-3.75 2.25-4.5 1.75-17.25.25-10.75 10-21.75.25-7.25 14.25-16.5 7 21.5 2.75 13 5.5 4.5.5 11 4 3.25-.75 3-2.75 1.25 1.25 4 1.25 3.75-4 3 4.75 7.25-.25 6.5.5 6.5 2 2.75-3.75 2 1.25 10.5 3 11.75 5 21.25 2 11-1.25 11 5 4-1 8.25-.5 11-.5 12.75.75 29.5-3.25 15.25 3.75 5.5 2 6.5 2.5-3.5 21.75-60.75 6.5-3.25 86.25 6.5 165.5 11.5 106.25 2.5v-7.25l-7.75-1.5-8-2.75.25-4 17-29.75 2.5-19.5 5.25-21 2.25-8 .25-125-1.75-23.5 37.25-20.25 32.75 4.25-.5 21.25-3 2 .25 23.25 14.75 10.75-1 3.25-3 .5-.25 28.5 6 1.5 63 9 149 15.5 155.75 15.25 70.504 3.75-2-49.25-1.5-2.75 29-9.75 29.25 4.75-.25 3.25-.75 15-.25 40.25 3.5 3.25 9 4.25 97.5 12 84.5 9 12.75-.75-2-35.5-3-4.25-.5-10.25 38.75-10.75 19.25 3.25v17l-1.5 4.75 1.5 59 83.25 62.25 5 5.5-1 3.25-2 5.25-14 14.25-2.5 3 5.25 173 8.25 1.5 18-7 22.75 5.25 8.75 8 41.75 4.75 6.5 7.75 2.75 5.5 25.25-1 26.75 11 33.25 24.5 7.25 9.5 36.25-3.25 30.75-1.25 58.45 19.25 81.55 1 39-7 11.75-1.4-1-37.1s-9.25-6.25-8-15.75 7.25-13.25 8.5-11.5 7.5 6.5 8.25 9.5-4.25 17.25-3 14.5-1 6.25-1 6.25l1.25 33.75 7.9 3.6s26.7 3.5 41.7 4.4c15.4.924 25.17-12.982 48.15-11.5 31 2 53.17 9.5 53.17 9.5l.5 453H0l1.451-908.5L0 428Z';
let progress = $state(0);
</script>
<svelte:head>
<title>Hello World — Dolly Zoom</title>
<meta
name="description"
content="A scroll-driven dolly zoom animation demo using the Manor image."
/>
</svelte:head>
<main class="hello-world-page">
<h1 class="visually-hidden">Hello World: Dolly Zoom Demo</h1>
<ScrollScene distance="300vh" bind:progress>
<DollyZoomBackground
imgSrc={manorImgSrc}
fgClipPath={manorClipPath}
clipWidth={2048}
clipHeight={1367}
{progress}
/>
</ScrollScene>
</main>
<style lang="scss">
.hello-world-page {
min-height: 100vh;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>

BIN
src/stories/hello-world/ai2html.ait (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/stories/hello-world/dotcom.ait (Stored with Git LFS) Normal file

Binary file not shown.

BIN
src/stories/hello-world/manor.ai (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -0,0 +1,48 @@
<script module lang="ts">
import { defineMeta } from '@storybook/addon-svelte-csf';
import Scroller from '$lib/components/Scroller/Scroller.svelte';
import DollyZoomBackground from '$lib/components/Scroller/demo/components/dolly-zoom/DollyZoomBackground.svelte';
const { Story } = defineMeta({
title: 'Hello World/Scroller Dolly Zoom',
component: Scroller,
});
</script>
<script lang="ts">
const manorImgSrc = '/stories/hello-world/manor_xl.jpg';
const steps = [
{
background: DollyZoomBackground,
backgroundProps: { imgSrc: manorImgSrc },
foreground: `#### Welcome to the Manor
Scroll down to experience the **Dolly Zoom** effect — a vertigo-like transformation inspired by [Alfred Hitchcock's technique](https://codepen.io/scharamoose/pen/PGpGez).
As you scroll, the image layers will shift in depth: the foreground zooms toward you while the background pulls away and softens.`,
altText:
'The Manor at Swan Harbour. A scroll-driven dolly zoom effect animates the image depth.',
},
{
background: DollyZoomBackground,
backgroundProps: { imgSrc: manorImgSrc },
foreground: `#### Step 2 — The Effect Intensifies
Keep scrolling. The perspective distortion creates a disorienting, cinematic sensation.`,
altText: 'The manor image with increased dolly zoom effect.',
},
{
background: DollyZoomBackground,
backgroundProps: { imgSrc: manorImgSrc },
foreground: `#### Step 3 — Full Vertigo
At full scroll, the foreground pushes forward while the background blurs into the distance.`,
altText: 'Maximum dolly zoom effect on the manor image.',
},
];
</script>
<Story name="Manor Dolly Zoom" exportName="ManorDollyZoom">
<Scroller steps={steps} foregroundPosition="middle" backgroundWidth="fluid" />
</Story>

BIN
src/stories/hello-world/sharecard.ait (Stored with Git LFS) Normal file

Binary file not shown.

View file

@ -9,7 +9,7 @@ const config = {
assets: 'build', assets: 'build',
fallback: undefined, fallback: undefined,
precompress: false, precompress: false,
strict: true, strict: false,
}), }),
files: { files: {
lib: 'src', lib: 'src',