diff --git a/index.html b/index.html
index d124463..1170285 100644
--- a/index.html
+++ b/index.html
@@ -28,6 +28,7 @@
+
diff --git a/priv/static/images/open-graph.png b/priv/static/images/open-graph.png
index 819e3ba..be18172 100644
Binary files a/priv/static/images/open-graph.png and b/priv/static/images/open-graph.png differ
diff --git a/sitemap.xml b/sitemap.xml
index 82a582c..bafced1 100644
--- a/sitemap.xml
+++ b/sitemap.xml
@@ -2,7 +2,7 @@
https://links.juleskab.lat/
- 2026-01-11
+ 2026-04-30
monthly
1.0
diff --git a/src/effects.mjs b/src/effects.mjs
new file mode 100644
index 0000000..352fd38
--- /dev/null
+++ b/src/effects.mjs
@@ -0,0 +1,35 @@
+export function setupCursorEffect(onLoaded, onMouseMoved) {
+ setTimeout(onLoaded, 120);
+
+ const el = () => document.getElementById("cursor");
+
+ window.addEventListener("mousemove", (e) => {
+ const c = el();
+ if (c) {
+ c.style.left = e.clientX + "px";
+ c.style.top = e.clientY + "px";
+ }
+ const normX = e.clientX / (window.innerWidth || 1);
+ const normY = e.clientY / (window.innerHeight || 1);
+ onMouseMoved(normX, normY);
+ });
+
+ document.addEventListener("mouseover", (e) => {
+ if (!e.target.closest("a")) return;
+ const c = el();
+ if (!c) return;
+ c.style.width = "36px";
+ c.style.height = "36px";
+ c.style.opacity = "0.35";
+ });
+
+ document.addEventListener("mouseout", (e) => {
+ const link = e.target.closest("a");
+ if (!link || link.contains(e.relatedTarget)) return;
+ const c = el();
+ if (!c) return;
+ c.style.width = "";
+ c.style.height = "";
+ c.style.opacity = "";
+ });
+}
diff --git a/src/linktri.gleam b/src/linktri.gleam
index 3766c0d..310b0c3 100644
--- a/src/linktri.gleam
+++ b/src/linktri.gleam
@@ -1,22 +1,16 @@
+import gleam/float
import gleam/int
import gleam/list
-import gleam/option.{type Option, None, Some}
-import gleam/string
import lustre
import lustre/attribute
+import lustre/effect.{type Effect}
import lustre/element.{type Element}
import lustre/element/html
-import styles.{general_styles}
+import lustre/element/svg
+import ui
+import vans_styles.{vans_styles}
-fn description() -> String {
- string.join(
- [
- "Software Engineer", "Tech Enthusiast", "Metalhead",
- "Wannabe Pixel Artist",
- ],
- with: " > ",
- )
-}
+// ── External JS ────────────────────────────────────────────────────────────
@external(javascript, "./date.mjs", "getCurrentYear")
fn get_current_year() -> Int
@@ -24,7 +18,13 @@ fn get_current_year() -> Int
@external(javascript, "./routing.mjs", "getPath")
fn get_path() -> String
-const full_name = "Julio Cabrera"
+@external(javascript, "./effects.mjs", "setupCursorEffect")
+fn do_setup_cursor_effect(
+ on_loaded: fn() -> Nil,
+ on_mouse_moved: fn(Float, Float) -> Nil,
+) -> Nil
+
+// ── Routing ─────────────────────────────────────────────────────────────────
pub type Route {
Home
@@ -38,227 +38,676 @@ fn get_route() -> Route {
}
}
-fn start_year() -> String {
- int.to_string(2024)
-}
-
-fn footer_content() -> String {
- "© "
- <> start_year()
- <> "-"
- <> int.to_string(get_current_year())
- <> " ~ "
- <> " Juleskab"
-}
-
-pub fn main() {
- let app = lustre.simple(init, update, view)
- let assert Ok(_) = lustre.start(app, "#app", Nil)
-}
-
-// MODEL
+// ── Types ───────────────────────────────────────────────────────────────────
pub type LinkType {
- GitHub
+ Email
LinkedIn
- Portfolio
Twitter
+ GitHub
+ Portfolio
Blog
- Email
}
pub type Link {
- Link(title: String, url: String, link_type: LinkType, display_name: String)
+ Link(label: String, sub: String, href: String, link_type: LinkType)
+}
+
+pub type LinkGroup {
+ LinkGroup(label: String, items: List(Link))
}
pub type Model {
Model(
- social_links: List(Link),
- work_links: List(Link),
- hover_link: Option(String),
- route: Route
+ link_groups: List(LinkGroup),
+ loaded: Bool,
+ mouse_x: Float,
+ mouse_y: Float,
+ route: Route,
)
}
-fn init(_flags) -> #(Model, Nil) {
- let social_links = [
- Link("Email", "mailto:hello@juleskab.lat", Email, "hello@juleskab.lat"),
- Link("LinkedIn", "https://www.linkedin.com/in/julio-cabrera-820", LinkedIn, "julio cabrera"),
- Link("X", "https://twitter.com/arielcabrera_11", Twitter, "arielcabrera_11"),
- ]
- let work_links = [
- Link("GitHub", "https://github.com/juliocabrera820", GitHub, "juliocabrera820"),
- Link("Portfolio", "https://juleskab.lat", Portfolio, "Portfolio"),
- Link("Blog", "https://juleskab.lat/blog", Blog, "Blog"),
+pub type Msg {
+ Loaded
+ MouseMoved(Float, Float)
+}
+
+// ── Init ────────────────────────────────────────────────────────────────────
+
+fn init(_flags) -> #(Model, Effect(Msg)) {
+ let link_groups = [
+ LinkGroup("Contact", [
+ Link(
+ "Personal Email",
+ "hello@juleskab.lat",
+ "mailto:hello@juleskab.lat",
+ Email,
+ ),
+ ]),
+ LinkGroup("Social Networks", [
+ Link(
+ "LinkedIn",
+ "linkedin.com/in/julio-cabrera-820",
+ "https://www.linkedin.com/in/julio-cabrera-820",
+ LinkedIn,
+ ),
+ Link(
+ "Twitter / X",
+ "@arielcabrera_11",
+ "https://twitter.com/arielcabrera_11",
+ Twitter,
+ ),
+ ]),
+ LinkGroup("Projects", [
+ Link(
+ "GitHub",
+ "github.com/juliocabrera820",
+ "https://github.com/juliocabrera820",
+ GitHub,
+ ),
+ Link("Portfolio", "juleskab.lat", "https://juleskab.lat", Portfolio),
+ Link("Blog", "juleskab.lat/blog", "https://blog.juleskab.lat", Blog),
+ ]),
]
- #(Model(social_links: social_links, work_links: work_links, hover_link: None, route: get_route()), Nil)
+ #(
+ Model(
+ link_groups: link_groups,
+ loaded: False,
+ mouse_x: 0.5,
+ mouse_y: 0.5,
+ route: get_route(),
+ ),
+ effect.from(fn(dispatch) {
+ do_setup_cursor_effect(
+ fn() { dispatch(Loaded) },
+ fn(x, y) { dispatch(MouseMoved(x, y)) },
+ )
+ }),
+ )
}
-// UPDATE
+// ── Update ──────────────────────────────────────────────────────────────────
-pub type Msg {
- MouseEnter(String)
- MouseLeave
- NavigateTo(Route)
+pub fn main() {
+ let app = lustre.application(init, update, view)
+ let assert Ok(_) = lustre.start(app, "#app", Nil)
}
-fn update(model: #(Model, Nil), msg: Msg) -> #(Model, Nil) {
+fn update(model: Model, msg: Msg) -> #(Model, Effect(Msg)) {
case msg {
- MouseEnter(url) -> #(Model(..model.0, hover_link: Some(url)), Nil)
- MouseLeave -> #(Model(..model.0, hover_link: None), Nil)
- NavigateTo(route) -> #(Model(..model.0, route: route), Nil)
+ Loaded -> #(Model(..model, loaded: True), effect.none())
+ MouseMoved(x, y) ->
+ #(Model(..model, mouse_x: x, mouse_y: y), effect.none())
}
}
-// ICONS - Using CSS classes with background images
+// ── Icon mapping ─────────────────────────────────────────────────────────────
-fn get_icon_class(link_type: LinkType) -> String {
+fn get_icon(link_type: LinkType) -> Element(Msg) {
case link_type {
- GitHub -> "link-icon link-icon-github"
- LinkedIn -> "link-icon link-icon-linkedin"
- Portfolio -> "link-icon link-icon-portfolio"
- Twitter -> "link-icon link-icon-twitter"
- Blog -> "link-icon link-icon-blog"
- Email -> "link-icon link-icon-email"
+ Email -> ui.icon_mail()
+ LinkedIn -> ui.icon_linkedin()
+ Twitter -> ui.icon_twitter()
+ GitHub -> ui.icon_github()
+ Portfolio -> ui.icon_portfolio()
+ Blog -> ui.icon_blog()
}
}
-// VIEW
-
-fn view(model_tuple: #(Model, Nil)) -> Element(Msg) {
- let model = model_tuple.0
-
- let font_styles = html.style([], general_styles())
+// ── View ─────────────────────────────────────────────────────────────────────
+fn view(model: Model) -> Element(Msg) {
let content = case model.route {
Home -> view_home(model)
NotFound -> view_404()
}
- html.div([attribute.class("root")], [
- font_styles,
- html.div([attribute.class("container")], [content]),
- ])
+ html.div([], [html.style([], vans_styles()), content])
}
+// ── 404 ──────────────────────────────────────────────────────────────────────
+
fn view_404() -> Element(Msg) {
html.div([attribute.class("error-page")], [
- // Skateboard icon animation
html.div([attribute.class("error-skateboard")], [
- html.span([attribute.class("skateboard-emoji")], [html.text("🛹")])
+ html.span([attribute.class("skateboard-emoji")], [html.text("🛹")]),
]),
- // Error code with glitch effect
html.h1([attribute.class("error-code")], [html.text("404")]),
- // Main message
html.p([attribute.class("error-title")], [
- html.text("You went off the wall!")
+ html.text("You went off the wall!"),
]),
- // Subtitle
html.p([attribute.class("error-subtitle")], [
- html.text("Looks like this page bailed on you...")
+ html.text("Looks like this page bailed on you..."),
]),
- // Fun skateboarding terms
html.div([attribute.class("error-tips")], [
html.p([attribute.class("error-tip")], [
- html.text("Maybe try a different trick? 🤙")
- ])
+ html.text("Maybe try a different trick? 🤙"),
+ ]),
]),
- // Back button with style
html.a([attribute.href("/"), attribute.class("error-button")], [
html.span([attribute.class("error-button-icon")], [html.text("←")]),
- html.span([attribute.class("error-button-text")], [html.text("Back to the ramp")])
- ])
+ html.span([attribute.class("error-button-text")], [
+ html.text("Back to the ramp"),
+ ]),
+ ]),
])
}
+// ── Home ─────────────────────────────────────────────────────────────────────
+
fn view_home(model: Model) -> Element(Msg) {
- // Profile card with avatar, name, bio and status
- let profile_card =
- html.div([attribute.class("profile-card")], [
- html.div([attribute.class("profile")], [
- // Avatar with decorative ring
- html.div([attribute.class("avatar-wrapper")], [
- html.div([attribute.class("avatar-ring")], []),
- html.img([
- attribute.src("/priv/static/images/avatar.webp"),
- attribute.alt(full_name),
- attribute.class("avatar"),
- ]),
+ let px = { model.mouse_x -. 0.5 } *. 24.0
+ let py = { model.mouse_y -. 0.5 } *. 18.0
+ let parallax_transform =
+ "translate("
+ <> float.to_string(px)
+ <> "px, "
+ <> float.to_string(py)
+ <> "px)"
+
+ let num_groups = list.length(model.link_groups)
+
+ html.div([], [
+ // Checkerboard corners
+ view_checker_tl(),
+ view_checker_br(),
+ // Rotated side label
+ html.div([attribute.class("side-label")], [
+ html.text("OFF THE WALL — SINCE 1966"),
+ ]),
+ // Scrolling bottom marquee
+ view_marquee(),
+ // Main page content
+ html.div([attribute.class("page")], [
+ view_parallax_bg(parallax_transform),
+ html.div(
+ [attribute.class("card")],
+ list.flatten([
+ [
+ view_avatar(model.loaded),
+ view_name_block(model.loaded),
+ html.div([attribute.class("divider")], []),
+ ],
+ list.flatten(
+ list.index_map(model.link_groups, fn(group, gi) {
+ view_link_group(group, gi, num_groups, model.loaded)
+ }),
+ ),
+ [view_footer_tag(model.loaded)],
]),
- html.h1([attribute.class("profile-name")], [html.text(full_name)]),
- html.p([attribute.class("profile-bio")], [html.text(description())]),
- // Status badge
- html.div([attribute.class("status-badge")], [
- html.span([attribute.class("status-dot")], []),
- html.span([], [html.text("Available for work")])
- ])
- ])
- ])
-
- // Section title for social links
- let social_section_title =
- html.div([attribute.class("section-title")], [
- html.span([attribute.class("section-title-line")], []),
- html.span([attribute.class("section-title-text")], [html.text("Connect with me")]),
- html.span([attribute.class("section-title-line")], [])
- ])
-
- let social_link_elements =
- list.map(model.social_links, render_link(model.hover_link, _))
-
- // Section title for work links
- let work_section_title =
- html.div([attribute.class("section-title section-title-work")], [
- html.span([attribute.class("section-title-line")], []),
- html.span([attribute.class("section-title-text")], [html.text("My work")]),
- html.span([attribute.class("section-title-line")], [])
- ])
-
- let work_link_elements =
- list.map(model.work_links, render_link(model.hover_link, _))
-
- let footer = html.footer([attribute.class("footer")], [
- html.div([attribute.class("footer-text")], [
- html.text(footer_content()),
- html.span([attribute.class("footer-heart")], [html.text(" ♥")])
- ])
+ ),
+ ]),
])
+}
- html.div([], [
- profile_card,
- social_section_title,
- html.div([attribute.class("main-content")], social_link_elements),
- work_section_title,
- html.div([attribute.class("main-content")], work_link_elements),
- footer
+fn view_avatar(loaded: Bool) -> Element(Msg) {
+ let wrap_class = case loaded {
+ True -> "avatar-wrap visible"
+ False -> "avatar-wrap"
+ }
+ html.div([attribute.class(wrap_class)], [
+ html.div([attribute.class("avatar-ring")], [
+ html.img([attribute.src("/priv/static/images/avatar.webp"), attribute.alt("Jules Kab"), attribute.class("avatar-placeholder")])
+ ]),
+ html.div([attribute.class("avatar-badge")], [
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 12 12"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.path([
+ attribute.attribute("d", "M2 6h8M6 2l4 4-4 4"),
+ attribute.attribute("stroke", "#FAFAF7"),
+ attribute.attribute("stroke-width", "1.5"),
+ attribute.attribute("stroke-linecap", "round"),
+ attribute.attribute("stroke-linejoin", "round"),
+ ]),
+ ],
+ ),
+ ]),
+ ])
+}
+
+fn view_name_block(loaded: Bool) -> Element(Msg) {
+ let block_class = case loaded {
+ True -> "name-block visible"
+ False -> "name-block"
+ }
+ html.div([attribute.class(block_class)], [
+ html.div([attribute.class("name")], [
+ html.text("JULES"),
+ html.span([attribute.class("name-accent")], [html.text("KAB")]),
+ ]),
+ html.div([attribute.class("otw-tag")], [
+ html.text("\"OFF THE WALL, ON THE KEYBOARD\""),
+ ]),
+ html.div([attribute.class("tagline")], [
+ html.text("> Software Engineer · Tech Enthusiast"),
+ html.br([]),
+ html.text("Metalhead · Wannabe Pixel Artist"),
+ ]),
])
}
-// --- RENDER HELPERS ---
+fn view_footer_tag(loaded: Bool) -> Element(Msg) {
+ let footer_class = case loaded {
+ True -> "footer-tag visible"
+ False -> "footer-tag"
+ }
+ html.div([attribute.class(footer_class)], [
+ html.text(
+ "JULESKAB · ON THE KEYBOARD · ©2024-"
+ <> int.to_string(get_current_year()),
+ ),
+ ])
+}
-fn render_link(hover_link: Option(String), link: Link) -> Element(Msg) {
- let is_hover = case hover_link {
- Some(url) if url == link.url -> True
- _ -> False
+fn view_link_group(
+ group: LinkGroup,
+ group_idx: Int,
+ total_groups: Int,
+ loaded: Bool,
+) -> List(Element(Msg)) {
+ let label_class = case loaded {
+ True -> "section-label visible"
+ False -> "section-label"
}
+ let label_delay =
+ float.to_string(0.4 +. int.to_float(group_idx) *. 0.12) <> "s"
+
+ let items =
+ list.index_map(group.items, fn(link, item_idx) {
+ let delay =
+ 0.5
+ +. int.to_float(group_idx)
+ *. 0.12
+ +. int.to_float(item_idx)
+ *. 0.09
+ ui.link_item(
+ link.label,
+ link.sub,
+ link.href,
+ get_icon(link.link_type),
+ loaded,
+ float.to_string(delay) <> "s",
+ )
+ })
+
+ let divider = case group_idx < total_groups - 1 {
+ True ->
+ html.div(
+ [
+ attribute.class("divider"),
+ attribute.style([#("margin-bottom", "12px")]),
+ ],
+ [],
+ )
+ False -> element.none()
+ }
+
+ [
+ html.div(
+ [
+ attribute.class(label_class),
+ attribute.style([#("transition-delay", label_delay)]),
+ ],
+ [html.text("— " <> group.label)],
+ ),
+ html.div([attribute.class("links-group")], items),
+ divider,
+ ]
+}
+
+// ── Parallax background decoratives ──────────────────────────────────────────
+
+fn view_parallax_bg(transform: String) -> Element(Msg) {
+ html.div(
+ [
+ attribute.class("parallax-bg"),
+ attribute.style([
+ #("transform", transform),
+ #("transition", "transform 0.6s cubic-bezier(0.23, 1, 0.32, 1)"),
+ ]),
+ ],
+ [
+ // Soft blobs
+ html.div(
+ [
+ attribute.class("parallax-circle"),
+ attribute.style([
+ #("width", "320px"),
+ #("height", "320px"),
+ #("top", "10%"),
+ #("right", "8%"),
+ ]),
+ ],
+ [],
+ ),
+ html.div(
+ [
+ attribute.class("parallax-circle"),
+ attribute.style([
+ #("width", "180px"),
+ #("height", "180px"),
+ #("bottom", "18%"),
+ #("left", "6%"),
+ #("opacity", "0.4"),
+ ]),
+ ],
+ [],
+ ),
+ html.div(
+ [
+ attribute.class("parallax-circle"),
+ attribute.style([
+ #("width", "80px"),
+ #("height", "80px"),
+ #("top", "38%"),
+ #("left", "16%"),
+ #("opacity", "0.3"),
+ ]),
+ ],
+ [],
+ ),
+ // Ghost text
+ html.div(
+ [
+ attribute.class("bg-word"),
+ attribute.style([
+ #("font-size", "clamp(80px,14vw,160px)"),
+ #("top", "5%"),
+ #("right", "-2%"),
+ ]),
+ ],
+ [html.text("OFF")],
+ ),
+ html.div(
+ [
+ attribute.class("bg-word"),
+ attribute.style([
+ #("font-size", "clamp(80px,14vw,160px)"),
+ #("bottom", "28%"),
+ #("right", "-3%"),
+ ]),
+ ],
+ [html.text("WALL")],
+ ),
+ html.div(
+ [
+ attribute.class("bg-word"),
+ attribute.style([
+ #("font-size", "clamp(40px,6vw,80px)"),
+ #("bottom", "10%"),
+ #("left", "2%"),
+ #("letter-spacing", "0.18em"),
+ ]),
+ ],
+ [html.text("SINCE 1966")],
+ ),
+ // Checkerboard strips
+ html.div(
+ [
+ attribute.class("bg-checker-strip"),
+ attribute.style([
+ #("width", "45%"),
+ #("height", "14px"),
+ #("top", "48%"),
+ #("right", "0"),
+ #("opacity", "0.9"),
+ ]),
+ ],
+ [],
+ ),
+ html.div(
+ [
+ attribute.class("bg-checker-strip"),
+ attribute.style([
+ #("width", "30%"),
+ #("height", "14px"),
+ #("top", "65%"),
+ #("left", "0"),
+ #("opacity", "0.9"),
+ ]),
+ ],
+ [],
+ ),
+ // White geo bars
+ html.div(
+ [
+ attribute.class("geo-bar"),
+ attribute.style([
+ #("width", "120px"),
+ #("height", "9px"),
+ #("top", "22%"),
+ #("left", "10%"),
+ #("opacity", "0.5"),
+ ]),
+ ],
+ [],
+ ),
+ html.div(
+ [
+ attribute.class("geo-bar"),
+ attribute.style([
+ #("width", "60px"),
+ #("height", "9px"),
+ #("top", "28%"),
+ #("right", "18%"),
+ #("opacity", "0.4"),
+ ]),
+ ],
+ [],
+ ),
+ html.div(
+ [
+ attribute.class("geo-bar"),
+ attribute.style([
+ #("width", "180px"),
+ #("height", "9px"),
+ #("bottom", "22%"),
+ #("right", "10%"),
+ #("opacity", "0.35"),
+ ]),
+ ],
+ [],
+ ),
+ // Skate deck silhouettes
+ view_skate_deck("44px", "96px", "14%", "7%", "0.07", "rotate(30deg)"),
+ view_skate_deck("32px", "70px", "6%", "7%", "0.06", "rotate(-18deg)"),
+ // Plus / cross marks
+ view_plus_mark("36px", "62%", "18%", "0.1"),
+ view_plus_mark("22px", "18%", "5%", "0.07"),
+ ],
+ )
+}
- let link_class = case is_hover {
- True -> "link link-hover"
- False -> "link"
+fn view_skate_deck(
+ w: String,
+ h: String,
+ vert: String,
+ horiz: String,
+ opacity: String,
+ transform: String,
+) -> Element(Msg) {
+ let position_key = case w == "44px" {
+ True -> "bottom"
+ False -> "top"
+ }
+ let horiz_key = case w == "44px" {
+ True -> "right"
+ False -> "left"
}
+ svg.svg(
+ [
+ attribute.class("bg-deco"),
+ attribute.style([
+ #("width", w),
+ #("height", h),
+ #(position_key, vert),
+ #(horiz_key, horiz),
+ #("opacity", opacity),
+ #("transform", transform),
+ ]),
+ attribute.attribute("viewBox", "0 0 44 96"),
+ ],
+ [
+ svg.rect([
+ attribute.attribute("x", "4"),
+ attribute.attribute("y", "16"),
+ attribute.attribute("width", "36"),
+ attribute.attribute("height", "64"),
+ attribute.attribute("rx", "18"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2.5"),
+ ]),
+ svg.circle([
+ attribute.attribute("cx", "11"),
+ attribute.attribute("cy", "8"),
+ attribute.attribute("r", "5.5"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2"),
+ ]),
+ svg.circle([
+ attribute.attribute("cx", "33"),
+ attribute.attribute("cy", "8"),
+ attribute.attribute("r", "5.5"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2"),
+ ]),
+ svg.circle([
+ attribute.attribute("cx", "11"),
+ attribute.attribute("cy", "88"),
+ attribute.attribute("r", "5.5"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2"),
+ ]),
+ svg.circle([
+ attribute.attribute("cx", "33"),
+ attribute.attribute("cy", "88"),
+ attribute.attribute("r", "5.5"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2"),
+ ]),
+ ],
+ )
+}
- html.a(
+fn view_plus_mark(
+ size: String,
+ vert: String,
+ horiz: String,
+ opacity: String,
+) -> Element(Msg) {
+ svg.svg(
+ [
+ attribute.class("bg-deco"),
+ attribute.style([
+ #("width", size),
+ #("height", size),
+ #("top", vert),
+ #("right", horiz),
+ #("opacity", opacity),
+ ]),
+ attribute.attribute("viewBox", "0 0 36 36"),
+ ],
[
- attribute.href(link.url),
- attribute.target("_blank"),
- attribute.rel("noopener noreferrer"),
- attribute.class(link_class),
- attribute.on("mouseenter", fn(_) { Ok(MouseEnter(link.url)) }),
- attribute.on("mouseleave", fn(_) { Ok(MouseLeave) }),
+ svg.line([
+ attribute.attribute("x1", "18"),
+ attribute.attribute("y1", "3"),
+ attribute.attribute("x2", "18"),
+ attribute.attribute("y2", "33"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "4"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ svg.line([
+ attribute.attribute("x1", "3"),
+ attribute.attribute("y1", "18"),
+ attribute.attribute("x2", "33"),
+ attribute.attribute("y2", "18"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "4"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
],
+ )
+}
+
+// ── Checker corners ───────────────────────────────────────────────────────────
+
+fn checker_cell(x: Int, y: Int, filled: Bool) -> Element(msg) {
+ svg.rect([
+ attribute.attribute("x", int.to_string(x)),
+ attribute.attribute("y", int.to_string(y)),
+ attribute.attribute("width", "12"),
+ attribute.attribute("height", "12"),
+ attribute.attribute("fill", case filled {
+ True -> "#111"
+ False -> "transparent"
+ }),
+ attribute.attribute("opacity", case filled {
+ True -> "0.12"
+ False -> "0"
+ }),
+ ])
+}
+
+fn checker_cells(invert: Bool) -> List(Element(msg)) {
+ list.flat_map([0, 1, 2], fn(row) {
+ list.map([0, 1, 2], fn(col) {
+ let filled = case invert {
+ False -> { row + col } % 2 == 0
+ True -> { row + col } % 2 == 1
+ }
+ checker_cell(col * 12, row * 12, filled)
+ })
+ })
+}
+
+fn checker_svg(invert: Bool) -> Element(msg) {
+ svg.svg(
[
- html.span([attribute.class(get_icon_class(link.link_type))], []),
- html.span([attribute.class("link-text")], [html.text(link.display_name)]),
+ attribute.attribute("width", "72"),
+ attribute.attribute("height", "72"),
+ attribute.attribute("viewBox", "0 0 72 72"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ attribute.class("checker-pattern"),
],
+ checker_cells(invert),
)
-}
\ No newline at end of file
+}
+
+fn view_checker_tl() -> Element(Msg) {
+ html.div([attribute.class("checker-tl")], [checker_svg(False)])
+}
+
+fn view_checker_br() -> Element(Msg) {
+ html.div([attribute.class("checker-br")], [checker_svg(True)])
+}
+
+// ── Marquee ───────────────────────────────────────────────────────────────────
+
+fn marquee_item(text: String) -> List(Element(Msg)) {
+ [
+ html.span([], [html.text(text)]),
+ html.span([attribute.class("dot")], [html.text("✕")]),
+ ]
+}
+
+fn view_marquee() -> Element(Msg) {
+ let words = ["OFF THE WALL", "ON THE KEYBOARD", "JULESKAB", "SOFTWARE ENGINEER", "METALHEAD", "WANNABE PIXEL ARTIST"]
+ let items = list.flatten(list.map(words, marquee_item))
+ html.div([attribute.class("marquee-wrap")], [
+ html.div(
+ [attribute.class("marquee-track")],
+ list.flatten([items, items]),
+ ),
+ ])
+}
diff --git a/src/ui.gleam b/src/ui.gleam
new file mode 100644
index 0000000..2fa7702
--- /dev/null
+++ b/src/ui.gleam
@@ -0,0 +1,259 @@
+import lustre/attribute
+import lustre/element.{type Element}
+import lustre/element/html
+import lustre/element/svg
+
+// ── SVG Icons ──────────────────────────────────────────────────────────────
+// Each icon matches the original vans.html icon components.
+// All icons are generic Element(msg) — no Lustre Msg dependency.
+
+pub fn icon_mail() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.rect([
+ attribute.attribute("x", "2"),
+ attribute.attribute("y", "5"),
+ attribute.attribute("width", "20"),
+ attribute.attribute("height", "14"),
+ attribute.attribute("rx", "2"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ attribute.attribute("fill", "none"),
+ ]),
+ svg.path([
+ attribute.attribute("d", "M2 8l10 7 10-7"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_linkedin() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.path([
+ attribute.attribute(
+ "d",
+ "M16 8a6 6 0 0 1 6 6v7h-4v-7a2 2 0 0 0-2-2 2 2 0 0 0-2 2v7h-4v-7a6 6 0 0 1 6-6z",
+ ),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.rect([
+ attribute.attribute("x", "2"),
+ attribute.attribute("y", "9"),
+ attribute.attribute("width", "4"),
+ attribute.attribute("height", "12"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.circle([
+ attribute.attribute("cx", "4"),
+ attribute.attribute("cy", "4"),
+ attribute.attribute("r", "2"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_twitter() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.path([
+ attribute.attribute("d", "M4 4h5l11 16h-5z"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.path([
+ attribute.attribute("d", "M4 20L20 4"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "2"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_github() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.path([
+ attribute.attribute(
+ "d",
+ "M12 2C6.477 2 2 6.484 2 12.017c0 4.425 2.865 8.18 6.839 9.504.5.092.682-.217.682-.483 0-.237-.008-.868-.013-1.703-2.782.605-3.369-1.343-3.369-1.343-.454-1.158-1.11-1.466-1.11-1.466-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844c.85.004 1.705.115 2.504.337 1.909-1.296 2.747-1.027 2.747-1.027.546 1.379.202 2.398.1 2.651.64.7 1.028 1.595 1.028 2.688 0 3.848-2.339 4.695-4.566 4.943.359.309.678.92.678 1.855 0 1.338-.012 2.419-.012 2.747 0 .268.18.58.688.482A10.019 10.019 0 0 0 22 12.017C22 6.484 17.522 2 12 2z",
+ ),
+ attribute.attribute("fill", "#111"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_portfolio() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.rect([
+ attribute.attribute("x", "3"),
+ attribute.attribute("y", "3"),
+ attribute.attribute("width", "7"),
+ attribute.attribute("height", "7"),
+ attribute.attribute("rx", "1"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.rect([
+ attribute.attribute("x", "14"),
+ attribute.attribute("y", "3"),
+ attribute.attribute("width", "7"),
+ attribute.attribute("height", "7"),
+ attribute.attribute("rx", "1"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.rect([
+ attribute.attribute("x", "3"),
+ attribute.attribute("y", "14"),
+ attribute.attribute("width", "7"),
+ attribute.attribute("height", "7"),
+ attribute.attribute("rx", "1"),
+ attribute.attribute("fill", "#111"),
+ ]),
+ svg.rect([
+ attribute.attribute("x", "14"),
+ attribute.attribute("y", "14"),
+ attribute.attribute("width", "7"),
+ attribute.attribute("height", "7"),
+ attribute.attribute("rx", "1"),
+ attribute.attribute("fill", "#111"),
+ attribute.attribute("opacity", "0.4"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_blog() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.rect([
+ attribute.attribute("x", "4"),
+ attribute.attribute("y", "3"),
+ attribute.attribute("width", "16"),
+ attribute.attribute("height", "18"),
+ attribute.attribute("rx", "2"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ ]),
+ svg.line([
+ attribute.attribute("x1", "8"),
+ attribute.attribute("y1", "8"),
+ attribute.attribute("x2", "16"),
+ attribute.attribute("y2", "8"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ svg.line([
+ attribute.attribute("x1", "8"),
+ attribute.attribute("y1", "12"),
+ attribute.attribute("x2", "16"),
+ attribute.attribute("y2", "12"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ svg.line([
+ attribute.attribute("x1", "8"),
+ attribute.attribute("y1", "16"),
+ attribute.attribute("x2", "12"),
+ attribute.attribute("y2", "16"),
+ attribute.attribute("stroke", "#111"),
+ attribute.attribute("stroke-width", "1.8"),
+ attribute.attribute("stroke-linecap", "round"),
+ ]),
+ ],
+ )
+}
+
+pub fn icon_arrow() -> Element(msg) {
+ svg.svg(
+ [
+ attribute.attribute("viewBox", "0 0 24 24"),
+ attribute.attribute("fill", "none"),
+ attribute.attribute("xmlns", "http://www.w3.org/2000/svg"),
+ ],
+ [
+ svg.path([
+ attribute.attribute("d", "M5 12h14M13 6l6 6-6 6"),
+ attribute.attribute("stroke", "currentColor"),
+ attribute.attribute("stroke-width", "2"),
+ attribute.attribute("stroke-linecap", "round"),
+ attribute.attribute("stroke-linejoin", "round"),
+ ]),
+ ],
+ )
+}
+
+// ── Link Item ──────────────────────────────────────────────────────────────
+// Renders a single link row. Hover effects are handled entirely by CSS.
+// `delay` is a CSS time string like "0.5s" for staggered entrance animations.
+
+pub fn link_item(
+ label: String,
+ sub: String,
+ href: String,
+ icon: Element(msg),
+ loaded: Bool,
+ delay: String,
+) -> Element(msg) {
+ let item_class = case loaded {
+ True -> "link-item visible"
+ False -> "link-item"
+ }
+ html.a(
+ [
+ attribute.href(href),
+ attribute.target("_blank"),
+ attribute.rel("noopener noreferrer"),
+ attribute.class(item_class),
+ attribute.style([#("transition-delay", delay)]),
+ ],
+ [
+ html.div([attribute.class("link-icon")], [icon]),
+ html.div(
+ [attribute.class("link-info")],
+ [
+ html.span([attribute.class("link-label")], [html.text(label)]),
+ html.span([attribute.class("link-sub")], [html.text(sub)]),
+ ],
+ ),
+ html.div([attribute.class("link-arrow")], [icon_arrow()]),
+ ],
+ )
+}
diff --git a/src/vans_styles.gleam b/src/vans_styles.gleam
new file mode 100644
index 0000000..f86fd2a
--- /dev/null
+++ b/src/vans_styles.gleam
@@ -0,0 +1,456 @@
+pub fn vans_styles() -> String {
+ "@import url('https://fonts.googleapis.com/css2?family=Anton&family=Space+Grotesk:wght@300;400;500;600&display=swap');
+
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+ :root {
+ --bg: #EDE8DF;
+ --bg2: #E4DDD3;
+ --red: #E31837;
+ --black: #111111;
+ --white: #FAFAF7;
+ --muted: #7A7066;
+ --font-display: 'Anton', sans-serif;
+ --font-body: 'Space Grotesk', sans-serif;
+ --spring: cubic-bezier(0.34, 1.56, 0.64, 1);
+ }
+
+ html, body {
+ width: 100%; height: 100%;
+ background: var(--bg);
+ font-family: var(--font-body);
+ color: var(--black);
+ overflow-x: hidden;
+ cursor: none;
+ }
+
+ a, button, [role='button'] { cursor: none; }
+
+ #cursor {
+ position: fixed;
+ width: 10px;
+ height: 10px;
+ background: var(--red);
+ border-radius: 50%;
+ pointer-events: none;
+ z-index: 9999;
+ top: -100px;
+ left: -100px;
+ transform: translate(-50%, -50%);
+ transition: width 0.2s ease, height 0.2s ease, opacity 0.2s ease;
+ }
+
+ .checker-tl, .checker-br {
+ position: fixed;
+ width: 72px; height: 72px;
+ pointer-events: none;
+ z-index: 10;
+ }
+ .checker-tl { top: 0; left: 0; }
+ .checker-br { bottom: 0; right: 0; }
+
+ .geo-bar {
+ position: absolute;
+ background: var(--white);
+ opacity: 0.75;
+ }
+
+ .marquee-wrap {
+ position: fixed;
+ bottom: 0; left: 0; right: 0;
+ height: 36px;
+ background: var(--red);
+ overflow: hidden;
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ }
+ .marquee-track {
+ display: flex;
+ white-space: nowrap;
+ animation: marquee 22s linear infinite;
+ gap: 0;
+ }
+ .marquee-track span {
+ font-family: var(--font-display);
+ font-size: 13px;
+ letter-spacing: 0.12em;
+ color: var(--white);
+ padding: 0 32px;
+ }
+ .marquee-track .dot {
+ color: var(--white);
+ opacity: 0.5;
+ padding: 0;
+ }
+ @keyframes marquee {
+ from { transform: translateX(0); }
+ to { transform: translateX(-50%); }
+ }
+
+ .side-label {
+ position: fixed;
+ left: 24px;
+ top: 50%;
+ transform: translateY(-50%) rotate(-90deg);
+ transform-origin: center center;
+ font-family: var(--font-display);
+ font-size: 11px;
+ letter-spacing: 0.25em;
+ color: var(--muted);
+ white-space: nowrap;
+ pointer-events: none;
+ z-index: 10;
+ }
+ @media (max-width: 768px) {
+ .side-label { display: none; }
+ }
+
+ .page {
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ padding: 60px 24px 72px;
+ position: relative;
+ overflow: hidden;
+ }
+
+ .parallax-bg {
+ position: absolute;
+ inset: -60px;
+ pointer-events: none;
+ z-index: 0;
+ }
+ .parallax-circle {
+ position: absolute;
+ border-radius: 50%;
+ background: var(--bg2);
+ opacity: 0.6;
+ }
+
+ .bg-word {
+ position: absolute;
+ font-family: var(--font-display);
+ letter-spacing: -0.01em;
+ color: transparent;
+ -webkit-text-stroke: 1px rgba(17,17,17,0.055);
+ white-space: nowrap;
+ pointer-events: none;
+ user-select: none;
+ line-height: 1;
+ }
+ .bg-checker-strip {
+ position: absolute;
+ background-image: repeating-conic-gradient(rgba(17,17,17,0.06) 0% 25%, transparent 0% 50%);
+ background-size: 16px 16px;
+ pointer-events: none;
+ }
+ .bg-deco {
+ position: absolute;
+ pointer-events: none;
+ }
+
+ .card {
+ position: relative;
+ z-index: 2;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 0;
+ width: 100%;
+ max-width: 520px;
+ }
+
+ .avatar-wrap {
+ position: relative;
+ margin-bottom: 28px;
+ opacity: 0;
+ transform: translateY(24px);
+ transition: opacity 0.7s ease 0.1s, transform 0.7s ease 0.1s;
+ }
+ .avatar-wrap.visible {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ .avatar-ring {
+ width: 96px; height: 96px;
+ border-radius: 50%;
+ border: 3px solid var(--red);
+ display: flex; align-items: center; justify-content: center;
+ background: var(--bg2);
+ position: relative;
+ overflow: hidden;
+ }
+ .avatar-placeholder {
+ width: 100%; height: 100%;
+ display: flex; align-items: center; justify-content: center;
+ background: repeating-linear-gradient(
+ 45deg,
+ var(--bg2) 0px, var(--bg2) 6px,
+ var(--white) 6px, var(--white) 12px
+ );
+ font-family: var(--font-display);
+ font-size: 28px;
+ color: var(--muted);
+ letter-spacing: 0.01em;
+ }
+ .avatar-badge {
+ position: absolute;
+ bottom: -4px; right: -4px;
+ width: 28px; height: 28px;
+ background: var(--red);
+ border-radius: 50%;
+ border: 2px solid var(--bg);
+ display: flex; align-items: center; justify-content: center;
+ }
+ .avatar-badge svg { width: 12px; height: 12px; }
+
+ .name-block {
+ text-align: center;
+ margin-bottom: 8px;
+ opacity: 0;
+ transform: translateY(20px);
+ transition: opacity 0.7s ease 0.25s, transform 0.7s ease 0.25s;
+ }
+ .name-block.visible { opacity: 1; transform: translateY(0); }
+ .name {
+ font-family: var(--font-display);
+ font-size: clamp(38px, 7vw, 64px);
+ line-height: 1;
+ letter-spacing: 0.02em;
+ color: var(--black);
+ }
+ .name-accent { color: var(--red); }
+
+ .otw-tag {
+ display: inline-block;
+ margin-top: 6px;
+ font-family: var(--font-display);
+ font-size: 11px;
+ letter-spacing: 0.22em;
+ color: var(--white);
+ background: var(--red);
+ padding: 3px 10px;
+ text-transform: uppercase;
+ }
+
+ .tagline {
+ margin-top: 10px;
+ font-size: 12px;
+ color: var(--muted);
+ letter-spacing: 0.08em;
+ line-height: 1.7;
+ text-align: center;
+ max-width: 360px;
+ }
+ .tagline code {
+ font-family: 'Space Grotesk', monospace;
+ color: var(--red);
+ font-size: 11px;
+ }
+
+ .divider {
+ width: 100%;
+ height: 1px;
+ background: linear-gradient(90deg, transparent, var(--black) 20%, var(--black) 80%, transparent);
+ opacity: 0.12;
+ margin: 24px 0 20px;
+ }
+
+ .section-label {
+ font-family: var(--font-display);
+ font-size: 14px;
+ letter-spacing: 0.1em;
+ color: var(--muted);
+ text-transform: uppercase;
+ align-self: flex-start;
+ margin-bottom: 8px;
+ opacity: 0;
+ transform: translateX(-12px);
+ transition: opacity 0.5s ease, transform 0.5s ease;
+ }
+ .section-label.visible { opacity: 1; transform: translateX(0); }
+
+ .links-group {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ margin-bottom: 8px;
+ }
+
+ .link-item {
+ position: relative;
+ display: flex;
+ align-items: center;
+ gap: 14px;
+ padding: 13px 12px;
+ border-radius: 8px;
+ border-bottom: 1px solid rgba(17,17,17,0.07);
+ text-decoration: none;
+ color: var(--black);
+ overflow: hidden;
+ cursor: none;
+ opacity: 0;
+ transform: translateX(-20px);
+ transition: opacity 0.5s ease, transform 0.5s ease,
+ background 0.25s ease, border-color 0.25s ease;
+ }
+ .link-item.visible {
+ opacity: 1;
+ transform: translateX(0);
+ }
+ .link-item::before {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: rgba(17,17,17,0.04);
+ border-radius: 8px;
+ opacity: 0;
+ transition: opacity 0.25s ease;
+ z-index: 0;
+ }
+ .link-item::after {
+ content: '';
+ position: absolute;
+ left: 0; top: 10px; bottom: 10px;
+ width: 3px;
+ background: var(--red);
+ border-radius: 0 2px 2px 0;
+ transform: scaleY(0);
+ transform-origin: center;
+ transition: transform 0.35s var(--spring);
+ z-index: 2;
+ }
+ .link-item:hover::before { opacity: 1; }
+ .link-item:hover::after { transform: scaleY(1); }
+ .link-item:hover .link-label { color: var(--red); }
+ .link-item:hover .link-arrow { opacity: 1; transform: translateX(0); }
+ .link-item:hover .link-icon {
+ background: var(--red);
+ border-color: var(--red);
+ transform: scale(1.1);
+ }
+ .link-item:hover .link-info { transform: translateX(5px); }
+ .link-item:hover .link-icon svg path,
+ .link-item:hover .link-icon svg rect,
+ .link-item:hover .link-icon svg circle,
+ .link-item:hover .link-icon svg line { stroke: var(--white); }
+ .link-item:hover .link-icon svg path[fill='#111'],
+ .link-item:hover .link-icon svg rect[fill='#111'],
+ .link-item:hover .link-icon svg circle[fill='#111'] { fill: var(--white); }
+
+ .link-icon {
+ position: relative;
+ z-index: 1;
+ width: 36px; height: 36px;
+ border-radius: 8px;
+ background: var(--bg2);
+ border: 1px solid rgba(17,17,17,0.08);
+ display: flex; align-items: center; justify-content: center;
+ flex-shrink: 0;
+ transition: background 0.2s ease, border-color 0.2s ease,
+ transform 0.4s var(--spring);
+ }
+ .link-icon svg { width: 16px; height: 16px; }
+
+ .link-info {
+ position: relative;
+ z-index: 1;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ transition: transform 0.35s var(--spring);
+ }
+
+ .link-label {
+ font-size: 14px;
+ font-weight: 500;
+ letter-spacing: 0.01em;
+ transition: color 0.2s ease;
+ }
+ .link-sub {
+ font-size: 11px;
+ color: var(--muted);
+ letter-spacing: 0.04em;
+ }
+
+ .link-arrow {
+ position: relative;
+ z-index: 1;
+ opacity: 0;
+ transform: translateX(-6px);
+ transition: opacity 0.25s ease, transform 0.35s var(--spring);
+ color: var(--red);
+ }
+ .link-arrow svg { width: 14px; height: 14px; }
+
+ .footer-tag {
+ margin-top: 28px;
+ font-family: var(--font-display);
+ font-size: 10px;
+ letter-spacing: 0.25em;
+ color: var(--muted);
+ opacity: 0;
+ transition: opacity 0.7s ease 1.2s;
+ }
+ .footer-tag.visible { opacity: 1; }
+
+ .checker-pattern { image-rendering: pixelated; }
+
+ .error-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 40px 24px 72px;
+ text-align: center;
+ }
+ .error-skateboard { font-size: 48px; margin-bottom: 16px; animation: skate 2s ease-in-out infinite; }
+ @keyframes skate {
+ 0%, 100% { transform: rotate(-8deg) translateX(-8px); }
+ 50% { transform: rotate(8deg) translateX(8px); }
+ }
+ .error-code {
+ font-family: var(--font-display);
+ font-size: clamp(80px, 20vw, 160px);
+ line-height: 1;
+ color: var(--black);
+ letter-spacing: 0.02em;
+ }
+ .error-title {
+ font-family: var(--font-display);
+ font-size: clamp(16px, 4vw, 24px);
+ letter-spacing: 0.08em;
+ color: var(--black);
+ margin-top: 12px;
+ }
+ .error-subtitle {
+ font-size: 13px;
+ color: var(--muted);
+ margin-top: 8px;
+ letter-spacing: 0.04em;
+ }
+ .error-tips { margin-top: 16px; }
+ .error-tip { font-size: 13px; color: var(--muted); }
+ .error-button {
+ margin-top: 32px;
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 12px 24px;
+ background: var(--black);
+ color: var(--white);
+ text-decoration: none;
+ font-family: var(--font-display);
+ font-size: 13px;
+ letter-spacing: 0.12em;
+ transition: background 0.2s ease;
+ }
+ .error-button:hover { background: var(--red); }
+ "
+}