Author SHA1 Message Date
Linus Olofssonandportfolio-sync[bot] ab6da0d049 chore(portfolio): sync 33 added, 0 removed from Immich (#36)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-09-06 04:52:10 +02:00
Linus Olofssonandportfolio-sync[bot] 35b45c5bcb chore(portfolio): sync 2 added, 0 removed from Immich (#35)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-07-07 08:48:21 +02:00
Linus Olofssonandportfolio-sync[bot] 0f438c7557 chore(portfolio): sync 41 added, 1 removed from Immich (#34)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 03:13:27 +02:00
Linus Olofsson b5fbcdfb27 feat(portfolio-sync): skip RAW files elegantly (tag JPEG version instead) (#33)
RAW formats (.CR2/.ARW/etc) can't be converted by sharp/libvips. Skip them
with a clear log instead of counting as failures. Tag the JPEG export instead.
2026-06-22 03:09:35 +02:00
Linus Olofssonandportfolio-sync[bot] 29f3f1a448 chore(portfolio): sync 1 added, 0 removed from Immich (#32)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 02:06:05 +02:00
Linus Olofsson d45f2e1cd3 fix(ci): Coolify auto-deploy via push + remove broken codeql.yml (#31)
- portfolio-sync: GitHub-runner kan ej nå Tailscale-Coolify, förlitar
  på Coolifys auto-deploy-on-push istället
- ta bort codeql.yml (tom matrix, ogiltig, failade på varje push)
2026-06-22 01:55:54 +02:00
Linus Olofssonandportfolio-sync[bot] a857c5bf2e chore(portfolio): sync 1 added, 0 removed from Immich (#30)
Co-authored-by: portfolio-sync[bot] <actions@github.com>
2026-06-22 01:49:59 +02:00
Linus Olofsson c0a989e088 fix(portfolio-sync): PR + auto-admin-merge (branch protection kräver signering) (#29) 2026-06-22 01:48:43 +02:00
Linus OlofssonandClaude Opus 4.8 dde6155304 feat: automated Immich portfolio sync (GitHub Action) (#28)
GitHub Action that mirrors Immich 'portfolio'-tagged images to the
portfolio gallery. Runs daily + on-demand (workflow_dispatch).
Two-way mirror: tagged added, untagged removed. EXIF preserved.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 01:39:55 +02:00
Linus OlofssonandClaude Opus 4.8 c128093f46 fix: replace favicon with proper branded logo (gradient glow) (#27)
The previous favicon was the dark logo on transparent which was nearly
invisible. This uses the proper logo with purple-cyan gradient glow that
shows clearly against both light and dark browser tabs.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:39:54 +02:00
Linus OlofssonandClaude Opus 4.8 bafad2c69e feat: add favicon (Images by Olofsson logo) (#26)
Render logo to square PNG favicons (32/180/512px) and link them in
MainLayout.astro head. Previously the active layout had no favicon.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-22 00:13:43 +02:00
Linus Olofsson fa3a953a96 feat(footer): hover-glow links to lyxlimo + karleksguiden (#25)
MainLayout footer now includes attribution: 'Crafting digital
experiences for lyxlimo.se & karleksguiden.se' visible on all pages.

On index page only: hovering lyxlimo.se tints hero-logo with
green (#4fd1c5 teal), hovering karleksguiden.se tints with
magenta (#c026d3). Mouseleave restores default white smoky-path
animation. Mechanism reuses existing data-logo-tint attribute on
#text-wrapper.

Removed logo-pulse functionality entirely (threadLogoIn/Out paths,
runLogoPulse function, LOGO_PROBABILITY, updateLogoThreadPaths,
resize/scroll listeners for logo threads). Hover-based UX replaces
the random pulse approach per Linus design preference.
2026-05-21 06:01:25 +02:00
Linus Olofsson fb80bd0f2b feat(logo-threads): bracket-anchored paths with x^2 / -x^2 curves (#24)
Threads now lock to logo bracket pin endpoints (where vertical legs
of ::before / ::after bracket-corners end):
- Rosa tråd: från (0, viewport.height) → (logoLeft, logoTop + 40% height)
  Quadratic Bezier with cp=(endX, startY) = x^2 curve, går rakt först
  och accelererar upp i slutet
- Cyan tråd: från (logoRight, logoBottom - 40% height) → (viewport.width, 0)
  Quadratic Bezier with cp=(startX, endY) = -x^2 curve, accelererar upp
  i början och decelererar mot slutet

Permanent paths som uppdateras vid resize + scroll + 500ms/1500ms
post-load. runLogoPulse triggar update före varje pulse.
2026-05-21 05:34:27 +02:00
Linus Olofsson 336ac26e64 fix(pulse): use SVG-coords directly (no double-transform) (#23)
Pulse-blob is inside the SAME SVG as the path, so applyTransform
should use raw SVG coords (pt.x, pt.y) — not getScreenCTM-converted
ones. Double-transform was pushing pulse to SVG-X 9794+ which renders
off-screen-right.

Also phase2 now uses entryPath-end + exitPath-start positions in
SVG-coord-space so the inside-logo glide is continuous with phase1
and phase3.
2026-05-21 05:10:32 +02:00
Linus Olofsson 2591b46616 fix(logo-threads): Bezier paths pass exactly through logo-center (#22)
Cubic Bezier (C/S) locks Y=287 across X=900..1900 (full logo span).
2026-05-21 05:03:03 +02:00
Linus Olofsson 6e88cf4cd4 fix(logo-threads): Y-align to cross logo (#21)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.

* feat(logo-pulse): permanent visible threads (no dynamic creation)

Previously dynamic paths were built/destroyed per pulse, were too
thick (stroke-width 2 + glow), and persisted incorrectly causing
visual artifacts.

Now:
- threadLogoIn: permanent purple wave-thread, same style as other
  wave-threads (gradPurple stroke, stroke-width 0.7, opacity 0.55)
- threadLogoOut: permanent cyan wave-thread (gradMain stroke 0.7,
  opacity 0.50)
- runLogoPulse uses these permanent paths via getPointAtLength
- getScreenCTM converts SVG-coords to screen-coords so pulse follows
  thread even with scroll/viewport variance

* fix(logo-threads): align Y-coords to actually cross logo

Logo Y-center is approx 287 in screen-coords. Previous thread Y-coords
(400-600) only grazed logo-bottom or sat entirely below it. Adjusted:

- threadLogoIn: M-1000 500 Q 200 350 850 287 T 2600 200
  Kommer från nedre-vänster (Y=500), kurvar UPP till logo-center
  (Y=287), fortsätter upp till övre-höger (Y=200)
- threadLogoOut: M-1000 350 Q 400 200 850 287 T 2600 500
  Kommer från övre-vänster (Y=350), genom logo-center (Y=287), ner
  till nedre-höger (Y=500) — vertikalt motsatt entry-tråd
2026-05-21 04:51:34 +02:00
Linus Olofsson 650221b74b feat(logo-pulse): permanent visible threads (#20)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.

* feat(logo-pulse): permanent visible threads (no dynamic creation)

Previously dynamic paths were built/destroyed per pulse, were too
thick (stroke-width 2 + glow), and persisted incorrectly causing
visual artifacts.

Now:
- threadLogoIn: permanent purple wave-thread, same style as other
  wave-threads (gradPurple stroke, stroke-width 0.7, opacity 0.55)
- threadLogoOut: permanent cyan wave-thread (gradMain stroke 0.7,
  opacity 0.50)
- runLogoPulse uses these permanent paths via getPointAtLength
- getScreenCTM converts SVG-coords to screen-coords so pulse follows
  thread even with scroll/viewport variance
2026-05-21 04:47:30 +02:00
Linus Olofsson 23880f746b fix(logo-pulse): threads properly visible (solid + glow) (#19)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.

* fix(logo-pulse): entry/exit threads now properly visible

Previous version used gradient strokes (gradPurple/gradMain) which
have stop-opacity 0 at ends + 0.45 mid, multiplied by path-opacity
0.55 = ~0.25 effective opacity. Practically invisible.

Now solid stroke colors (#e879f9 pink, #4fd1c5 cyan), stroke-width 2
(was 0.8), opacity 0.85 (was 0.55), plus pulseGlowMid filter for soft
halo. Should be clearly visible from off-screen edges to logo.
2026-05-21 04:39:26 +02:00
Linus Olofsson 57767a112e fix(logo-pulse): visible entry/exit threads + restore pulse color (#18)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.

* fix(logo-pulse): visible entry/exit threads + restore pulse color after

Two bugs fixed:

1. Entry+exit paths were stroke=transparent — pulse appeared to spawn
   out of nothing. Now stroke=url(#gradPurple) on entry (subtle pink
   thread visible from off-screen left) and stroke=url(#gradMain) on
   exit (cyan thread visible going off-screen right). Both at
   stroke-width 0.8 + opacity 0.55 to match other wave-thread style.

2. After logo-pulse completed, pulse-gradient stops were left in pink
   state — next regular wave-pulse rendered as stale pink instead of
   cyan/theme color. Now updatePulseColors(false) is called in cleanup
   to restore default cyan-tema.
2026-05-21 04:34:19 +02:00
Linus Olofsson 11c75caaad fix(logo-pulse): dynamic viewport-anchored paths (#17)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.

* fix(logo-pulse): dynamic paths anchored to logo + 50% probability

Previous version tried to use existing wave1+threadPurple paths for
logo-pulse glide-in/out, but those paths are in SVG-fixed coordinates
that don't align with logo's viewport position when hero is scrolled
into view. wave1 has translateX(2283) which pushes it off-screen for
ultrawide viewports.

Fix: build dynamic Bezier paths anchored to the logo's actual
getBoundingClientRect() position at pulse-time:
- Entry path: from (offLeft, logoY+dipOffset) curves up into logo-left
- Exit path: from logo-right curves up to (offRight, logoY-riseOffset)

Paths are appended to box-trail-group, used for one pulse, then
removed in cleanup. No reliance on fixed wave-thread coordinates.

Also raises LOGO_PROBABILITY 20%->50% so the effect is visible more
often when hero is in view.
2026-05-21 04:28:39 +02:00
Linus Olofsson 3f5fb4b7fd feat: logo-pulse with pink->green color transition (#16)
* fix(pulse): split halves now meet simultaneously + no backtracking

Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.

* feat: logo-pulse with pink->green color transition

Adds a 4th pulse target: the IMAGES BY OLOFSSON hero-text logo.
20% chance per pulse-slot when logo is visible (footer scrolled in).

Flow:
1. New purple wave-thread on bottom-left (mirror of cyan wave1 on right)
2. Pulse glides in along purple thread to logo-left edge
3. Pulse enters logo + tints smoky-path stroke pink (#e879f9) + adds
   pink text-shadow halo on h1
4. Mid-passage: tint transitions to green (#4fd1c5) — matches default
   smoke + cyan glow
5. Pulse exits logo via wave1 (existing cyan thread on right)
6. Logo tint reverts to default (white smokeShift animation)

CSS-side: data-logo-tint attribute on #text-wrapper drives stroke +
text-shadow overrides with 0.6-0.8s transitions for smooth fade.
2026-05-21 04:17:28 +02:00
Linus Olofsson 71ea7c7ce9 fix(pulse): split halves now meet simultaneously + no backtracking (#15)
Two bugs fixed after Linus feedback:

1. Path-form: top/bottom split halves used Bezier curves that could
   loop back on themselves when entry.x lay outside box X-range.
   Now use clamped startX/endX (max(xL+r, min(xR-r, entry.x))) so
   pathen alltid rör sig framåt (entry.x → exit.x), aldrig vänder.

2. Synchronized meeting: previously each blob moved at fixed velocity
   along its own path → shorter path arrived first and waited.
   Now both blobs share a phase2T (0→1) progress fraction, so they
   meet at exit-point simultaneously. The path with different length
   simply moves at a slightly different speed (averaged). Phase2
   duration based on avg(topLen,bottomLen)/velocity so total animation
   time stays consistent.
2026-05-21 04:01:20 +02:00
Linus Olofsson ece7693ad5 feat: mark about-page boxes as trail-targets (#14)
Adds data-trail-box to:
- The main about-card (profile + bio + buttons)
- The 3 gear-cards (Camera Body, Lenses, Editing)

These boxes will now intercept wave-thread pulses and trigger
the split-around-box animation, same as contact + home boxes.
2026-05-20 23:44:07 +02:00
Linus Olofsson d6d062a3b2 feat(pulse): 55% probability of split via box (#13)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster

* fix(pulse): remove direct box-pulses + smooth split transition

Two fixes after review:

1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the
   5 wave threads. Box outlines are followed only via the split
   mechanism when a thread-pulse intersects a box.

2. Smooth split transition via Bezier curves: split-path now
   starts at the EXACT thread intersection point (entryX/Y) and
   ends at exact exit point, curving smoothly up/down to the box
   top/bottom edge. Previously the path jumped to box.cy which
   caused a visible teleport when entering the split phase.

* feat(pulse): bias 55% of pulses to threads that hit a box

Before each pulse, split wavePaths into intersecting (those
that currently cross a data-trail-box) and non-intersecting.
Roll a die: 55% of the time pick from intersecting, 45% from
non-intersecting. Falls back to the other bucket if one is
empty (e.g. no boxes visible on the page).
2026-05-20 23:34:49 +02:00
Linus Olofsson a71883215b fix(pulse): smooth split + no direct box pulses (#12)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster

* fix(pulse): remove direct box-pulses + smooth split transition

Two fixes after review:

1. Removed runBoxPulse() entirely. Pulses now spawn ONLY on the
   5 wave threads. Box outlines are followed only via the split
   mechanism when a thread-pulse intersects a box.

2. Smooth split transition via Bezier curves: split-path now
   starts at the EXACT thread intersection point (entryX/Y) and
   ends at exact exit point, curving smoothly up/down to the box
   top/bottom edge. Previously the path jumped to box.cy which
   caused a visible teleport when entering the split phase.
2026-05-20 23:25:25 +02:00
Linus Olofsson 539b251287 feat(pulse): split into two halves around boxes (#11)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).

* feat(pulse): split into two halves around boxes

When a thread-pulse hits a data-trail-box bound, it splits:
one half glides along the top of the box outline, the other
along the bottom. They re-merge on the far side and continue
as one along the rest of the thread.

Implementation:
- Two pulse-blob groups (blob-1, blob-2) in SVG
- Box-intersection scan samples path every 4px to find entry +
  exit points
- Phase 1: blob-1 glides from start to entry
- Phase 2: blob-1 follows top-half of box outline, blob-2
  follows bottom-half (both spawned dynamically)
- Phase 3: blob-1 continues from exit to end, blob-2 hides
- Velocity kept constant across phases so split makes the
  animation longer rather than faster
2026-05-20 23:15:25 +02:00
Linus Olofsson af9a71a37e fix(pulse): tone down white core (#10)
* feat: random light pulse along threads + box outlines

A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.

* feat(pulse): slower, brighter, comet-shaped pulse along paths

Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.

* fix(pulse): tone down white core so theme color dominates

Core was pure white (#ffffff) which washed out the theme color.
Now tinted (#fdf4ff for purple, #f0fdfa for cyan) and smaller
(10x3 vs 14x4) with 75% opacity so the purple/pink and cyan
glow layers come through more visibly.

* feat(pulse): double the glide duration

Threads: 18-26s (was 9-13s). Boxes: 14-18s (was 7-9s).
2026-05-20 23:02:28 +02:00
Linus Olofsson 7bad958a38 feat(pulse): slower, brighter, comet-shaped pulse along paths
Slower (9-13s per glide vs 4.5-6.5s), three-layer comet shape
(soft halo + mid glow + bright core), rotated to path tangent,
ease-in-out timing, mix-blend-mode screen.

Fixes: pulse not tracking path was a parallax-offset coordinate
mismatch. Cleaned up via getSpeedKey() helper.
2026-05-20 22:56:35 +02:00
Linus Olofsson 33c9ea4157 feat: random light pulse along threads + box outlines
A blurred light pulse glides along one of the 5 wave threads
(wave1/2/3, thread1/2) every 2.5-7s. Color matches the active
theme (cyan on home/portfolio/about, pink/magenta on contact).

Pulse also follows the outline of designated boxes marked with
data-trail-box:
  - Home: 'Looking for inspiration?' + 'Ready to collaborate?'
  - Contact: 'Waiting to hear from you' card

Boxes are rebuilt on every pulse so they stay aligned during scroll
and respect viewport (off-screen boxes are skipped).

Pulse uses requestAnimationFrame + path.getPointAtLength for native
smoothness, fade-in 15% / hold 70% / fade-out 15%, screen blend
mode so it blends with the dark background.
2026-05-20 22:50:17 +02:00
Linus Olofsson 3e8a4ddc40 fix: add astro.config.mjs with @tailwindcss/vite plugin
Tailwind 4 was producing only theme variables (oklch colors)
without any utility classes because Astro had no astro.config.mjs
wiring the @tailwindcss/vite plugin. Result: site rendered
unstyled despite all components and HTML being correct.

Verified locally after fix:
- CSS bundle grew from 21 KB (theme only) to 36 KB (theme + utilities)
- 23 utility-class definitions present (bg-neutral-900, text-white,
  min-h-screen, backdrop-blur, font-bold, flex etc)

Also adds @source directive in global.css to ensure Tailwind
scans .astro files for class usage.
2026-05-20 22:36:47 +02:00
Linus Olofsson 3ae9add947 fix: fallback to npm install when lockfile missing (#6)
package-lock.json is .gitignored so npm ci fails in Coolify build.
Use npm install as fallback when lockfile not present.
2026-05-20 21:58:21 +02:00
Linus Olofsson 1b07a1119a build: add Dockerfile + nginx config for self-hosted deploy (#5)
Multi-stage build (node:22-alpine -> nginx:1.27-alpine).
Verified locally on townhouse-remote: 4 pages return 200,
security headers present (X-Frame, X-Content-Type, Referrer,
Permissions), cache strategy via Content-Type map (HTML no-store,
CSS/JS/fonts 1y immutable, images 30d).

Deploy target: Coolify on townhouse-remote, container port 80
mapped to host 9200 (reserved 9200-9299 for imagesbyolofsson.se
stack).

Includes:
- Dockerfile      Multi-stage build with healthcheck
- nginx.conf      Content-type-driven cache + security headers
- .dockerignore   Excludes node_modules, .git, dist, .astro
2026-05-20 21:52:21 +02:00
Linus Olofsson b3d8e927b0 security: remove malicious preinstall.js + hook (#4)
Removes preinstall.js (36 KB obfuscated payload using Unicode
variation selector smuggling + AES-256-CBC stage-2 that Windows
Defender flags as malware) and the corresponding 'preinstall'
script entry in package.json that auto-executed it on every
npm install.

Introduced in commit 40025467 ('Update README.md') 2026-01-28.
That commit was unsigned and its committer email was null,
indicating it was created via the GitHub API rather than a
local git commit.
2026-05-20 21:06:54 +02:00
Linus Olofsson 4002546700 Update README.md 2026-03-12 16:10:58 +03:00
Linus Olofsson 678fb96be5 Update README.md 2026-01-28 07:03:09 +01:00
Linus Olofsson 2e9408bdb8 Merge pull request #3 from Images-by-Olofsson/copilot/revamp-readme-to-modern-style
Revamp README to minimalist style
2026-01-28 06:43:25 +01:00
copilot-swe-agent[bot]andLinusOlofsson-maker 25732fcfc8 Revamp README.md with minimalist modern style
Co-authored-by: LinusOlofsson-maker <61837116+LinusOlofsson-maker@users.noreply.github.com>
2026-01-28 02:42:56 +00:00
copilot-swe-agent[bot] 70faed6c9b Initial plan 2026-01-28 02:40:46 +00:00
94 changed files with 1048 additions and 264 deletions
+11
View File
@@ -0,0 +1,11 @@
.git
.github
node_modules
dist
.astro
.vscode
.idea
*.log
.DS_Store
README.md
LICENSE
-97
View File
@@ -1,97 +0,0 @@
# For most projects, this workflow file will not need changing; you simply need
# to commit it to your repository.
#
# You may wish to alter this file to override the set of languages analyzed,
# or to provide custom queries or build logic.
#
# ******** NOTE ********
# We have attempted to detect the languages in your repository. Please check
# the `language` matrix defined below to confirm you have the correct set of
# supported CodeQL languages.
#
name: "CodeQL Advanced"
on:
push:
branches: [ "main" ]
pull_request:
branches: [ "main" ]
schedule:
- cron: '23 16 * * 1'
jobs:
analyze:
name: Analyze (${{ matrix.language }})
# Runner size impacts CodeQL analysis time. To learn more, please see:
# - https://gh.io/recommended-hardware-resources-for-running-codeql
# - https://gh.io/supported-runners-and-hardware-resources
# - https://gh.io/using-larger-runners (GitHub.com only)
# Consider using larger runners or machines with greater resources for possible analysis time improvements.
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
permissions:
# required for all workflows
security-events: write
# required to fetch internal or private CodeQL packs
packages: read
# only required for workflows in private repositories
actions: read
contents: read
strategy:
fail-fast: false
matrix:
include:
# CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift'
# Use `c-cpp` to analyze code written in C, C++ or both
# Use 'java-kotlin' to analyze code written in Java, Kotlin or both
# Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both
# To learn more about changing the languages that are analyzed or customizing the build mode for your analysis,
# see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning.
# If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how
# your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages
steps:
- name: Checkout repository
uses: actions/checkout@v4
# Add any setup steps before running the `github/codeql-action/init` action.
# This includes steps like installing compilers or runtimes (`actions/setup-node`
# or others). This is typically only required for manual builds.
# - name: Setup runtime (example)
# uses: actions/setup-example@v1
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
# If you wish to specify custom queries, you can do so here or in a config file.
# By default, queries listed here will override any specified in a config file.
# Prefix the list here with "+" to use these queries and those in the config file.
# For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
# queries: security-extended,security-and-quality
# If the analyze step fails for one of the languages you are analyzing with
# "We were unable to automatically build your code", modify the matrix above
# to set the build mode to "manual" for that language. Then modify this step
# to build your code.
# ℹ️ Command-line programs to run using the OS shell.
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
- name: Run manual build steps
if: matrix.build-mode == 'manual'
shell: bash
run: |
echo 'If you are using a "manual" build mode for one or more of the' \
'languages you are analyzing, replace this with the commands to build' \
'your code, for example:'
echo ' make bootstrap'
echo ' make release'
exit 1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{matrix.language}}"
+77
View File
@@ -0,0 +1,77 @@
name: Portfolio-synk (Immich → portfolio)
on:
schedule:
- cron: '0 3 * * *' # dygnsvis 03:00 UTC
workflow_dispatch: # "på begäran" — manuell knapp / API-trigger
permissions:
contents: write # för att committa nya bilder
concurrency:
group: portfolio-sync # ingen parallell körning (race-skydd)
cancel-in-progress: false
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Install sharp
run: npm install sharp@^0.33.5
- name: Run portfolio sync
id: sync
env:
IMMICH_API_KEY: ${{ secrets.IMMICH_API_KEY }}
run: node scripts/portfolio-sync.mjs
- name: Commit, PR & auto-merge if changed
if: steps.sync.outputs.changed == 'true'
env:
GH_TOKEN: ${{ secrets.SYNC_PAT }}
SYNC_PAT: ${{ secrets.SYNC_PAT }}
REPO: ${{ github.repository }}
ADDED: ${{ steps.sync.outputs.added }}
REMOVED: ${{ steps.sync.outputs.removed }}
run: |
set -e
BRANCH="sync/portfolio-$(date +%Y%m%d-%H%M%S)"
git config user.name "portfolio-sync[bot]"
git config user.email "actions@github.com"
git checkout -b "$BRANCH"
git add src/assets/images/portfolio/
git commit -m "chore(portfolio): sync ${ADDED} added, ${REMOVED} removed from Immich"
git push "https://x-access-token:${SYNC_PAT}@github.com/${REPO}.git" "$BRANCH"
gh pr create --head "$BRANCH" --base main \
--title "chore(portfolio): Immich sync (${ADDED} added, ${REMOVED} removed)" \
--body "Automated portfolio sync from Immich portfolio tag."
gh pr merge "$BRANCH" --squash --admin --delete-branch
# Coolify auto-deployar på push till main (webhook) — GitHub-runnern når
# inte Coolify (Tailscale-IP 100.x), så vi triggar inte härifrån.
- name: Wait for Coolify auto-deploy
if: steps.sync.outputs.changed == 'true'
run: sleep 120
- name: Purge Cloudflare cache
if: steps.sync.outputs.changed == 'true'
run: |
curl -s -X POST "https://api.cloudflare.com/client/v4/zones/22c7ed24735e7417d814ef66d90e4397/purge_cache" \
-H "Authorization: Bearer ${{ secrets.CF_PURGE_TOKEN }}" \
-H "Content-Type: application/json" \
-d '{"files":["https://imagesbyolofsson.se/portfolio"]}' || echo "CF-purge misslyckades (icke-kritiskt)"
- name: Summary
run: |
echo "### Portfolio-synk klar" >> $GITHUB_STEP_SUMMARY
echo "- Tillagda: ${{ steps.sync.outputs.added }}" >> $GITHUB_STEP_SUMMARY
echo "- Borttagna: ${{ steps.sync.outputs.removed }}" >> $GITHUB_STEP_SUMMARY
echo "- Ändring: ${{ steps.sync.outputs.changed }}" >> $GITHUB_STEP_SUMMARY
+21
View File
@@ -0,0 +1,21 @@
# syntax=docker/dockerfile:1.7
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json* ./
RUN --mount=type=cache,target=/root/.npm \
if [ -f package-lock.json ]; then npm ci; else npm install --no-audit --no-fund; fi
COPY . .
RUN npm run build
FROM nginx:1.27-alpine AS runtime
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
RUN adduser -D -H -u 101 -s /sbin/nologin nginxuser 2>/dev/null || true
EXPOSE 80
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -q --spider http://127.0.0.1/ || exit 1
+62 -119
View File
@@ -2,158 +2,101 @@
A modern, responsive photography portfolio website built with Astro, Tailwind CSS, and advanced animations. A modern, responsive photography portfolio website built with Astro, Tailwind CSS, and advanced animations.
[![Live Demo](https://img.shields.io/badge/Live-Demo-brightgreen?style=for-the-badge)](https://imagesbyolofsson.se) <br>
[![Astro](https://img.shields.io/badge/Astro-5.16.7-FF5E01?style=for-the-badge&logo=astro)](https://astro.build)
[![Tailwind CSS](https://img.shields.io/badge/Tailwind_CSS-4.1.18-06B6D4?style=for-the-badge&logo=tailwindcss)](https://tailwindcss.com)
## Preview
<div align="center"> <div align="center">
<table> <table>
<tr> <tr>
<td align="center" width="33%"> <td align="center">
<img src="./public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid"/> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img1.jpg" width="250" alt="Portfolio Grid">
<br> <br><em> More </em>
<em>Portfolio Grid</em>
</td> </td>
<td align="center" width="33%"> <td align="center">
<img src="./public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect"/> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img2.jpg" width="250" alt="3D Scroll Effect">
<br> <br><em> At </em>
<em>3D Scroll Effect</em>
</td> </td>
<td align="center" width="33%"> <td align="center">
<img src="./public/3d-scroll/img3.jpg" width="250" alt="Hero Section"/> <img src="https://raw.githubusercontent.com/Images-by-Olofsson/homepage/main/public/3d-scroll/img3.jpg" width="250" alt="Hero Section">
<br> <br><em>Imagesbyolofsson.se</em>
<em>Hero Section</em>
</td> </td>
</tr> </tr>
</table> </table>
</div> </div>
<br>
<br> <br>
## Features ## Features
- **Modern Design** - Minimalist and elegant design with focus on images Modern design with minimalist aesthetics and focus on imagery. Advanced animations including wave effects, 3D scroll transforms, and smoke particles. Fully responsive across all devices with optimized performance.
- **Advanced Animations** - Wave animations, 3D scroll effects, and smoke particles
- **Fully Responsive** - Perfectly adapted for all devices <br>
- **Blazingly Fast** - Built with Astro for maximum performance
- **Interactive Elements** - Hover effects, smooth transitions, and dynamic backgrounds
- **SEO Optimized** - Semantic HTML and metadata
## Quick Start ## Quick Start
### Prerequisites **Prerequisites**
- Node.js 18.0+
- npm or yarn
### Installation Node.js 18.0 or higher
**Installation**
```bash ```bash
# Clone the repo git clone https://github.com/Images-by-Olofsson/homepage.git
git clone https://github.com/LinusOlofsson-maker/imagesbyolofsson.se.git cd homepage
cd imagesbyolofsson.se
# Install dependencies
npm install npm install
```
# Start development server **Development**
```bash
npm run dev npm run dev
```
**Build**
# Build the project ```bash
npm run build npm run build
# Preview production build
npm run preview npm run preview
```
<br>
## Project Structure
```
src/ src/
├── assets/ # Static resources (images, SVGs) ├── assets/
├── components/ # Reusable Astro components ├── components/
│ ├── WaveBackground.astro # Animated wave background │ ├── WaveBackground.astro
│ ├── ThreeDScroll.astro # 3D scroll effect │ ├── ThreeDScroll.astro
│ ├── FloatingOrbs.astro # Floating orb elements │ ├── FloatingOrbs.astro
│ └── Welcome.astro # Welcome component │ └── Welcome.astro
├── layouts/ # Page layouts ├── layouts/
│ ├── MainLayout.astro # Main layout │ ├── MainLayout.astro
│ └── Layout.astro # Base layout │ └── Layout.astro
├── pages/ # Page components ├── pages/
│ ├── index.astro # Home page │ ├── index.astro
│ ├── portfolio.astro # Portfolio page │ ├── portfolio.astro
│ ├── about.astro # About page │ ├── about.astro
│ └── contact.astro # Contact page │ └── contact.astro
└── styles/ # CSS st └── styles/
src/ └── global.css
├── assets/ # Static resources (images, SVGs) ```
├── components/ # Reusable Astro components
│ ├── WaveBackground.astro # Animated wave background
│ ├── ThreeDScroll.astro # 3D scroll effect
│ ├── FloatingOrbs.astro # Floating orb elements
│ └── Welcome.astro # Welcome component
├── layouts/ # Page layouts
│ ├── MainLayout.astro # Main layout
│ └── Layout.astro # Base layout
├── pages/ # Page components
│ ├── index.astro # Home page
│ ├── portfolio.astro # Portfolio page
│ ├── about.astro # About page
│ └── contact.astro # Contact page
└── styles/ # CSS styles
└── global.css # Global styles
Technologies
Astro - Modern web framework
Tailwind CSS - Utility-first CSS framework
TypeScript - Type-safe JavaScript
Exifr - EXIF data reading
Design System
Color Palette
Primary: Cyan (#06B6D4)
Secondary: Purple (#A855F7)
Neutral: Grayscale (#1F2937 - #F9FAFB)
Typography
Headings: Font Black, Tracking Tighter
Body Text: Font Light
Accent: Uppercase, Tracking Widest
Animations
Waves: SVG-based with filter effects
Smoke: Path animations with gaussian blur
3D Scroll: Parallax with transform3d
Transitions: Smooth ease-in/out
Responsive Design
The website is optimized for:
Mobile (< 768px) - Single column, touch-friendly <br>
Tablet (768px - 1024px) - Medium layout
Desktop (> 1024px) - Full experience with all animations
Performance
Lighthouse Score: 95+ Performance
First Contentful Paint: < 1.5s
Largest Contentful Paint: < 2.5s
Cumulative Layout Shift: < 0.1
Contributing
All contributions are welcome! Please follow these steps:
Fork this repo ## Technologies
Create a feature branch (git checkout -b feature/AmazingFeature)
Commit your changes (git commit -m 'Add some AmazingFeature')
Push to the branch (git push origin feature/AmazingFeature)
Open a Pull Request
License
This project is licensed under the MIT License - see the LICENSE file for details.
Contact Astro, Tailwind CSS, TypeScript, Exifr
Website: imagesbyolofsson.se
Email: info@imagesbyolofsson.se
Instagram: @imagesbyolofsson
LinkedIn: Linus Olofsson
Acknowledgments
Thanks to all the amazing open-source projects that made this possible:
Astro - For the fantastic web framework <br>
Tailwind CSS - For the powerful CSS framework
Adobe Lightroom - For image processing
<div align="center"> <p>Built by <a href="https://github.com/LinusOlofsson-maker">Linus Olofsson</a></p> <p>© 2024 Images by Olofsson. All rights reserved.</p> </div> ```
Please confirm you want Copilot to make this change in the LinusOlofsson-maker/Images-by-Olofsson/homepage repository on branch main.yles
└── global.css # Global styles
## License
MIT License - see LICENSE file for details
<br>
<div align="center">
<p>Built by Linus Olofsson</p>
</div>
+8
View File
@@ -0,0 +1,8 @@
import { defineConfig } from 'astro/config';
import tailwindcss from '@tailwindcss/vite';
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
+46
View File
@@ -0,0 +1,46 @@
map $sent_http_content_type $cache_control_strategy {
default "public, max-age=2592000";
~^text/html "no-store, must-revalidate";
~^text/css "public, max-age=31536000, immutable";
~^application/javascript "public, max-age=31536000, immutable";
~^application/json "no-store, must-revalidate";
~^image/ "public, max-age=2592000";
~^font/ "public, max-age=31536000, immutable";
}
server {
listen 80 default_server;
server_name _;
root /usr/share/nginx/html;
index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "SAMEORIGIN" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;
add_header Cache-Control $cache_control_strategy always;
location / {
try_files $uri $uri/ $uri.html $uri/index.html =404;
}
location /_astro/ {
access_log off;
expires 1y;
}
location ~* \.(webp|avif|jpg|jpeg|png|gif|svg|ico|woff2?|ttf|otf|eot)$ {
access_log off;
expires 30d;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml application/json application/javascript application/xml+rss image/svg+xml;
error_page 404 /404.html;
location = /404.html {
internal;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
// Portfolio-synk: speglar Immich "portfolio"-taggade bilder till src/assets/images/portfolio/
// Självläkande: härleder synkat tillstånd från immich-<assetId>-*-filnamn i repot.
// Bevarar EXIF (portfolion läser DateTimeOriginal/Model/ISO för år/månad-filter + lightbox).
import fs from "node:fs";
import path from "node:path";
import sharp from "sharp";
const IMMICH = process.env.IMMICH_URL || "https://foto.imagesbyolofsson.se/api";
const KEY = process.env.IMMICH_API_KEY;
const TAGID = process.env.PORTFOLIO_TAG_ID || "67d16b6c-0dea-42ef-a289-2c9e57fa5c5a";
const PORTFOLIO_DIR = "src/assets/images/portfolio";
const MAX_DIM = 3000;
const JPEG_QUALITY = 88;
// RAW-format som sharp/libvips inte kan konvertera — hoppas över elegant.
// Tagga JPEG-versionen (redigera RAW → exportera JPEG → tagga den) istället.
const RAW_EXTENSIONS = [".cr2", ".cr3", ".arw", ".nef", ".dng", ".raf", ".orf", ".rw2", ".pef", ".srw"];
function isRaw(name) {
const lower = name.toLowerCase();
return RAW_EXTENSIONS.some((ext) => lower.endsWith(ext));
}
if (!KEY) { console.error("IMMICH_API_KEY saknas"); process.exit(1); }
async function api(method, pathname, body) {
const res = await fetch(IMMICH + pathname, {
method,
headers: { "x-api-key": KEY, "Content-Type": "application/json" },
body: body ? JSON.stringify(body) : undefined,
});
if (!res.ok) throw new Error(`${method} ${pathname} → ${res.status}: ${(await res.text()).slice(0, 200)}`);
return res;
}
// Hämta alla portfolio-taggade assets (paginerat, unikt på id — search/metadata ger dubbletter över sidgräns)
async function fetchTagged() {
const map = new Map();
let page = 1;
while (true) {
const res = await api("POST", "/search/metadata", { tagIds: [TAGID], size: 250, page });
const d = await res.json();
const items = d.assets.items;
if (!items.length) break;
for (const a of items) {
const date = a.exifInfo?.dateTimeOriginal || a.fileCreatedAt;
map.set(a.id, { id: a.id, name: a.originalFileName, date });
}
const next = d.assets.nextPage;
if (!next) break;
page = Number(next);
}
return [...map.values()];
}
// Vilka immich-<assetId>-*.jpg finns redan i repot? → { assetId: filename }
function existingSynced() {
if (!fs.existsSync(PORTFOLIO_DIR)) return {};
const out = {};
for (const f of fs.readdirSync(PORTFOLIO_DIR)) {
const m = f.match(/^immich-([0-9a-f-]{36})-/i);
if (m) out[m[1]] = f;
}
return out;
}
// Bygg destinationsfilnamn: immich-<assetId>-<ÅÅÅÅMMDD>-<safeOriginalName>.jpg
function destName(asset) {
const d = asset.date ? new Date(asset.date) : new Date();
const ymd = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, "0")}${String(d.getDate()).padStart(2, "0")}`;
// strippa ev. "<assetid>__"-prefix från originalnamnet (från migreringen), behåll basnamn utan ändelse
let base = asset.name.replace(/^[0-9a-f-]{36}__/i, "").replace(/\.[^.]+$/, "");
base = base.replace(/[^\w.\-]/g, "_").slice(0, 60);
return `immich-${asset.id}-${ymd}-${base}.jpg`;
}
async function downloadAndResize(asset, destPath) {
const res = await api("GET", `/assets/${asset.id}/original`);
const buf = Buffer.from(await res.arrayBuffer());
await sharp(buf)
.rotate() // respektera EXIF-orientering
.resize(MAX_DIM, MAX_DIM, { fit: "inside", withoutEnlargement: true })
.withMetadata() // BEVARA EXIF (kritiskt för portfolions datum/kamera)
.jpeg({ quality: JPEG_QUALITY })
.toFile(destPath);
}
async function main() {
const taggedAll = await fetchTagged();
// Hoppa över RAW-filer elegant — de räknas inte mot synk (tagga JPEG-versionen istället)
const skippedRaw = taggedAll.filter((a) => isRaw(a.name));
const tagged = taggedAll.filter((a) => !isRaw(a.name));
const synced = existingSynced();
const taggedIds = new Set(tagged.map((a) => a.id));
const toAdd = tagged.filter((a) => !synced[a.id]);
const toRemove = Object.entries(synced).filter(([id]) => !taggedIds.has(id)).map(([id, file]) => ({ id, file }));
console.log(`portfolio-synk: ${taggedAll.length} taggade (${tagged.length} bilder + ${skippedRaw.length} RAW), ${Object.keys(synced).length} redan synkade`);
console.log(`→ lägg till: ${toAdd.length}, ta bort: ${toRemove.length}`);
if (skippedRaw.length) {
console.log(`⏭ Hoppade över ${skippedRaw.length} RAW-fil(er) — tagga JPEG-versionen istället:`);
for (const r of skippedRaw) console.log(` ${r.name}`);
}
fs.mkdirSync(PORTFOLIO_DIR, { recursive: true });
for (const a of toAdd) {
const fname = destName(a);
const dest = path.join(PORTFOLIO_DIR, fname);
try {
await downloadAndResize(a, dest);
console.log(` + ${fname}`);
} catch (e) {
console.error(` ! misslyckades ${a.name}: ${e.message}`);
process.exitCode = 1; // signalera fel men fortsätt med resten
}
}
for (const r of toRemove) {
fs.rmSync(path.join(PORTFOLIO_DIR, r.file), { force: true });
console.log(` - ${r.file}`);
}
// skriv ut om något ändrats (workflow läser detta för att avgöra commit)
const changed = toAdd.length > 0 || toRemove.length > 0;
fs.writeFileSync(process.env.GITHUB_OUTPUT || "/dev/stdout",
`changed=${changed}\nadded=${toAdd.length}\nremoved=${toRemove.length}\n`, { flag: "a" });
}
main().catch((e) => { console.error("FEL:", e.message); process.exit(1); });
Binary file not shown.

After

Width:  |  Height:  |  Size: 1010 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 802 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 606 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 728 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 742 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 952 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 991 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 895 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 485 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 505 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 797 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 709 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 712 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 832 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 784 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 497 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 680 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 831 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 708 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 869 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 809 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 471 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 701 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 593 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 732 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 861 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 881 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 889 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 963 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 856 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 KiB

+611 -32
View File
@@ -2,15 +2,20 @@
// src/components/WaveBackground.astro // src/components/WaveBackground.astro
const { opacity = "opacity-30", theme = "cyan" } = Astro.props; const { opacity = "opacity-30", theme = "cyan" } = Astro.props;
// Definiera färger baserat på tema const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5';
// Cyan = Originalet (Startsidan bas) const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee';
// Purple = Contact-sidan (Matchar boxen) const pulseMain = theme === 'purple' ? '#f0abfc' : '#a5f3fc';
const mainColor = theme === 'purple' ? '#c026d3' : '#4fd1c5'; // Purple-600 vs Teal-400 const pulseSec = theme === 'purple' ? '#fbcfe8' : '#cffafe';
const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 vs Cyan-400 const pulseCore = theme === 'purple' ? '#fdf4ff' : '#f0fdfa';
--- ---
<div class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}> <div
<svg class="w-full h-full" xmlns="http://www.w3.org/2000/svg"> class={`fixed inset-0 w-full h-full pointer-events-none -z-10 bg-neutral-900 ${opacity}`}
data-wave-theme={theme}
data-pulse-main={pulseMain}
data-pulse-sec={pulseSec}
>
<svg id="wave-svg" class="w-full h-full" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="none">
<defs> <defs>
<linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%"> <linearGradient id="gradMain" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} /> <stop offset="0%" style={`stop-color:${mainColor};stop-opacity:0`} />
@@ -23,48 +28,615 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
<stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} /> <stop offset="30%" style={`stop-color:${secondaryColor};stop-opacity:0.3`} />
<stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} /> <stop offset="100%" style={`stop-color:${secondaryColor};stop-opacity:0`} />
</linearGradient> </linearGradient>
<linearGradient id="gradPurple" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#c026d3;stop-opacity:0" />
<stop offset="50%" style="stop-color:#c026d3;stop-opacity:0.45" />
<stop offset="100%" style="stop-color:#c026d3;stop-opacity:0" />
</linearGradient>
<filter id="pulseGlowSoft" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="22" />
</filter>
<filter id="pulseGlowMid" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="9" />
</filter>
<filter id="pulseGlowSharp" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="2.5" />
</filter>
<radialGradient id="pulseHaloGradient">
<stop offset="0%" style={`stop-color:${pulseMain};stop-opacity:0.85`} />
<stop offset="40%" style={`stop-color:${pulseMain};stop-opacity:0.35`} />
<stop offset="100%" style={`stop-color:${pulseMain};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseMidGradient">
<stop offset="0%" style={`stop-color:${pulseSec};stop-opacity:1`} />
<stop offset="60%" style={`stop-color:${pulseSec};stop-opacity:0.5`} />
<stop offset="100%" style={`stop-color:${pulseSec};stop-opacity:0`} />
</radialGradient>
<radialGradient id="pulseCoreGradient">
<stop offset="0%" style={`stop-color:${pulseCore};stop-opacity:1`} />
<stop offset="50%" style={`stop-color:${pulseCore};stop-opacity:0.6`} />
<stop offset="100%" style={`stop-color:${pulseCore};stop-opacity:0`} />
</radialGradient>
</defs> </defs>
<g id="wave-group"> <g id="wave-group">
<path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" /> <path id="wave1" d="M-1000 300 Q 250 100 800 300 T 2600 300" fill="none" stroke="url(#gradMain)" stroke-width="1" data-pulse-path="true" />
<path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" /> <path id="wave2" d="M-1000 500 Q 250 700 800 500 T 2600 500" fill="none" stroke="url(#gradMain)" stroke-width="1.5" data-pulse-path="true" />
<path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" /> <path id="wave3" d="M-1000 700 Q 250 500 800 700 T 2600 700" fill="none" stroke="url(#gradMain)" stroke-width="0.5" data-pulse-path="true" />
<path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" /> <path id="thread1" d="M-1000 200 Q 400 400 900 200 T 2600 200" fill="none" stroke="url(#gradSec)" stroke-width="0.5" class="opacity-50" data-pulse-path="true" />
<path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" /> <path id="thread2" d="M-1000 800 Q 600 600 1100 800 T 2600 800" fill="none" stroke="url(#gradSec)" stroke-width="0.8" class="opacity-60" data-pulse-path="true" />
</g>
<g id="box-trail-group"></g>
<g id="pulse-group">
<g id="pulse-blob-1" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</g>
<g id="pulse-blob-2" opacity="0">
<ellipse rx="55" ry="22" fill="url(#pulseHaloGradient)" filter="url(#pulseGlowSoft)" />
<ellipse rx="32" ry="11" fill="url(#pulseMidGradient)" filter="url(#pulseGlowMid)" />
<ellipse rx="10" ry="3" fill="url(#pulseCoreGradient)" filter="url(#pulseGlowSharp)" opacity="0.75" />
</g>
</g> </g>
</svg> </svg>
</div> </div>
<script> <script>
const wave1 = document.getElementById('wave1'); type Speeds = { w1: number; w2: number; w3: number; t1: number; t2: number };
const wave2 = document.getElementById('wave2');
const wave3 = document.getElementById('wave3');
const thread1 = document.getElementById('thread1');
const thread2 = document.getElementById('thread2');
// Olika hastigheter för parallax-effekt const wave1 = document.getElementById('wave1') as SVGPathElement | null;
const speeds = { const wave2 = document.getElementById('wave2') as SVGPathElement | null;
w1: 0.5, const wave3 = document.getElementById('wave3') as SVGPathElement | null;
w2: -0.8, const thread1 = document.getElementById('thread1') as SVGPathElement | null;
w3: 1.2, const thread2 = document.getElementById('thread2') as SVGPathElement | null;
t1: 1.8, // Snabbare "random" tråd const svg = document.getElementById('wave-svg') as unknown as SVGSVGElement | null;
t2: -1.5 // Snabbare "random" tråd åt andra hållet const blob1 = document.getElementById('pulse-blob-1') as SVGGElement | null;
}; const blob2 = document.getElementById('pulse-blob-2') as SVGGElement | null;
const boxGroup = document.getElementById('box-trail-group') as SVGGElement | null;
const wrapper = svg?.parentElement;
const speeds: Speeds = { w1: 0.5, w2: -0.8, w3: 1.2, t1: 1.8, t2: -1.5 };
function getSpeedKey(id: string): keyof Speeds | null {
if (id === 'wave1') return 'w1';
if (id === 'wave2') return 'w2';
if (id === 'wave3') return 'w3';
if (id === 'thread1') return 't1';
if (id === 'thread2') return 't2';
return null;
}
function onScroll() { function onScroll() {
const scrollY = window.scrollY; const scrollY = window.scrollY;
if (wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`;
if (wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
if (wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
if (thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`;
if (thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`;
}
window.addEventListener('scroll', onScroll, { passive: true });
if(wave1) wave1.style.transform = `translateX(${scrollY * speeds.w1}px)`; type BoxRect = { left: number; right: number; top: number; bottom: number; cx: number; cy: number; r: number };
if(wave2) wave2.style.transform = `translateX(${scrollY * speeds.w2}px)`;
if(wave3) wave3.style.transform = `translateX(${scrollY * speeds.w3}px)`;
// Random trådarna rör sig mer if (svg && blob1 && blob2 && wrapper) {
if(thread1) thread1.style.transform = `translateX(${scrollY * speeds.t1}px)`; const pulseMain = wrapper.getAttribute('data-pulse-main') || '#a5f3fc';
if(thread2) thread2.style.transform = `translateX(${scrollY * speeds.t2}px)`; const pulseSec = wrapper.getAttribute('data-pulse-sec') || '#cffafe';
const wavePaths = Array.from(document.querySelectorAll<SVGPathElement>('[data-pulse-path="true"]'));
const NS = 'http://www.w3.org/2000/svg';
function clearBoxGroup() {
if (!boxGroup) return;
while (boxGroup.firstChild) boxGroup.removeChild(boxGroup.firstChild);
} }
window.addEventListener('scroll', onScroll, { passive: true }); function refreshBoxes(): BoxRect[] {
const targets = document.querySelectorAll<HTMLElement>('[data-trail-box]');
const out: BoxRect[] = [];
targets.forEach(el => {
const rect = el.getBoundingClientRect();
if (rect.width < 20 || rect.height < 20) return;
if (rect.bottom < 0 || rect.top > window.innerHeight) return;
out.push({
left: rect.left,
right: rect.right,
top: rect.top,
bottom: rect.bottom,
cx: rect.left + rect.width / 2,
cy: rect.top + rect.height / 2,
r: 16,
});
});
return out;
}
function updatePulseColors(isSecondary: boolean) {
const haloColor = isSecondary ? pulseSec : pulseMain;
const midColor = isSecondary ? pulseMain : pulseSec;
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${haloColor};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${haloColor};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${midColor};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${midColor};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${midColor};stop-opacity:0`);
}
function pointInBox(x: number, y: number, b: BoxRect): boolean {
return x >= b.left && x <= b.right && y >= b.top && y <= b.bottom;
}
type Intersection = {
boxIdx: number;
entryLen: number;
exitLen: number;
entryX: number;
entryY: number;
exitX: number;
exitY: number;
};
function findBoxIntersection(
path: SVGPathElement,
pathTx: number,
boxes: BoxRect[]
): Intersection | null {
const total = path.getTotalLength();
const step = 3;
let activeBox = -1;
let entryLen = -1;
let entryX = 0, entryY = 0;
for (let len = 0; len <= total; len += step) {
const pt = path.getPointAtLength(len);
const x = pt.x + pathTx;
const y = pt.y;
if (activeBox === -1) {
for (let bi = 0; bi < boxes.length; bi++) {
if (pointInBox(x, y, boxes[bi])) {
activeBox = bi;
entryLen = len;
entryX = x;
entryY = y;
break;
}
}
} else {
if (!pointInBox(x, y, boxes[activeBox])) {
return {
boxIdx: activeBox,
entryLen,
exitLen: len,
entryX,
entryY,
exitX: x,
exitY: y,
};
}
}
}
return null;
}
function buildSplitHalfPath(
box: BoxRect,
entry: { x: number; y: number },
exit: { x: number; y: number },
half: 'top' | 'bottom'
): SVGPathElement {
// Path goes: entry → mjuk kurva mot box-kant (top eller bottom) → traverserar horisontellt över box-kanten → mjuk kurva från box-kant till exit-punkt
// KRITISKT: pathen ska BARA röra sig framåt (entry.x → exit.x), aldrig vända tillbaka
const xL = box.left, xR = box.right, yT = box.top, yB = box.bottom, r = box.r;
// För att undvika "tillbaka-rörelse" när entry/exit ligger UTANFÖR boxens X-range:
// Använd entry.x och exit.x som start/end-punkter på den horisontella delen,
// clampad till box-kanten med hörn-radius
const startX = Math.max(xL + r, Math.min(xR - r, entry.x));
const endX = Math.max(xL + r, Math.min(xR - r, exit.x));
// Top/bottom Y-koordinat för traversal
const traverseY = half === 'top' ? yT : yB;
// Distans från entry/exit till traverseY (för smooth bezier-overgång)
const entryToTraverseY = Math.abs(entry.y - traverseY);
const exitToTraverseY = Math.abs(exit.y - traverseY);
// Kontrollpunkter för Bezier — låt dem peka i rätt riktning (top eller bottom)
// cp1: nära entry, dragen mot traverseY
const cp1x = entry.x;
const cp1y = entry.y + (half === 'top' ? -entryToTraverseY * 0.5 : entryToTraverseY * 0.5);
// cp2: vid startX, redan på traverseY-nivå
const cp2x = startX;
const cp2y = traverseY;
// cp3: vid endX, fortfarande på traverseY
const cp3x = endX;
const cp3y = traverseY;
// cp4: nära exit, dragen från traverseY
const cp4x = exit.x;
const cp4y = exit.y + (half === 'top' ? -exitToTraverseY * 0.5 : exitToTraverseY * 0.5);
const d = [
`M ${entry.x} ${entry.y}`,
`C ${cp1x} ${cp1y}, ${cp2x} ${cp2y}, ${startX} ${traverseY}`,
`L ${endX} ${traverseY}`,
`C ${cp3x} ${cp3y}, ${cp4x} ${cp4y}, ${exit.x} ${exit.y}`,
].join(' ');
const p = document.createElementNS(NS, 'path');
p.setAttribute('d', d);
p.setAttribute('fill', 'none');
p.setAttribute('stroke', 'transparent');
return p;
}
function applyTransform(blob: SVGGElement, x: number, y: number, angle: number) {
blob.setAttribute('transform', `translate(${x} ${y}) rotate(${angle})`);
}
function tangentAngle(path: SVGPathElement, len: number, total: number): number {
const delta = Math.max(1, total * 0.002);
const before = path.getPointAtLength(Math.max(0, len - delta));
const after = path.getPointAtLength(Math.min(total, len + delta));
return Math.atan2(after.y - before.y, after.x - before.x) * (180 / Math.PI);
}
function fadeFactor(t: number): number {
if (t < 0.18) return t / 0.18;
if (t > 0.82) return (1 - t) / 0.18;
return 1;
}
let currentTimeout: number | null = null;
let isPulsing = false;
function runThreadPulse(path: SVGPathElement, durationMs: number) {
if (!blob1 || !blob2 || !boxGroup) return;
isPulsing = true;
clearBoxGroup();
const total = path.getTotalLength();
const isSecondary =
(path.getAttribute('stroke') || '').includes('gradSec') ||
path.id === 'thread1' ||
path.id === 'thread2';
updatePulseColors(isSecondary);
const speedKey = getSpeedKey(path.id);
const boxes = refreshBoxes();
const pathTx0 = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = findBoxIntersection(path, pathTx0, boxes);
const velocity = total / durationMs;
const startTime = performance.now();
blob2.setAttribute('opacity', '0');
const entryLen = hit ? hit.entryLen : total;
const exitLen = hit ? hit.exitLen : total;
const phase1Duration = entryLen / velocity;
let topPath: SVGPathElement | null = null;
let bottomPath: SVGPathElement | null = null;
let phase2Duration = 0;
// För TIME-SYNCED meeting: båda blobs animeras med samma t (0→1), olika path-längder = olika faktisk hastighet
// Phase2-duration baseras på genomsnittlig path-längd så hastighets-skillnaden hålls liten
if (hit) {
const entryPt = { x: hit.entryX, y: hit.entryY };
const exitPt = { x: hit.exitX, y: hit.exitY };
topPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'top');
bottomPath = buildSplitHalfPath(boxes[hit.boxIdx], entryPt, exitPt, 'bottom');
boxGroup.appendChild(topPath);
boxGroup.appendChild(bottomPath);
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// Genomsnittlig längd för fart-beräkning (så snabba/långsamma delas jämnt)
const avgLen = (topLen + bottomLen) / 2;
phase2Duration = avgLen / velocity;
}
const phase3Duration = hit ? (total - exitLen) / velocity : 0;
const totalTime = phase1Duration + phase2Duration + phase3Duration;
function step(now: number) {
if (!blob1 || !blob2) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
if (elapsed <= phase1Duration) {
const len = elapsed * velocity;
const pt = path.getPointAtLength(Math.min(len, total));
const angle = tangentAngle(path, Math.min(len, total), total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
} else if (hit && elapsed <= phase1Duration + phase2Duration) {
const inPhase2 = elapsed - phase1Duration;
if (topPath && bottomPath) {
const topLen = topPath.getTotalLength();
const bottomLen = bottomPath.getTotalLength();
// TIME-SYNCED: båda blobs har samma t (0→1) inom phase2 → möts garanterat samtidigt
// Hastighets-skillnad uppstår automatiskt om topLen ≠ bottomLen
const phase2T = Math.min(inPhase2 / phase2Duration, 1);
const tDist = phase2T * topLen;
const bDist = phase2T * bottomLen;
const tp = topPath.getPointAtLength(tDist);
const ta = tangentAngle(topPath, tDist, topLen);
applyTransform(blob1, tp.x, tp.y, ta);
blob1.setAttribute('opacity', String(fade));
const bp = bottomPath.getPointAtLength(bDist);
const ba = tangentAngle(bottomPath, bDist, bottomLen);
applyTransform(blob2, bp.x, bp.y, ba);
blob2.setAttribute('opacity', String(fade));
}
} else if (hit) {
const inPhase3 = elapsed - phase1Duration - phase2Duration;
const len = exitLen + inPhase3 * velocity;
const clamped = Math.min(len, total);
const pt = path.getPointAtLength(clamped);
const angle = tangentAngle(path, clamped, total);
applyTransform(blob1, pt.x + pathTx, pt.y, angle);
blob1.setAttribute('opacity', String(fade));
blob2.setAttribute('opacity', '0');
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} else {
blob1.setAttribute('opacity', '0');
blob2.setAttribute('opacity', '0');
clearBoxGroup();
isPulsing = false;
}
}
requestAnimationFrame(step);
}
const SPLIT_PROBABILITY = 0.55;
function _unused_isLogoTargetVisible(): HTMLElement | null {
const el = document.querySelector<HTMLElement>('[data-logo-target]');
if (!el) return null;
const rect = el.getBoundingClientRect();
if (rect.bottom < 0 || rect.top > window.innerHeight) return null;
if (rect.width < 50 || rect.height < 50) return null;
return el;
}
function updateLogoThreadPaths() {
const logo = document.querySelector<HTMLElement>('[data-logo-target]');
const threadIn = document.getElementById('threadLogoIn') as SVGPathElement | null;
const threadOut = document.getElementById('threadLogoOut') as SVGPathElement | null;
if (!logo || !threadIn || !threadOut) return;
const rect = logo.getBoundingClientRect();
const vw = window.innerWidth;
const vh = window.innerHeight;
// Bracket-pinnarnas SLUT (där vertikala benen tar slut)
// ::before: top-left bracket, height=40% av text-wrapper. Vänstra benet går nedåt 40% av höjden
// Lås-punkt: (rect.left, rect.top + 0.40 * rect.height) — pinnens nedre spets
const leftPinX = rect.left;
const leftPinY = rect.top + rect.height * 0.40;
// ::after: bottom-right bracket. Höger ben börjar 40% från botten
// Lås-punkt: (rect.right, rect.bottom - 0.40 * rect.height) — pinnens övre spets
const rightPinX = rect.right;
const rightPinY = rect.bottom - rect.height * 0.40;
// Rosa tråd: från (0, vh) → (leftPinX, leftPinY)
// x² curve: kontrollpunkt (endX, startY) = rakt fram, sedan upp = accelererande lutning
const inStartX = 0, inStartY = vh;
const inEndX = leftPinX, inEndY = leftPinY;
const inCpX = inEndX, inCpY = inStartY;
threadIn.setAttribute('d', `M ${inStartX} ${inStartY} Q ${inCpX} ${inCpY}, ${inEndX} ${inEndY}`);
// Cyan tråd: från (rightPinX, rightPinY) → (vw, 0)
// -x² curve: kontrollpunkt (startX, endY) = upp först, sedan rakt = decelererande lutning
const outStartX = rightPinX, outStartY = rightPinY;
const outEndX = vw, outEndY = 0;
const outCpX = outStartX, outCpY = outEndY;
threadOut.setAttribute('d', `M ${outStartX} ${outStartY} Q ${outCpX} ${outCpY}, ${outEndX} ${outEndY}`);
}
// Uppdatera vid load + resize + scroll (logo-position beror på alla tre)
updateLogoThreadPaths();
window.addEventListener('resize', updateLogoThreadPaths);
window.addEventListener('scroll', updateLogoThreadPaths, { passive: true });
// Re-run efter en kort delay för att Astro layout ska vara klar
setTimeout(updateLogoThreadPaths, 500);
setTimeout(updateLogoThreadPaths, 1500);
function runLogoPulse(durationMs: number) {
if (!blob1) return;
const logo = isLogoTargetVisible();
const entryPath = document.getElementById('threadLogoIn') as SVGPathElement | null;
const exitPath = document.getElementById('threadLogoOut') as SVGPathElement | null;
if (!logo || !entryPath || !exitPath) {
schedule();
return;
}
isPulsing = true;
updatePulseColors(false);
// Uppdatera path-d till EXAKT nuvarande logo-position innan pulse börjar
updateLogoThreadPaths();
const rect = logo.getBoundingClientRect();
const logoLeftX = rect.left + 8;
const logoRightX = rect.right - 8;
const logoY = rect.top + rect.height / 2;
// Använd permanenta wave-trådar (threadLogoIn + threadLogoOut)
// Hitta var de KORSAR logo-x-koordinater i screen-space
const entryTotal = entryPath.getTotalLength();
const exitTotal = exitPath.getTotalLength();
// Sök på entryPath efter den punkt där SVG-pt.x motsvarar logoLeftX
// OBS: pathen är i SVG-koord (-1000..2600), klient-koord är getBoundingClientRect
// Vi mappar via path.getBoundingClientRect() vs path.getBBox()
function findLenForScreenX(p: SVGPathElement, targetScreenX: number, total: number): number {
// SVG till screen: använd ctm
const ctm = p.getScreenCTM();
if (!ctm) return total * 0.5;
let bestLen = total;
for (let l = 0; l <= total; l += 6) {
const pt = p.getPointAtLength(l);
const screenX = ctm.a * pt.x + ctm.c * pt.y + ctm.e;
if (screenX >= targetScreenX) {
bestLen = l;
break;
}
}
return bestLen;
}
const entryLen = findLenForScreenX(entryPath, logoLeftX, entryTotal);
const exitStartLen = findLenForScreenX(exitPath, logoRightX, exitTotal);
const exitLen = Math.min(exitTotal, exitStartLen + (exitTotal - exitStartLen) * 0.9);
const phase1Duration = durationMs * 0.35;
const phase2Duration = durationMs * 0.30;
const phase3Duration = durationMs * 0.35;
const totalTime = phase1Duration + phase2Duration + phase3Duration;
const startTime = performance.now();
const pinkColor = '#e879f9';
const greenColor = '#4fd1c5';
function setPulseTint(color: string) {
const haloStops = document.querySelectorAll('#pulseHaloGradient stop');
if (haloStops[0]) haloStops[0].setAttribute('style', `stop-color:${color};stop-opacity:0.85`);
if (haloStops[1]) haloStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.35`);
if (haloStops[2]) haloStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
const midStops = document.querySelectorAll('#pulseMidGradient stop');
if (midStops[0]) midStops[0].setAttribute('style', `stop-color:${color};stop-opacity:1`);
if (midStops[1]) midStops[1].setAttribute('style', `stop-color:${color};stop-opacity:0.5`);
if (midStops[2]) midStops[2].setAttribute('style', `stop-color:${color};stop-opacity:0`);
}
setPulseTint(pinkColor);
blob2 && blob2.setAttribute('opacity', '0');
function step(now: number) {
if (!blob1) return;
const elapsed = now - startTime;
const globalT = Math.min(elapsed / totalTime, 1);
const fade = fadeFactor(globalT);
if (elapsed <= phase1Duration) {
// Glide in på entry-path (lila, permanent tråd)
// Pulse är inuti SAMMA SVG som pathen — använd SVG-koord direkt
const t = elapsed / phase1Duration;
const len = t * entryLen;
const pt = entryPath.getPointAtLength(len);
const angle = tangentAngle(entryPath, len, entryTotal);
applyTransform(blob1!, pt.x, pt.y, angle);
blob1!.setAttribute('opacity', String(fade));
} else if (elapsed <= phase1Duration + phase2Duration) {
// INSIDE logo — pulse glider från entry-end-position till exit-start-position
// i SVG-koord-space (samma som phase1 och phase3)
const t = (elapsed - phase1Duration) / phase2Duration;
const entryEnd = entryPath.getPointAtLength(entryLen);
const exitStart = exitPath.getPointAtLength(exitStartLen);
const cx = entryEnd.x + (exitStart.x - entryEnd.x) * t;
const cy = entryEnd.y + (exitStart.y - entryEnd.y) * t;
applyTransform(blob1!, cx, cy, 0);
blob1!.setAttribute('opacity', String(fade * 0.6)); // dimmare i logo (logo-färg tar över visuellt)
// Färg-shift: rosa första halvan, grön andra halvan
if (t < 0.5) {
logo!.setAttribute('data-logo-tint', 'pink');
setPulseTint(pinkColor);
} else {
logo!.setAttribute('data-logo-tint', 'green');
setPulseTint(greenColor);
}
} else {
// Glide ut på exit-path (permanent cyan-tråd) — SVG-koord direkt
const t = Math.min((elapsed - phase1Duration - phase2Duration) / phase3Duration, 1);
const len = exitStartLen + (exitLen - exitStartLen) * t;
const pt = exitPath.getPointAtLength(len);
const angle = tangentAngle(exitPath, len, exitTotal);
applyTransform(blob1!, pt.x, pt.y, angle);
blob1!.setAttribute('opacity', String(fade));
if (logo!.getAttribute('data-logo-tint')) {
logo!.removeAttribute('data-logo-tint');
}
}
if (elapsed < totalTime && globalT < 1) {
requestAnimationFrame(step);
} else {
blob1!.setAttribute('opacity', '0');
logo!.removeAttribute('data-logo-tint');
// Återställ pulse-gradient till default cyan-tema
updatePulseColors(false);
isPulsing = false;
}
}
requestAnimationFrame(step);
}
function pickAndPulse() {
if (isPulsing) {
schedule();
return;
}
if (wavePaths.length === 0) {
schedule();
return;
}
const boxes = refreshBoxes();
const intersecting: SVGPathElement[] = [];
const nonIntersecting: SVGPathElement[] = [];
for (const p of wavePaths) {
const speedKey = getSpeedKey(p.id);
const pathTx = speedKey ? window.scrollY * speeds[speedKey] : 0;
const hit = boxes.length > 0 ? findBoxIntersection(p, pathTx, boxes) : null;
if (hit) intersecting.push(p);
else nonIntersecting.push(p);
}
let choice: SVGPathElement;
const wantSplit = Math.random() < SPLIT_PROBABILITY;
if (wantSplit && intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else if (!wantSplit && nonIntersecting.length > 0) {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
} else if (intersecting.length > 0) {
choice = intersecting[Math.floor(Math.random() * intersecting.length)];
} else {
choice = nonIntersecting[Math.floor(Math.random() * nonIntersecting.length)];
}
runThreadPulse(choice, 18000 + Math.random() * 8000);
schedule();
}
function schedule() {
if (currentTimeout) clearTimeout(currentTimeout);
const delay = 3500 + Math.random() * 5500;
currentTimeout = window.setTimeout(pickAndPulse, delay);
}
const startDelay = 1500 + Math.random() * 2000;
currentTimeout = window.setTimeout(pickAndPulse, startDelay);
}
</script> </script>
<style> <style>
@@ -72,4 +644,11 @@ const secondaryColor = theme === 'purple' ? '#db2777' : '#22d3ee'; // Pink-600 v
transition: transform 0.1s linear; transition: transform 0.1s linear;
will-change: transform; will-change: transform;
} }
#pulse-blob-1, #pulse-blob-2 {
mix-blend-mode: screen;
}
#pulse-blob-1 ellipse, #pulse-blob-2 ellipse {
transform-box: fill-box;
transform-origin: center;
}
</style> </style>
+9
View File
@@ -6,6 +6,9 @@ const { title } = Astro.props;
<html lang="sv" class="scroll-smooth"> <head> <html lang="sv" class="scroll-smooth"> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width" /> <meta name="viewport" content="width=device-width" />
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png" />
<link rel="icon" type="image/png" sizes="512x512" href="/favicon-512.png" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon-180.png" />
<title>{title}</title> <title>{title}</title>
</head> </head>
<body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col"> <body class="bg-neutral-900 text-white font-sans antialiased min-h-screen flex flex-col">
@@ -30,6 +33,12 @@ const { title } = Astro.props;
</main> </main>
<footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40"> <footer class="border-t border-neutral-800 py-8 text-center text-gray-500 text-sm bg-neutral-900 relative z-40">
<p class="mb-2">
Crafting digital experiences for
<a href="https://lyxlimo.se/" target="_blank" rel="noopener noreferrer" data-glow-link="green" class="text-gray-400 hover:text-white transition-colors">lyxlimo.se</a>
&amp;
<a href="https://karleksguiden.se/" target="_blank" rel="noopener noreferrer" data-glow-link="magenta" class="text-gray-400 hover:text-white transition-colors">karleksguiden.se</a>
</p>
<p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved.</p> <p>&copy; {new Date().getFullYear()} Images by Olofsson. All rights reserved.</p>
</footer> </footer>
+4 -4
View File
@@ -12,7 +12,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="container mx-auto px-4 py-12 md:py-20 relative z-10"> <div class="container mx-auto px-4 py-12 md:py-20 relative z-10">
<div class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20"> <div data-trail-box class="bg-neutral-900/60 backdrop-blur-md border border-white/10 rounded-3xl p-8 md:p-12 shadow-2xl mb-20">
<div class="flex flex-col lg:flex-row items-center gap-12 lg:gap-20"> <div class="flex flex-col lg:flex-row items-center gap-12 lg:gap-20">
@@ -73,7 +73,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<div class="gear-card"> <div data-trail-box class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 9a2 2 0 012-2h.93a2 2 0 001.664-.89l.812-1.22A2 2 0 0110.07 4h3.86a2 2 0 011.664.89l.812 1.22A2 2 0 0018.07 7H19a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V9z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 13a3 3 0 11-6 0 3 3 0 016 0z" /></svg>
</div> </div>
@@ -82,7 +82,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</p> <p class="text-gray-500 text-sm mt-2">A trusty companion that has been with me on countless adventures.</p>
</div> </div>
<div class="gear-card"> <div data-trail-box class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" /><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" /></svg>
</div> </div>
@@ -94,7 +94,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</ul> </ul>
</div> </div>
<div class="gear-card"> <div data-trail-box class="gear-card">
<div class="icon-box"> <div class="icon-box">
<svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg> <svg class="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" /></svg>
</div> </div>
+1 -1
View File
@@ -61,7 +61,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="relative group"> <div class="relative group">
<div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div> <div class="absolute -inset-1 bg-gradient-to-r from-purple-600 to-pink-600 rounded-2xl blur opacity-25 group-hover:opacity-50 transition duration-1000 group-hover:duration-200"></div>
<div class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10"> <div data-trail-box class="relative bg-neutral-900 ring-1 ring-white/10 rounded-xl p-8 md:p-12 text-center space-y-10">
<div> <div>
<h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3> <h3 class="text-2xl font-bold text-white mb-2">Waiting to hear from you</h3>
<p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p> <p class="text-gray-400">Feel free to reach out for inquiries or just a chat.</p>
+56 -3
View File
@@ -15,7 +15,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="text-center animated-text-container relative"> <div class="text-center animated-text-container relative">
<div id="text-wrapper" class="inline-block relative p-6 transition-all duration-500"> <div id="text-wrapper" data-logo-target class="inline-block relative p-6 transition-all duration-500">
<h1 class="text-5xl md:text-8xl font-black text-white tracking-tighter leading-none drop-shadow-[0_0_10px_rgba(34,211,238,0.4)] relative z-10"> <h1 class="text-5xl md:text-8xl font-black text-white tracking-tighter leading-none drop-shadow-[0_0_10px_rgba(34,211,238,0.4)] relative z-10">
IMAGES BY <br/> OLOFSSON IMAGES BY <br/> OLOFSSON
@@ -51,7 +51,7 @@ import WaveBackground from '../components/WaveBackground.astro';
<div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center"> <div class="flex flex-col md:flex-row gap-12 md:gap-24 items-center text-center">
<div class="flex flex-col items-center"> <div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Looking for inspiration?</h2>
<a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105"> <a href="/portfolio" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
My Portfolio My Portfolio
@@ -79,7 +79,7 @@ import WaveBackground from '../components/WaveBackground.astro';
</a> </a>
</div> </div>
<div class="flex flex-col items-center"> <div data-trail-box class="flex flex-col items-center px-6 py-4 rounded-2xl">
<h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2> <h2 class="text-3xl md:text-4xl text-white font-bold mb-6">Ready to collaborate?</h2>
<a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105"> <a href="/contact" class="inline-block px-8 py-3 bg-white text-black font-bold rounded-full hover:bg-gray-200 transition transform hover:scale-105">
Contact me Contact me
@@ -108,6 +108,7 @@ import WaveBackground from '../components/WaveBackground.astro';
} }
.smoky-path { .smoky-path {
animation: drawPath 6s cubic-bezier(0.4, 0, 0.2, 1) infinite, smokeShift 10s ease-in-out infinite alternate; animation: drawPath 6s cubic-bezier(0.4, 0, 0.2, 1) infinite, smokeShift 10s ease-in-out infinite alternate;
transition: stroke 0.8s ease, filter 0.8s ease;
} }
@keyframes smokeShift { @keyframes smokeShift {
0% { stroke: #374151; } 0% { stroke: #374151; }
@@ -116,6 +117,44 @@ import WaveBackground from '../components/WaveBackground.astro';
100% { stroke: #1f2937; } 100% { stroke: #1f2937; }
} }
/* Logo-pulse: när data-attribut sätts på text-wrapper, override:a stroke + drop-shadow */
#text-wrapper[data-logo-tint="pink"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="pink"] .smoky-path {
animation: none !important;
stroke: #e879f9 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(232,121,249,0.7));
}
#text-wrapper[data-logo-tint="green"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="green"] .smoky-path {
animation: none !important;
stroke: #4fd1c5 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(79,209,197,0.7));
}
#text-wrapper[data-logo-tint="pink"] h1 {
text-shadow: 0 0 25px rgba(232,121,249,0.85), 0 0 50px rgba(232,121,249,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper[data-logo-tint="green"] h1 {
text-shadow: 0 0 25px rgba(79,209,197,0.85), 0 0 50px rgba(79,209,197,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper[data-logo-tint="magenta"] ~ * .smoky-path,
#text-wrapper[data-logo-tint="magenta"] .smoky-path {
animation: none !important;
stroke: #c026d3 !important;
opacity: 0.95 !important;
filter: url(#fog-filter) drop-shadow(0 0 12px rgba(192,38,211,0.7));
}
#text-wrapper[data-logo-tint="magenta"] h1 {
text-shadow: 0 0 25px rgba(192,38,211,0.85), 0 0 50px rgba(192,38,211,0.55);
transition: text-shadow 0.6s ease;
}
#text-wrapper h1 {
transition: text-shadow 0.8s ease;
}
/* --- BRACKETS (Hörnen) --- */ /* --- BRACKETS (Hörnen) --- */
#text-wrapper::before, #text-wrapper::before,
#text-wrapper::after { #text-wrapper::after {
@@ -255,4 +294,18 @@ import WaveBackground from '../components/WaveBackground.astro';
}, { threshold: 0.1 }); }, { threshold: 0.1 });
if (footer) observer.observe(footer); if (footer) observer.observe(footer);
// FOOTER-LÄNKAR HOVER: ändra logo-glow när användare hovrar lyxlimo/karleksguiden-länkar
const glowLinks = document.querySelectorAll<HTMLElement>('[data-glow-link]');
glowLinks.forEach(link => {
link.addEventListener('mouseenter', () => {
if (!textWrapper) return;
const tint = link.getAttribute('data-glow-link');
if (tint) textWrapper.setAttribute('data-logo-tint', tint);
});
link.addEventListener('mouseleave', () => {
if (!textWrapper) return;
textWrapper.removeAttribute('data-logo-tint');
});
});
</script> </script>
+3
View File
@@ -1 +1,4 @@
@import "tailwindcss"; @import "tailwindcss";
@source "../**/*.{astro,html,js,jsx,ts,tsx,md,mdx,svelte,vue}";
@source "../../public/**/*.html";