Compare commits

...

7 commits

Author SHA1 Message Date
saiminh
e277b33bd8 replace manual -> work navigating via window with goto() 2026-01-23 12:10:56 +13:00
saiminh
d49773134f color change 2026-01-22 18:43:26 +13:00
saiminh
d7cab5d526 upgrades and updates 2026-01-22 17:24:51 +13:00
saiminh
2149372530 upgrade to pixi8 2026-01-19 17:23:26 +13:00
saiminh
01b217a2a5 update onclick syntax 2026-01-19 10:04:56 +13:00
saiminh
06a5dd0232 state -> derived 2026-01-19 10:04:39 +13:00
saiminh
e024c4258a linter error 2026-01-19 10:03:51 +13:00
25 changed files with 475 additions and 1763 deletions

1226
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -38,14 +38,10 @@
},
"type": "module",
"dependencies": {
"@pixi/core": "^7.4.3",
"@pixi/filter-alpha": "^7.4.3",
"@pixi/filter-blur": "^7.4.3",
"@pixi/unsafe-eval": "^7.4.3",
"gsap": "^3.13.0",
"mdsvex": "^0.11.0",
"pixi-filters": "^5.2.1",
"pixi.js": "^7.2.4",
"pixi.js": "^8.15.0",
"pixi-filters": "^6.1.5",
"superjson": "^1.13.1",
"svelte-cloudinary": "^1.1.0"
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import '$lib/utils/pixiInit'; // Initialize PixiJS settings before importing filters
import * as PIXI from 'pixi.js';
import { BulgePinchFilter, TwistFilter } from 'pixi-filters';
import * as filters from 'pixi-filters';
import gsap from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { SplitText } from 'gsap/SplitText';
@ -23,6 +23,9 @@ let app: PIXI.Application;
let canvas: HTMLCanvasElement;
onMount(()=>{
let cleanup: (() => void) | undefined;
(async ()=>{
function xFrac(x: number){
return window.innerWidth * x;
@ -39,13 +42,14 @@ onMount(()=>{
gsap.registerPlugin(PixiPlugin, ScrollTrigger, SplitText);
app = new PIXI.Application({
app = new PIXI.Application();
await app.init({
canvas: canvas,
resizeTo: window,
antialias: true,
autoDensity: true,
resolution: 2,
backgroundAlpha: 0,
view: canvas,
});
//for debugging but Typescript has an issue with this:
@ -60,9 +64,8 @@ onMount(()=>{
app.stage.addChild(bulgegroup);
let bulgebg = new PIXI.Graphics();
bulgebg.beginFill('rgb(0, 0, 0)');
bulgebg.drawRect(0, 0, xFrac(1), yFrac(1));
bulgebg.endFill();
bulgebg.rect(0, 0, xFrac(1), yFrac(1));
bulgebg.fill('rgb(0, 0, 0)');
bulgebg.alpha = 0;
bulgebg.pivot.set(xFrac(.5), yFrac(.5));
bulgebg.x = xFrac(0.5);
@ -71,13 +74,13 @@ onMount(()=>{
let center = [0.5, 0.5];
let bulgefilter = new BulgePinchFilter();
let bulgefilter = new filters.BulgePinchFilter();
bulgefilter.radius = is_landscape ? xFrac(0.5) : xFrac(0.55);
bulgefilter.strength = 0.5;
bulgefilter.center = is_landscape ? center : [0.5, 0];
bulgefilter.resolution = 2;
let twistfilter = new TwistFilter();
let twistfilter = new filters.TwistFilter();
twistfilter.angle = 0;
twistfilter.radius = is_landscape ? window.innerWidth/4 : window.innerWidth/2;
twistfilter.offset = new PIXI.Point(window.innerWidth/2, window.innerHeight/3);
@ -207,8 +210,8 @@ onMount(()=>{
* ----------------------------------*/
let elapsed = 0.0;
app.ticker.add((delta) => {
elapsed += delta;
app.ticker.add((ticker) => {
elapsed += ticker.deltaTime;
if (!is_landscape) {
// bulgefilter.center = [(tween.x + Math.sin(elapsed/200)/20 ),(tween.y + Math.cos(elapsed/200)/20 )];
let movingCenter = [(0.5 + Math.sin(elapsed/200)/20 ),(0.33 + Math.cos(elapsed/200)/20 )];
@ -229,11 +232,17 @@ onMount(()=>{
window.addEventListener('resize', (e) => {
tween.y = is_fine ? 0.5 : 250/window.innerHeight;
})
return () => {
cleanup = () => {
gsap.killTweensOf(imgElems);
gsap.killTweensOf(elems);
ScrollTrigger.getAll().forEach( instance => instance.kill() );
}
})();
return () => {
if (cleanup) cleanup();
}
}) // <- end onMount
onDestroy(() => {

View file

@ -63,8 +63,8 @@
</script>
<details bind:this={details}>
<!-- svelte-ignore a11y-no-static-element-interactions -->
<summary on:click={summaryClickHandler} on:keyup={summaryClickHandler}>{@html summary}</summary>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<summary onclick={summaryClickHandler} onkeyup={summaryClickHandler}>{@html summary}</summary>
<div class="faq-content">
{@render children()}
</div>

View file

@ -23,6 +23,9 @@ let app: PIXI.Application;
let canvas: HTMLCanvasElement;
onMount(()=>{
let cleanup: (() => void) | undefined;
(async ()=>{
function xFrac(x: number){
return window.innerWidth * x;
@ -38,13 +41,14 @@ onMount(()=>{
gsap.registerPlugin(PixiPlugin, ScrollTrigger, SplitText);
app = new PIXI.Application({
app = new PIXI.Application();
await app.init({
canvas: canvas,
resizeTo: document.querySelector('.canvasResizeToThis') as HTMLElement,
antialias: true,
autoDensity: true,
resolution: 2,
backgroundAlpha: 0,
view: canvas
});
//for debugging but Typescript has an issue with this:
@ -59,9 +63,8 @@ onMount(()=>{
app.stage.addChild(bulgegroup);
let bulgebg = new PIXI.Graphics();
bulgebg.beginFill('rgb(0, 0, 0)');
bulgebg.drawRect(0, 0, xFrac(1.2), yFrac(1.2));
bulgebg.endFill();
bulgebg.rect(0, 0, xFrac(1.2), yFrac(1.2));
bulgebg.fill('rgb(0, 0, 0)');
bulgebg.alpha = 0;
bulgebg.pivot.set(xFrac(.5), yFrac(.5));
bulgebg.x = xFrac(0.5);
@ -202,8 +205,8 @@ onMount(()=>{
* ----------------------------------*/
let elapsed = 0.0;
app.ticker.add((delta) => {
elapsed += delta;
app.ticker.add((ticker) => {
elapsed += ticker.deltaTime;
if (is_portrait) {
bulgefilter.center = [(0.5 + Math.sin(elapsed/200)/40 ),(0.45 + Math.cos(elapsed/200)/20 )];
// bulgefilter.center = [0.5, 0.5];
@ -218,11 +221,17 @@ onMount(()=>{
window.addEventListener('resize', (e) => {
tween.y = is_fine ? 0.5 : 250/window.innerHeight;
})
return () => {
cleanup = () => {
gsap.killTweensOf(imgElems);
gsap.killTweensOf(elems);
ScrollTrigger.getAll().forEach( instance => instance.kill() );
}
})();
return () => {
if (cleanup) cleanup();
}
}) // <- end onMount
onDestroy(() => {

View file

@ -72,6 +72,7 @@
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
<!-- <HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
@ -79,8 +80,7 @@
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape />
<HomeIlluShape /> -->
</div>
<style>
@ -90,7 +90,7 @@
gap: clamp(1px, 0.3vw, 5px);
}
:global(.home-illu-shapes > *) {
flex-basis: calc(20% - clamp(1px, 0.3vw, 5px));
flex-basis: calc(33.333% - clamp(1px, 0.3vw, 5px));
opacity: 0;
}
</style>

View file

@ -1,7 +1,7 @@
<script lang='ts'>
import { onMount } from "svelte";
import { gsap } from "gsap";
import { page } from "$app/stores";
import { page } from "$app/state";
onMount(() => {
@ -16,7 +16,7 @@
let navlinks = document.querySelectorAll('nav a');
navlinks.forEach((link, index) => {
link.addEventListener('click', (e)=> {
if (e.target?.toString() === $page.url.toString()) {
if (e.target?.toString() === page.url.toString()) {
return;
}
const checkbox = document.querySelector('input[type="checkbox"]') as HTMLInputElement;
@ -52,11 +52,11 @@
<span class="close">×</span>
</label>
<nav id="nav">
<a href="/" class="navlink {$page.route.id === '/' ? 'current' : ''}">Home</a>
<a href="/work" class="navlink {$page.route.id?.includes('/work') ? 'current' : ''}">Work</a>
<a href="/service" class="navlink {$page.route.id === '/service' ? 'current' : ''}">Services</a>
<!-- <a href="/about" class="navlink {$page.route.id === '/about' ? 'current' : ''}">About</a> -->
<a href="/contact" class="navlink {$page.route.id === '/contact' ? 'current' : ''}">Contact</a>
<a href="/" class="navlink {page.route.id === '/' ? 'current' : ''}">Home</a>
<a href="/work" class="navlink {page.route.id?.includes('/work') ? 'current' : ''}">Work</a>
<a href="/service" class="navlink {page.route.id === '/service' ? 'current' : ''}">Services</a>
<!-- <a href="/about" class="navlink {page.route.id === '/about' ? 'current' : ''}">About</a> -->
<a href="/contact" class="navlink {page.route.id === '/contact' ? 'current' : ''}">Contact</a>
</nav>
</header>

View file

@ -25,6 +25,9 @@ let app: PIXI.Application;
let canvas: HTMLCanvasElement;
onMount(() => {
let cleanup: (() => void) | undefined;
(async () => {
let is_fine = window.matchMedia('(pointer:fine)').matches
let is_landscape = window.matchMedia('(orientation:landscape)').matches
@ -37,14 +40,15 @@ onMount( () => {
const highlightColorFromRoot = () => { return getComputedStyle(root).getPropertyValue('--color-highlight') || 'rgb(0, 0, 0)' };
const thisElemBgColor = (elem: HTMLElement) => { return getComputedStyle(elem).getPropertyValue('background-color') || 'rgb(255, 255, 255)' };
app = new PIXI.Application({
app = new PIXI.Application();
await app.init({
canvas: canvas,
resizeTo: window,
antialias: true,
autoDensity: true,
resolution: 2,
backgroundColor: bgColorFromRoot(),
backgroundAlpha: 0,
view: canvas,
});
//for debugging but Typescript has an issue with this:
@ -68,9 +72,8 @@ onMount( () => {
let group_background = new PIXI.Graphics();
function draw_group_background(group_background: PIXI.Graphics) {
group_background.clear();
group_background.beginFill(bgColorFromRoot());
group_background.drawRect(0, 0, xFrac(1), yFrac(1));
group_background.endFill();
group_background.rect(0, 0, xFrac(1), yFrac(1));
group_background.fill(bgColorFromRoot());
group_background.alpha = 0;
group_background.pivot.set(xFrac(.5), yFrac(.5));
group_background.x = xFrac(0.5);
@ -162,9 +165,8 @@ onMount( () => {
workinfos.forEach( workinfo => {
const workinfoRect = workinfo.getBoundingClientRect();
let workinfoGraphic = new PIXI.Graphics();
workinfoGraphic.beginFill(textColorFromRoot());
workinfoGraphic.drawRect(workinfoRect.x, workinfoRect.y, workinfoRect.width, workinfoRect.height);
workinfoGraphic.endFill();
workinfoGraphic.rect(workinfoRect.x, workinfoRect.y, workinfoRect.width, workinfoRect.height);
workinfoGraphic.fill(textColorFromRoot());
workinfoGraphic.pivot.set(workinfoRect.x, workinfoRect.y);
workinfoBgs.push(workinfoGraphic);
app.stage.addChild(workinfoGraphic);
@ -178,9 +180,8 @@ onMount( () => {
workinfos.forEach((workinfo, index) => {
const workinfoRect = workinfo.getBoundingClientRect();
workinfoBgs[index].clear();
workinfoBgs[index].beginFill(thisElemBgColor(workinfo as HTMLElement));
workinfoBgs[index].drawRect(workinfoRect.x, workinfoRect.y, workinfoRect.width, workinfoRect.height);
workinfoBgs[index].endFill();
workinfoBgs[index].rect(workinfoRect.x, workinfoRect.y, workinfoRect.width, workinfoRect.height);
workinfoBgs[index].fill(thisElemBgColor(workinfo as HTMLElement));
workinfoBgs[index].pivot.set(workinfoRect.x, workinfoRect.y);
workinfoBgs[index].position.set(workinfoRect.x - ((tween.x - 0.5) * 50), workinfoRect.y - ((tween.y - 0.5) * 50));
workinfoBgs[index].alpha = window.getComputedStyle(workinfo).opacity as unknown as number;
@ -224,8 +225,8 @@ onMount( () => {
* ----------------------------------*/
let elapsed = 0.0;
app.ticker.add((delta) => {
elapsed += delta;
app.ticker.add((ticker) => {
elapsed += ticker.deltaTime;
// bulgefilter.center = center;
// bulgefilter.center = [(center[0] + Math.sin(elapsed/200)/20 ),(center[1] + Math.cos(elapsed/200)/20 )];
bulgefilter.center = [tween.x, tween.y];
@ -233,7 +234,19 @@ onMount( () => {
updateImgs();
updateText();
updateWorkInfoBgs();
})
});
cleanup = () => {
gsap.killTweensOf(imgElems);
gsap.killTweensOf(elems);
gsap.killTweensOf(tween);
ScrollTrigger.getAll().forEach( instance => instance.kill() );
};
})();
return () => {
if (cleanup) cleanup();
};
}) // <- end onMount
onDestroy(() => {

View file

@ -1,9 +1,26 @@
:root {
--spacing-outer: 5vw;
--spacing-nav: 5vw;
--color-bg: rgb(207, 63, 63);
--color-bg: rgb(207, 63, 70);;
--color-text: rgb(255, 234, 217);
--color-highlight: rgb(29, 12, 18);
--color-bg-variant-1: rgb(207, 63, 70);
--color-text-variant-1: rgb(255, 234, 217);
--color-highlight-variant-1: rgb(29, 12, 18);
--color-bg-variant-2: rgb(63, 111, 207);
--color-text-variant-2: rgb(255, 228, 207);
--color-highlight-variant-2: rgb(57, 0, 17);
--color-bg-variant-3: rgb(34, 169, 49);
--color-text-variant-3: rgb(255, 232, 168);
--color-highlight-variant-3: rgb(36, 0, 0);
--color-bg-variant-4: rgb(238, 127, 1);
--color-text-variant-4: rgb(253, 254, 255);
--color-highlight-variant-4: rgb(0, 39, 67);
--aspect-ratio-heroes: 1.5;
--font-size-p: clamp(20px, 1.6vw, 1.6vw);
@ -41,6 +58,7 @@ body {
min-height: 100svh;
max-width: 100vw;
overflow-x: hidden;
transition: background-color .5s ease-in-out, color .5s ease-in-out;
}
body * {
box-sizing: border-box;

View file

@ -14,7 +14,7 @@ export default function createCanvasText(
if (elemSrc.includes('.svg')) {
scalefactor = Number(elem.attributes.getNamedItem('data-svgscale')?.value);
const canvasImgTexture = PIXI.Texture.from(elemSrc, { resourceOptions: { scale: scalefactor } });
const canvasImgTexture = PIXI.Texture.from(elemSrc, true as any);
// Use provided sprite object or create a new one
if (spriteObject) {

View file

@ -26,7 +26,7 @@ export default function createCanvasText(
// Use provided text object or create a new one
const canvasText = textObject || new Text(elem.textContent as string, {
fontFamily: elemFontFamily,
fontSize: elemFontSize,
fontSize: parseInt(elemFontSize),
fontWeight: elemFontWeight as PIXI.TextStyleFontWeight,
fontStyle: elemFontStyle as PIXI.TextStyleFontStyle,
letterSpacing: elemLetterSpacing,

View file

@ -7,11 +7,4 @@ import * as PIXI from 'pixi.js';
// Set default resolution early to avoid deprecation warnings
// from filter packages that use the deprecated settings.FILTER_RESOLUTION
PIXI.Filter.defaultResolution = 2;
// Also set the deprecated setting for backward compatibility with older packages
// This prevents warnings from packages like @pixi/filter-advanced-bloom that
// still check the deprecated settings.FILTER_RESOLUTION during module initialization
if (PIXI.settings && 'FILTER_RESOLUTION' in PIXI.settings) {
(PIXI.settings as any).FILTER_RESOLUTION = 2;
}
(PIXI.Filter as any).defaultResolution = 2;

View file

@ -1,40 +1,106 @@
<script lang='ts'>
<script lang="ts">
import '$lib/styles/global.scss';
import Header from '$lib/components/Header.svelte';
import Loader from '$lib/components/Loader.svelte';
import { navigating, page } from '$app/stores';
import { onMount } from 'svelte';
import { page } from '$app/state';
import { beforeNavigate } from '$app/navigation';
import { browser } from '$app/environment';
import type { Snippet } from 'svelte';
import { onMount } from 'svelte';
interface LayoutData {
pathname: string;
}
let { data, children }: { data: LayoutData, children: Snippet } = $props();
// Track pathname changes - if pathname changes but navigating is still true,
// navigation might be stuck (though this shouldn't happen normally)
let lastPathname = $state(data.pathname);
$effect(() => {
if (data.pathname !== lastPathname) {
lastPathname = data.pathname;
}
onMount(() => {
console.log('layout mounted');
// Set initial random background color on first load
// setRandomBgColor();
});
let { children }: { children: Snippet } = $props();
let showLoader = $state(false);
let timer: ReturnType<typeof setTimeout> | null = null;
let navToken = 0;
function clearTimer() {
if (timer) {
clearTimeout(timer);
timer = null;
}
}
function setRandomBgColor() {
const currentBgColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-bg');
const currentHighlightColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-highlight');
const currentTextColor = getComputedStyle(document.documentElement)
.getPropertyValue('--color-text');
let variantNumber: number;
let variantBgColor: string;
let variantHighlightColor: string;
let variantTextColor: string;
// Keep picking a random variant until it's different from the current color
do {
variantNumber = Math.floor(Math.random() * 4) + 1;
variantBgColor = getComputedStyle(document.documentElement)
.getPropertyValue(`--color-bg-variant-${variantNumber}`);
variantHighlightColor = getComputedStyle(document.documentElement)
.getPropertyValue(`--color-highlight-variant-${variantNumber}`);
variantTextColor = getComputedStyle(document.documentElement)
.getPropertyValue(`--color-text-variant-${variantNumber}`);
} while (variantBgColor.trim() === currentBgColor.trim());
document.documentElement.style.setProperty('--color-bg', variantBgColor);
document.documentElement.style.setProperty('--color-highlight', variantHighlightColor);
document.documentElement.style.setProperty('--color-text', variantTextColor);
}
if (browser) {
beforeNavigate((nav) => {
// token to avoid races between overlapping navigations
const token = ++navToken;
clearTimer();
showLoader = false;
// Change background color on route change
setRandomBgColor();
// only show if navigation takes >150ms
timer = setTimeout(() => {
if (token === navToken) showLoader = true;
}, 150);
// hide loader when THIS navigation completes (even if afterNavigate timing is odd)
nav.complete.finally(() => {
if (token !== navToken) return;
clearTimer();
showLoader = false;
});
});
}
</script>
<svelte:head>
<meta property="og:type" content="website" />
<meta property="og:url" content={`https://floter.design`+data.pathname} />
<meta property="og:url" content={`https://floter.design${page.url.pathname}`} />
<meta property="og:image" content="https://floter.design/ogimage.png">
<meta name="twitter:card" content="summary_large_image">
<meta name="description" content={$page.data.description ? $page.data.description : 'Simon Flöter is a designer and creative developer'} />
<title>{$page.data.title ? $page.data.title : 'Simon Flöter, Designer and Creative Developer'}</title>
<meta name="description" content={page.data.description ? page.data.description : 'Simon Flöter is a designer and creative developer'} />
<title>{page.data.title ? page.data.title : 'Simon Flöter, Designer and Creative Developer'}</title>
</svelte:head>
<Header />
{#key data.pathname}
<!-- {#key data.pathname} -->
<div class="content">
{#if $navigating}
{#if showLoader}
<Loader />
{/if}
{@render children()}
</div>
{/key}
<!-- {/key} -->

View file

@ -1,9 +1,10 @@
import { loading } from '$lib/utils/stores.js'
loading.set(true)
export const load = async ({ url }) => {
const { pathname } = url
loading.set(true)
// Simulate async operation if needed
await new Promise(resolve => setTimeout(resolve, 0))
loading.set(false)
return {
pathname
}
return {}
}

View file

@ -93,9 +93,6 @@
</script>
{#if !mounted}
<Loader />
{/if}
<article class="scroller">
<section class="canvasized splash">
<h1 class="align-middle">I create digital experiences that <em>stand out</em> from the rest.</h1>

View file

@ -0,0 +1,43 @@
<article>
<div class="eyebrow">
Research &amp; Thoughts
</div>
<h1>Ethical Web Development</h1>
<p>
What does that mean? For me, it means building websites and applications that prioritize user privacy, accessibility, and sustainability.
</p>
</article>
<style>
article {
max-width: 1200px;
padding: 0
var(--spacing-outer)
100px
var(--spacing-outer);
@media screen and (min-width: 768px) {
margin: 0
var(--spacing-outer)
0
150px;
padding: calc(50vh - var(--spacing-outer) - 6vw)
var(--spacing-outer)
var(--spacing-outer)
var(--spacing-outer);
width: calc(100% - 150px - var(--spacing-outer));
}
}
.eyebrow {
font-size: 1.25em;
text-transform: uppercase;
letter-spacing: 0.01em;
margin-bottom: 0;
}
h1 {
font-size: clamp(2.5rem, 7vw, 7rem);
letter-spacing: -0.04em;
margin-top: 0;
margin-bottom: 0.5em;
}
</style>

View file

@ -13,21 +13,24 @@
const textPool: PIXI.Text[] = [];
onMount(() => {
let highLightColor = window.getComputedStyle(document.body).getPropertyValue('--color-highlight');
let cleanup: (() => void) | undefined;
let is_fine = window.matchMedia('(pointer:fine)').matches
let is_landscape = window.matchMedia('(orientation:landscape)').matches
(async () => {
let highLightColor = window.getComputedStyle(document.body).getPropertyValue('--color-highlight');
let is_landscape = window.matchMedia('(orientation:landscape)').matches;
let isDestroyed = false;
gsap.registerPlugin(ScrollTrigger);
let app = new PIXI.Application({
let app = new PIXI.Application();
await app.init({
canvas: canvas,
resizeTo: window,
antialias: true,
autoDensity: true,
resolution: 2,
backgroundColor: 'rgb(255, 255, 255)',
backgroundColor: 'rgb(29, 12, 18)',
backgroundAlpha: 0,
view: canvas,
});
let textgroup = new PIXI.Container();
@ -134,9 +137,11 @@
ease: 'power2.inOut',
overwrite: true,
onComplete: () => {
if (!isDestroyed) {
introDone = true;
createScrollTrigger();
}
}
})
tweens.push(textgroupIn);
@ -166,6 +171,10 @@
let scrollTriggerTweens: Array<GSAPTween> = [];
function createScrollTrigger() {
// Check if component is destroyed before creating ScrollTriggers
if (isDestroyed) return;
const tween1 = gsap.to([textRows[0], textRows[1]], {
y: '+=' + -textRows[0][0].height * 0.9,
duration: 1,
@ -193,8 +202,10 @@
scrollTriggerTweens.push(tween1, tween2);
}
return () => {
cleanup = () => {
isDestroyed = true; // Flag to prevent further actions
cancelAnimationFrame(animationFrameId);
// IMPORTANT: Kill ScrollTrigger and GSAP animations FIRST,
// before destroying PixiJS objects they reference
scrollTriggerTweens.forEach(tween => {
@ -205,10 +216,23 @@
});
// Kill all other tweens
tweens.forEach(tween => tween.kill());
// Kill orphanes ScrollTriggers
ScrollTrigger.getAll().forEach((trigger) => {
if (trigger.trigger === document.querySelector('article')) {
trigger.kill();
}
});
// Return objects to pools
allTexts.forEach(text => returnTextToPool(text));
// Now safe to destroy PixiJS app
app.destroy(true, { children: true, texture: true, baseTexture: true });
app.destroy(true, { children: true, texture: true });
}
})();
return () => {
if (cleanup) cleanup();
}
})
</script>

View file

@ -18,7 +18,7 @@
backgroundAlpha: 0,
resizeTo: window
});
PIXI.Filter.defaultResolution = window.devicePixelRatio;
(PIXI.Filter as any).defaultResolution = window.devicePixelRatio;
const container = new PIXI.Container();
app.stage.addChild(container);

View file

@ -2,6 +2,7 @@
import { onMount } from 'svelte';
import gsap from 'gsap';
import { SplitText } from 'gsap/SplitText';
import { goto } from '$app/navigation';
let { data } = $props();
const orderedPosts = $derived.by(() => (data.posts || []).sort((a, b) => {
@ -16,6 +17,9 @@
let isZoomed = false;
const currentBgColor = getComputedStyle(document.documentElement).getPropertyValue('--color-bg');
const currentHighlightColor = getComputedStyle(document.documentElement).getPropertyValue('--color-highlight');
let works: Array<HTMLElement> = Array.from(document.querySelectorAll('.work'));
for ( let i = 0; i < 40; i++ ){
let clone = works[i].cloneNode(true) as HTMLElement;
@ -50,7 +54,8 @@
workclone.style.top = `0`;
workclone.style.left = `0`;
document.body.appendChild(workclone);
window.location.href = href || ''
// window.location.href = href || ''
goto(href || '');
}
})
gsap.fromTo(split.chars, {
@ -94,12 +99,12 @@
transformOrigin: '0 0',
})
gsap.set('.work',{
opacity: 0,
autoAlpha: 0,
yPercent: 50,
scaleY: 0,
})
gsap.to('.work',{
opacity:1,
autoAlpha:1,
yPercent: 0,
scaleY: 1,
duration: .75,
@ -129,37 +134,51 @@
overwrite: true,
})
}
// Hover states of .work
works.forEach((work, index) => {
work.addEventListener('mouseenter', (e) => {
if (isZoomed) return;
gsap.to(work, {
backgroundColor: 'var(--color-bg)',
duration: .125,
ease: 'power1.inOut',
})
gsap.to(work.querySelector('.work-logo'), {
color: 'var(--color-highlight)',
duration: .125,
ease: 'power1.inOut',
})
})
work.addEventListener('mouseleave', (e) => {
if (isZoomed) return;
gsap.to(work, {
backgroundColor: 'var(--color-highlight)',
duration: .125,
ease: 'power1.inOut',
})
gsap.to(work.querySelector('.work-logo'), {
color: 'var(--color-bg)',
duration: .125,
ease: 'power1.inOut',
})
})
})
window.addEventListener('mousemove', mouseMoveHandler);
// Create handler functions outside the loop for proper cleanup
const handleMouseEnter = (work: HTMLElement) => {
if (isZoomed) return;
gsap.killTweensOf([work, work.querySelector('.work-logo')]);
gsap.to(work, {
backgroundColor: currentBgColor,
duration: .125,
ease: 'power1.inOut',
})
const logo = work.querySelector('.work-logo');
if (logo) {
gsap.to(logo, {
color: currentHighlightColor,
duration: .125,
ease: 'power1.inOut',
})
}
}
const handleMouseLeave = (work: HTMLElement) => {
if (isZoomed) return;
gsap.killTweensOf([work, work.querySelector('.work-logo')]);
gsap.to(work, {
backgroundColor: currentHighlightColor,
duration: .125,
ease: 'power1.inOut',
})
const logo = work.querySelector('.work-logo');
if (logo) {
gsap.to(logo, {
color: currentBgColor,
duration: .125,
ease: 'power1.inOut',
})
}
}
// Hover states of .work
works.forEach((work) => {
work.addEventListener('mouseenter', () => handleMouseEnter(work));
work.addEventListener('mouseleave', () => handleMouseLeave(work));
})
// Move the works the same way on touch drag
let touchMoveHandler = (e: TouchEvent) => {
let x = e.touches[0].clientX;
@ -186,9 +205,9 @@
window.removeEventListener('mousemove', mouseMoveHandler);
window.removeEventListener('touchmove', touchMoveHandler);
gsap.killTweensOf('.works, .work');
works.forEach((work, index) => {
work.removeEventListener('mouseenter', (e) => {});
work.removeEventListener('mouseleave', (e) => {});
works.forEach((work) => {
work.removeEventListener('mouseenter', () => handleMouseEnter(work));
work.removeEventListener('mouseleave', () => handleMouseLeave(work));
work.removeEventListener('click', (e) => {});
})
}

View file

@ -1,7 +1,21 @@
export async function load( { params }: { params: { slug: string }} ){
try {
const post = await import(`../md/${params.slug}.md`)
const { title = '', date = '', header_bg_image = '', svg = '', video = '', tags = [], reference = '', referenceName = '', tasks = [], description = [], images = [], agency = '', agencyName = '' } = post.metadata
const {
title = '',
date = '',
header_bg_image = '',
svg = '',
video = '',
tags = [],
reference = '',
referenceName = '',
tasks = [],
description = [],
images = [],
agency = '',
agencyName = ''
} = post.metadata
// Don't pass the component - it's not serializable
// Import it directly in the page component instead

View file

@ -5,23 +5,9 @@
import { CldImage } from 'svelte-cloudinary';
let { data } = $props();
let visible = $state(false);
let Content = $state<any>(null);
// Import the markdown component dynamically and set up animations
onMount(async () => {
// Load the markdown component
try {
const post = await import(`../md/${data.slug}.md`);
Content = post.default;
} catch (error) {
console.error('Error loading markdown component:', error);
}
gsap.registerPlugin(ScrollToPlugin);
visible = true;
onMount(() => {
let is_landscape = window.matchMedia('(orientation:landscape)').matches
window.addEventListener('resize', () => {
@ -54,17 +40,18 @@
gsap.to('.logo-wrapper', {
opacity: 0,
scale: 0.85,
zIndex: -1,
duration: 1,
duration: .5,
ease: 'power2.out',
})
gsap.from('.work', {
y: '100vh',
gsap.to('.work', {
y: '-100vh',
duration: 1,
ease: 'power4.out',
})
gsap.from('.gallery-wrapper', {
y: '-100vh',
gsap.to('.gallery-wrapper', {
y: '100vh',
duration: 1,
ease: 'power4.out',
})
@ -137,8 +124,7 @@
</div>
<div class="gallery-wrapper">
<div class="gallery">
{#if visible}
{#each data.images as image}
{#each data.images as image (image)}
<figure>
{#if image.includes('/video/')}
<video src={image} width="1400" height="840" autoplay muted loop></video>
@ -156,21 +142,19 @@
{/if}
</figure>
{/each}
{/if}
</div>
</div>
<article class="work">
{#if visible}
<h1>{data.title}</h1>
<div class="description">
{data.description}
</div>
<h1>{data.title}</h1>
<div class="work-content">
<div class="infobox">
<div class="tasks">
<div class="tasks-title">What I did:</div>
<ul>
{#each data.tags as tag }
{#each data.tags as tag (tag)}
<li>{tag}</li>
{/each}
</ul>
@ -187,12 +171,13 @@
{/if}
</div>
<div class="work-content-text">
{#if Content}
<svelte:component this={Content} />
{/if}
{#await import(`../md/${data.slug}.md`) then { default: Content }}
<Content />
{:catch error}
<p>Error loading content: {error.message}</p>
{/await}
</div>
</div>
{/if}
</article>
<style lang="scss">
@ -234,6 +219,8 @@
height: 100%;
}
.work {
position: relative;
top: 100vh;
overflow: hidden;
padding: 0 var(--spacing-outer) 100px var(--spacing-outer);
@media screen and (min-width: 768px) {
@ -243,19 +230,20 @@
}
}
h1 {
margin: .5em 0 0.25em 0;
margin: .125em 0 0.5em 0;
font-size: 2.5rem;
@media screen and (min-width: 768px) {
font-size: 5rem;
font-size: 6rem;
}
}
.description {
margin-bottom: 1.5em;
margin-top: 1.5em;
line-height: 1.3;
letter-spacing: -0.0075em;
letter-spacing: 0;
text-transform: uppercase;
font-size: 1.25rem;
@media screen and (min-width: 768px) {
font-size: 2.5rem;
font-size: 1.75rem;
}
}
.infobox {
@ -320,17 +308,17 @@
}
.gallery-wrapper {
position: relative;
top: -100vh;
width: 100vw;
overflow: hidden;
padding: 0 0 1em 0;
z-index: 1;
// opacity: 0.75;
perspective: 250px;
perspective-origin: center bottom;
@media screen and (min-width: 768px) {
margin-bottom: -80px;
top: -80px;
top: calc(-100vh - 80px);
padding: 2em 0 4em 0;
margin-bottom: -120px;
perspective: 500px;

View file

@ -1,279 +0,0 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
import { CldImage } from 'svelte-cloudinary';
export let data;
gsap.registerPlugin(ScrollTrigger);
let visible = false;
function animForDesktop() {
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.to('.heromask, .coverclone', { duration: .6, x: "-10%", ease: "cubic.inOut" })
gsap.to('.work', {
xPercent: -100,
duration: .6,
ease: "expo.out",
delay: .2
})
let heroheight = document.querySelector('.heromask')?.getBoundingClientRect().height || 100;
gsap.to('.heromask', {
clipPath: "polygon(0 0, 60% 0, 35% 100%, 0% 100%)",
duration: 1,
ease: "power4.out",
onStart: () => {
setTimeout(() => {
document.querySelector('.coverclone')?.remove();
}, 100);
},
onComplete: () => {
gsap.to('.heromask', {
ease: "none",
clipPath: "polygon(0 0, 50% 0, 50% 100%, 0% 100%)",
scrollTrigger: {
trigger: '.work',
start: 'top top',
end: `200px top`,
scrub: true
}
})
}
})
}
function animForMobile() {
ScrollTrigger.getAll().forEach(t => t.kill());
gsap.to('.heromask, .coverclone', { duration: .6, y: -20, ease: "cubic.inOut" })
gsap.to('.work', {
opacity: 1,
yPercent: -100,
duration: .4,
ease: "expo.out",
delay: .2,
})
gsap.to('.heromask', {
clipPath: "polygon(0 0, 100% 0, 100% 75%, 0% 100%)",
duration: .6,
ease: "cubic.inOut",
onStart: () => {
setTimeout(() => {
document.querySelector('.coverclone')?.remove();
}, 100);
},
onComplete: () => {
gsap.to('.heromask', {
ease: "power1.out",
scrollTrigger: {
trigger: '.work',
markers: false,
start: '0px -10px',
end: `0px -20px`,
scrub: false,
onEnterBack: () => {
gsap.to('.heromask', {
clipPath: "polygon(0 0, 100% 0, 100% 75%, 0% 100%)",
duration: .6,
ease: "expo.out",
})
},
onEnter: () => {
gsap.to('.heromask', {
clipPath: "polygon(0 0, 100% 0, 100% 0%, 0% 0%)",
duration: .6,
ease: "expo.out",
})
}
}
})
}
})
}
function animForSize(){
if ( window.matchMedia("(min-width: 768px) and (orientation: landscape)").matches ) {
animForDesktop();
} else {
animForMobile();
}
}
onMount(() => {
visible = true;
document.querySelector('.heromask img')?.addEventListener('load', () => {
animForSize();
let portrait = window.matchMedia("(orientation: portrait)");
portrait.addEventListener("change", function(e) {
animForSize();
})
})
})
</script>
<div class="heromask">
<CldImage
src={data.header_bg_image}
alt="{data.title}"
sizes="100vw"
width={2100}
height={1400}
placeholder="blur"
loading="eager"
objectFit="fill"
/>
</div>
<div class="subnav">
<a href="/work" class="subnav-item">← Back</a>
</div>
<article class="work">
{#if visible}
<div class="work-content">
{#if data.tags != undefined && data.tags.length > 0 }
<div class="tags">
{#each data.tags as tag }
<div class="tag">{tag}</div>
{ /each }
</div>
{/if}
<h1><span class="svg-logo">{@html data.svg}</span><span class="name">{data.title}</span></h1>
<div class="work-content-text">
{@html data.Content.html}
</div>
</div>
{/if}
</article>
<style lang="scss">
.work {
width: 100vw;
min-height: 100svh;
overflow: hidden;
box-sizing: border-box;
transform: translateY(100%);
@media screen and (min-width: 768px) {
transform: translateX(100%);
}
}
.subnav {
position: fixed;
top: 0;
right: 0;
z-index: 4;
padding: var(--spacing-outer);
& a {
text-decoration: none;
color: var(--color-highlight);
}
}
.heromask {
position: fixed;
top: 0;
left:0;
aspect-ratio: var(--aspect-ratio-heroes);
width: 100%;
height: auto;
z-index: 2;
clip-path: polygon(0 0, 100% 0, 100% 100%, 0% 100%);
}
:global(.heromask img) {
z-index: 0;
display: block;
position: relative;
width: 100%;
height: 100%;
aspect-ratio: var(--aspect-ratio-heroes);
margin: 0;
object-fit: fill;
}
.work-content {
padding: 0 var(--spacing-outer);
padding-top: calc(66.6vw + 1em);
position: relative;
z-index: 1;
color: var(--color-text);
& > :last-child {
margin-bottom: 100px;
}
@media screen and (min-width: 768px) {
margin-left: 40vw;
max-width: 60vw;
padding-top: calc( 3 * var(--spacing-outer) );
padding-left: calc(var(--spacing-outer) * 1.5);
padding-right: calc(var(--spacing-outer) * 2.5);
}
}
h1 {
position: relative;
z-index: 1;
margin: 0;
@media screen and (min-width: 768px) {
padding: 0 0 1em 0;
}
& .name {
display: none;
}
& .svg-logo :global(svg) {
width: auto;
height: auto;
max-width: 250px;
max-height: 80px;
margin-bottom: 1em;
@media screen and (min-width: 768px) {
max-width: 400px;
max-height: 200px;
}
}
}
.tags {
padding-bottom: .25em;
font-size: 1em;
margin-bottom: 2em;
line-height: 1.1;
&:after {
content: '';
display: block;
width: calc(100% + var(--spacing-outer) * 2.5);
height: 1px;
background-color: var(--color-text);
margin-top: .5em;
}
}
.tag {
display: inline-block;
margin-right: .5em;
padding: .125em 0;
font-weight: 400;
text-transform: uppercase;
letter-spacing: -.005em;
&:after {
content: ','
}
&:last-child:after {
content: none
}
}
:global(.header-nav){
transition: all .3s cubic-bezier(0.075, 0.82, 0.165, 1);
}
:global(.work .header-nav){
transform: translateY(100%);
}
</style>

View file

@ -1,71 +0,0 @@
<script lang="ts">
import WorkCanvas from '$lib/components/WorkCanvas.svelte';
import { onMount } from 'svelte';
import { workClickHandler, initWorkPage } from './workUtils.js';
import { workbulge } from '$lib/utils/stores.js';
import { CldImage } from 'svelte-cloudinary';
export let data;
const orderedPosts = data.posts.sort((a, b) => {
return a.meta.order - b.meta.order;
});
let canvasTextElems: Array<HTMLElement>;
let canvasImgElems: Array<HTMLElement>;
let bulge = {factor: 0};
workbulge.subscribe((val) => {
bulge.factor = val;
});
onMount(() => {
const headline: HTMLElement = document.querySelector('.headline') as HTMLElement;
const headlines: Array<HTMLElement> = Array.from(document.querySelectorAll('h2, li'));
const images: Array<HTMLElement> = Array.from(document.querySelectorAll('.work img'));
let canvasElems = initWorkPage( headlines, images );
canvasTextElems = canvasElems.text as Array<HTMLElement>;
canvasImgElems = canvasElems.images;
});
</script>
<div class="works-wrapper">
<h1 class="headline"><span>Outstanding work</span></h1>
<div class="works">
{#each orderedPosts as work, i}
<a
data-sveltekit-preload-data
href="{work.path}"
class="work"
on:click={ (e) => workClickHandler(e) }
>
<CldImage
src={work.meta.header_bg_image}
sizes={ i === 0 ? `(min-width: 768px) 60vw, 50vw` : `(min-width: 768px) 20vw, 50vw`}
alt={work.meta.title}
width="2100"
height="1400"
objectFit="fill"
loading= "lazy"
/>
<div class="work-info">
<h2 class="title"><span class="title-words">{work.meta.title}</span></h2>
{#if work.meta.tags}
<ul class="tags">
{#each work.meta.tags as tag, index}
<li class="tag">{tag}{index < work.meta.tags.length-1 ? ' | ' : ''}</li>
{/each}
</ul>
{/if}
</div>
</a>
{/each}
</div>
<WorkCanvas
textsToCanvas={canvasTextElems}
imgsToCanvas={canvasImgElems}
bulgeFactor={bulge.factor}
/>
</div>
<style src="./work.scss" lang="scss"></style>

View file

@ -1,113 +0,0 @@
.works-wrapper {
max-width: 1200px;
margin: auto;
}
h1 {
font-size: 16vw;
margin: var(--spacing-nav) var(--spacing-nav) .25em var(--spacing-nav);
padding: 0 0 calc(var(--spacing-nav) / 3) 0;
position: relative;
z-index: -1;
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
border-bottom: 1px solid;
@media screen and (min-width: 768px) {
font-size: 7vw;
}
}
.works {
padding: 0.5em;
display: flex;
gap: 0.25em;
flex-wrap: wrap;
@media screen and (min-width: 768px) {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1px;
padding: 0 var(--spacing-nav);
margin: 0 auto;
padding-bottom: 20svh;
}
}
.work-info,
.work:visited .work-info {
position: absolute;
left: 0.25em;
top: 0.25em;
padding: 0.5em .75em;
overflow: hidden;
box-sizing: border-box;
background-color: var(--color-text);
visibility: hidden;
& h2 {
text-transform: none;
font-style: normal;
font-weight: 400;
margin: 0;
padding: 0;
font-size: 1.2em;
letter-spacing: -0.02em;
line-height: 1;
visibility: hidden;
color: var(--color-bg);
}
& .tags {
visibility: hidden;
list-style: none;
padding: 0;
margin: 10px 0 0 0 ;
display: flex;
flex-wrap: wrap;
gap: 0.15em;
transition: all .4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
& .tag {
font-size: .6em;
line-height: 1;
letter-spacing: -0.01em;
box-sizing: border-box;
padding: 0;
border-radius: 3px;
text-transform: uppercase;
// font-weight: 800;
// font-style: italic;
color: var(--color-text);
}
}
.work:hover .work-info {
background-color: var(--color-bg);
& h2 {
color: var(--color-text);
}
}
.work {
flex: 0 0 calc(50% - 0.125em );
display: block;
position: relative;
padding: 0;
// transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
text-decoration: none;
overflow: hidden;
@media screen and (min-width: 768px) {
grid-column-end: span 1;
&:first-child {
grid-column-end: span 2;
grid-row-end: span 2;
}
}
}
.work .tag {
opacity: 0;
}
:global(.work img) {
width: 100%;
height: auto;
aspect-ratio: var(--aspect-ratio-heroes);
object-fit: fill;
visibility: hidden;
display: block;
}

View file

@ -11,6 +11,7 @@
overflow: hidden;
perspective: 700px;
transform-origin: 0 0;
will-change: transform;
}
.headline {
position: fixed;
@ -48,9 +49,8 @@
display: grid;
grid-template-columns: repeat(6, 100vw);
grid-auto-rows: 100vh;
// grid-template-rows: repeat(6, 100vh);
// grid-auto-flow: column;
gap: 6px;
will-change: transform;
}
.work {
background-color: var(--color-highlight);
@ -59,9 +59,11 @@
width: 100vw;
height: 100vh;
opacity: 0;
visibility: hidden;
transform: translateZ(700px);
will-change: transform;
}
.work-logo {
:global(.work-logo) {
width: 100%;
height: 100%;
color: var(--color-bg);
@ -70,6 +72,7 @@
display: flex;
flex-direction: column;
justify-content: center;
will-change: transform;
@media screen and (min-width: 768px) {
width: 60%;