Compare commits
10 Commits
e1b3bc7d43
...
89d7e8d8ca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89d7e8d8ca | ||
|
|
bb0f0ec13a | ||
|
|
d357eba65f | ||
|
|
f9cb18bd74 | ||
|
|
aabd08371c | ||
|
|
4ac4a9c3bb | ||
|
|
ff0abee349 | ||
|
|
c16ee37096 | ||
|
|
4868111a14 | ||
|
|
b257d92636 |
26
README.md
26
README.md
@@ -95,10 +95,18 @@ An ordered array of groups. Each section renders with a numbered masthead
|
||||
"label": "Sites", // required, the section title
|
||||
"kicker": "Where I live online", // optional italic tagline
|
||||
"layout": "clients", // optional — see Layouts below
|
||||
"headless": true, // optional — render body only, no head
|
||||
"items": [ … ] // required, array of items
|
||||
}
|
||||
```
|
||||
|
||||
Set `headless: true` to render a section's body without the
|
||||
`№ NN / Label / kicker` header. Tighter top margin too, so the
|
||||
contents visually nest under the previous section. Useful for sliding
|
||||
a testimonials carousel underneath a Clients wall, for example —
|
||||
put the testimonials section directly after the clients section in
|
||||
the `sections` array with `headless: true` and they'll read as one.
|
||||
|
||||
#### Layouts
|
||||
|
||||
| `layout` | When to use | Grid behavior |
|
||||
@@ -207,9 +215,10 @@ only loads when clicked, so the page stays fast even with many videos.
|
||||
```
|
||||
|
||||
| field | required? | notes |
|
||||
|---------|-----------|-------|
|
||||
|------------|-----------|-------|
|
||||
| `id` | yes | YouTube video ID (the `v=` parameter from the URL). |
|
||||
| `title` | yes | Shown over the thumbnail and as the iframe's accessible title. |
|
||||
| `featured` | no | When `true`, the video card spans the full row at 78% max-width centered (like a portfolio plate). Default layout is 2-per-row on tablet+. Use sparingly to promote a hero video. |
|
||||
|
||||
#### `type: "client"` — logo tile
|
||||
|
||||
@@ -431,6 +440,21 @@ To switch:
|
||||
Choice is also cached in `localStorage` (`dlstack-template`) so reloads
|
||||
don't flash the wrong template before JSON parses.
|
||||
|
||||
### Overriding the template at view time
|
||||
|
||||
Two ways to ride a different template without touching `links.json`:
|
||||
|
||||
1. **URL parameter** — `?template=editorial`, `?template=swiss`, or
|
||||
`?template=cosmos` always wins over the JSON value, so links like
|
||||
`https://your.site/?template=cosmos` are shareable.
|
||||
2. **Easter egg** — type `warp` anywhere on the page (no input field
|
||||
focused). The page reloads on the next template in the cycle and a
|
||||
small `↯ <name>` toast confirms it. The URL is updated with the new
|
||||
`?template=` param.
|
||||
|
||||
Drop the URL param (or refresh from a clean URL) to return to whatever
|
||||
`theme.template` says in `data/links.json`.
|
||||
|
||||
## Theme (light / dark / auto)
|
||||
|
||||
A small pill in the top-right cycles **Auto → Light → Dark → Auto** on
|
||||
|
||||
@@ -52,6 +52,108 @@
|
||||
}
|
||||
@keyframes cosmos-fade-in { to { opacity: 1; } }
|
||||
|
||||
/* cursor aurora — soft cyan/magenta halo that lags behind the pointer */
|
||||
:root[data-template="cosmos"] .cosmos-halo {
|
||||
position: fixed; top: 0; left: 0;
|
||||
width: 600px; height: 600px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
background:
|
||||
radial-gradient(circle at 50% 50%,
|
||||
rgba(122, 247, 255, 0.20) 0%,
|
||||
rgba(176, 122, 255, 0.14) 18%,
|
||||
rgba(255, 78, 205, 0.10) 38%,
|
||||
transparent 65%);
|
||||
mix-blend-mode: screen;
|
||||
filter: blur(8px);
|
||||
opacity: 0;
|
||||
transition: opacity 700ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
:root[data-template="cosmos"].cosmos-cursor .cosmos-halo { opacity: 1; }
|
||||
|
||||
/* periodic comets — three streaks crossing the page */
|
||||
:root[data-template="cosmos"] .cosmos-comet {
|
||||
position: fixed; top: 0; left: 0;
|
||||
width: clamp(120px, 12vw, 200px);
|
||||
height: 2px;
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
background: linear-gradient(90deg,
|
||||
transparent 0%,
|
||||
rgba(255,255,255,0.85) 60%,
|
||||
var(--accent) 85%,
|
||||
transparent 100%);
|
||||
filter: drop-shadow(0 0 6px var(--accent)) drop-shadow(0 0 18px var(--accent-3));
|
||||
opacity: 0;
|
||||
transform-origin: right center;
|
||||
}
|
||||
:root[data-template="cosmos"] .cosmos-comet--1 { animation: cosmos-comet-a 14s 4s ease-in infinite; }
|
||||
:root[data-template="cosmos"] .cosmos-comet--2 { animation: cosmos-comet-b 22s 11s ease-in infinite; }
|
||||
:root[data-template="cosmos"] .cosmos-comet--3 { animation: cosmos-comet-c 19s 19s ease-in infinite; }
|
||||
@keyframes cosmos-comet-a {
|
||||
0% { transform: translate(-20vw, 12vh) rotate(28deg) scaleX(0.4); opacity: 0; }
|
||||
4% { opacity: 1; }
|
||||
18% { transform: translate(110vw, 70vh) rotate(28deg) scaleX(1); opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes cosmos-comet-b {
|
||||
0% { transform: translate(115vw, 8vh) rotate(158deg) scaleX(0.4); opacity: 0; }
|
||||
5% { opacity: 1; }
|
||||
22% { transform: translate(-20vw, 65vh) rotate(158deg) scaleX(1); opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
@keyframes cosmos-comet-c {
|
||||
0% { transform: translate(-20vw, 50vh) rotate(45deg) scaleX(0.4); opacity: 0; }
|
||||
3% { opacity: 1; }
|
||||
16% { transform: translate(110vw, -10vh) rotate(45deg) scaleX(1); opacity: 0; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
|
||||
/* card 3D tilt + cursor-tracking holographic shimmer */
|
||||
:root[data-template="cosmos"] .card { transform-style: preserve-3d; perspective: 1000px; }
|
||||
:root[data-template="cosmos"] .card:hover {
|
||||
transform:
|
||||
translateY(-4px)
|
||||
rotateX(calc(var(--ty, 0) * -6deg))
|
||||
rotateY(calc(var(--tx, 0) * 6deg));
|
||||
}
|
||||
:root[data-template="cosmos"] .card .card__shimmer {
|
||||
position: absolute; inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: inherit;
|
||||
background: radial-gradient(circle 220px at var(--sx, 50%) var(--sy, 50%),
|
||||
rgba(122, 247, 255, 0.20),
|
||||
rgba(176, 122, 255, 0.10) 35%,
|
||||
transparent 70%);
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
transition: opacity 350ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
z-index: 0;
|
||||
}
|
||||
:root[data-template="cosmos"] .card:hover .card__shimmer { opacity: 1; }
|
||||
:root[data-template="cosmos"] .card > * { position: relative; z-index: 1; }
|
||||
|
||||
/* hero parallax — name shifts counter to cursor, orb drifts with it */
|
||||
:root[data-template="cosmos"] .hero__name {
|
||||
transform:
|
||||
translate3d(calc(var(--cm-x, 0) * -10px), calc(var(--cm-y, 0) * -6px), 0);
|
||||
transition: transform 500ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
:root[data-template="cosmos"] .hero__asterism {
|
||||
/* override the base drift to include parallax */
|
||||
animation: cosmos-orb-rise 1400ms 300ms cubic-bezier(0.16, 1, 0.30, 1) both;
|
||||
transform: translate3d(calc(var(--cm-x, 0) * 16px), calc(var(--cm-y, 0) * 10px), 0);
|
||||
transition: transform 700ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
:root[data-template="cosmos"] .hero__tagline {
|
||||
transform: translate3d(calc(var(--cm-x, 0) * -4px), calc(var(--cm-y, 0) * -2px), 0);
|
||||
transition: transform 600ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
|
||||
/* CSS-only static fallback nebula when WebGL is unavailable */
|
||||
:root[data-template="cosmos"].cosmos-static body {
|
||||
background:
|
||||
@@ -572,7 +674,9 @@
|
||||
|
||||
/* ───── testimonial / carousel ───── */
|
||||
:root[data-template="cosmos"] .card--testimonial {
|
||||
background: linear-gradient(135deg, rgba(30, 22, 64, 0.55), rgba(14, 8, 38, 0.55));
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
:root[data-template="cosmos"] .testimonial__mark {
|
||||
color: var(--accent);
|
||||
@@ -633,10 +737,17 @@
|
||||
:root[data-template="cosmos"] .marker__brand .star,
|
||||
:root[data-template="cosmos"] .hero__asterism,
|
||||
:root[data-template="cosmos"] .section__head::after,
|
||||
:root[data-template="cosmos"] .card::before {
|
||||
:root[data-template="cosmos"] .card::before,
|
||||
:root[data-template="cosmos"] .cosmos-comet {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
:root[data-template="cosmos"] .cosmos-bg { display: none; }
|
||||
:root[data-template="cosmos"] .cosmos-bg,
|
||||
:root[data-template="cosmos"] .cosmos-halo,
|
||||
:root[data-template="cosmos"] .cosmos-comet { display: none; }
|
||||
:root[data-template="cosmos"] .hero__name,
|
||||
:root[data-template="cosmos"] .hero__tagline,
|
||||
:root[data-template="cosmos"] .hero__asterism { transform: none !important; }
|
||||
:root[data-template="cosmos"].cosmos-static body {
|
||||
background:
|
||||
radial-gradient(ellipse at 30% 20%, rgba(176, 122, 255, 0.25), transparent 60%),
|
||||
|
||||
@@ -37,6 +37,16 @@ a { color: inherit; text-decoration: none; }
|
||||
--radius: 14px;
|
||||
--ease: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
--ease-strong: cubic-bezier(0.16, 1, 0.30, 1);
|
||||
|
||||
/* spacing scale — semantic, fluid. Use these instead of ad-hoc clamps. */
|
||||
--space-2xs: 0.25rem;
|
||||
--space-xs: 0.5rem;
|
||||
--space-sm: 0.75rem;
|
||||
--space-md: 1rem;
|
||||
--space-lg: clamp(1.25rem, 1rem + 0.8vw, 1.75rem);
|
||||
--space-xl: clamp(2rem, 1.5rem + 1.4vw, 3rem);
|
||||
--space-2xl: clamp(3rem, 2rem + 3vw, 5rem);
|
||||
--space-3xl: clamp(4.5rem, 3rem + 5vw, 7.5rem);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@@ -85,7 +95,7 @@ body::before {
|
||||
/* ───── marker bar ───── */
|
||||
.marker {
|
||||
display: flex; align-items: center; gap: 1rem;
|
||||
padding: 1.25rem 0;
|
||||
padding: var(--space-lg) 0 var(--space-md);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
font: 500 var(--fs-mini)/1 var(--mono);
|
||||
text-transform: uppercase;
|
||||
@@ -149,7 +159,7 @@ body::before {
|
||||
opacity: 0.95;
|
||||
}
|
||||
.hero__tagline {
|
||||
margin-top: clamp(1.5rem, 3vw, 2.25rem);
|
||||
margin-top: var(--space-xl);
|
||||
font-family: var(--display);
|
||||
font-style: italic;
|
||||
font-weight: 400;
|
||||
@@ -162,14 +172,15 @@ body::before {
|
||||
}
|
||||
.hero__tagline span { color: var(--accent); padding: 0 0.2em; }
|
||||
.hero__bio {
|
||||
margin-top: 1.5rem;
|
||||
/* paired with tagline — tight gap so they read as one thought */
|
||||
margin-top: var(--space-sm);
|
||||
max-width: 48ch;
|
||||
color: var(--muted);
|
||||
font-size: var(--fs-md);
|
||||
}
|
||||
|
||||
/* ───── social rail ───── */
|
||||
.social { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: 2rem; }
|
||||
/* ───── social rail — separated from the bio so it reads as the action layer ───── */
|
||||
.social { display: flex; flex-wrap: wrap; gap: 0.5rem; margin-top: var(--space-xl); }
|
||||
.social a {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
padding: 9px 16px;
|
||||
@@ -185,17 +196,29 @@ body::before {
|
||||
.social svg { width: 16px; height: 16px; }
|
||||
|
||||
/* ───── sections ───── */
|
||||
.section { margin-top: clamp(4rem, 7vw, 6rem); }
|
||||
.section { margin-top: var(--space-2xl); }
|
||||
/* opening beat — first section after the hero gets extra air, then the
|
||||
rhythm settles into the regular cadence */
|
||||
.hero + .section,
|
||||
main > .section:first-child { margin-top: var(--space-3xl); }
|
||||
.section--headless {
|
||||
/* nest visually under the previous section instead of starting a new one */
|
||||
margin-top: clamp(2.75rem, 5vw, 4.5rem);
|
||||
}
|
||||
/* tighten the gap AFTER a headless section so the next section pulls back up */
|
||||
.section--headless + .section {
|
||||
margin-top: clamp(2.25rem, 4vw, 3.5rem);
|
||||
}
|
||||
.section__head {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: clamp(1rem, 2.5vw, 2rem);
|
||||
gap: clamp(1.25rem, 3vw, 2.5rem);
|
||||
align-items: end;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: clamp(1.5rem, 3vw, 2.25rem);
|
||||
padding-bottom: var(--space-md);
|
||||
margin-bottom: var(--space-xl);
|
||||
border-bottom: 2px solid var(--ink);
|
||||
}
|
||||
.section__numwrap { display: flex; flex-direction: column; gap: 0.2rem; align-self: end; }
|
||||
.section__numwrap { display: flex; flex-direction: column; gap: var(--space-2xs); align-self: end; }
|
||||
.section__numwrap small {
|
||||
font-family: var(--display);
|
||||
font-size: var(--fs-md);
|
||||
@@ -222,11 +245,11 @@ body::before {
|
||||
}
|
||||
.section__title {
|
||||
font-family: var(--display);
|
||||
font-weight: 350;
|
||||
font-weight: 420;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 30;
|
||||
font-size: var(--fs-xl);
|
||||
line-height: 0.95;
|
||||
letter-spacing: -0.025em;
|
||||
letter-spacing: -0.028em;
|
||||
color: var(--ink);
|
||||
}
|
||||
.section__kicker {
|
||||
@@ -256,8 +279,24 @@ body::before {
|
||||
.grid > .card--featured{ grid-column: span 8; }
|
||||
}
|
||||
|
||||
/* portfolio item — always full row */
|
||||
.grid > .card--portfolio { grid-column: span 12; }
|
||||
/* portfolio item — always full row, capped at 78% with breathing room */
|
||||
.grid > .card--portfolio {
|
||||
grid-column: span 12;
|
||||
margin-block: clamp(0.5rem, 1.5vw, 1.25rem);
|
||||
}
|
||||
/* featured/wide youtube — opt-in via `featured: true` in JSON */
|
||||
.grid > .card--youtube.card--wide {
|
||||
grid-column: span 12;
|
||||
margin-block: clamp(0.5rem, 1.5vw, 1.25rem);
|
||||
}
|
||||
@media (min-width: 700px) {
|
||||
.grid > .card--portfolio,
|
||||
.grid > .card--youtube.card--wide {
|
||||
width: 100%;
|
||||
max-width: 78%;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* clients grid — auto-flowing square tiles */
|
||||
.grid--clients {
|
||||
@@ -412,7 +451,7 @@ body::before {
|
||||
|
||||
/* client tile — square logo, caption below, no card chrome */
|
||||
.card--client {
|
||||
padding: 0;
|
||||
padding: 0 0 0.85rem;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
display: flex; flex-direction: column;
|
||||
@@ -479,6 +518,12 @@ body::before {
|
||||
|
||||
/* ───── carousel (testimonials) ───── */
|
||||
.carousel { position: relative; }
|
||||
.carousel--inline {
|
||||
display: flex; align-items: center;
|
||||
gap: clamp(0.5rem, 2vw, 1.5rem);
|
||||
padding: 0 clamp(0.5rem, 3vw, 2rem);
|
||||
}
|
||||
.carousel--inline .carousel__viewport { flex: 1 1 0; min-width: 0; }
|
||||
.carousel__viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -493,16 +538,15 @@ body::before {
|
||||
grid-area: stack;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translateY(8px);
|
||||
transition: opacity 600ms var(--ease-strong), transform 600ms var(--ease-strong), visibility 0s linear 600ms;
|
||||
transition: opacity 1100ms cubic-bezier(0.65, 0, 0.35, 1), visibility 0s linear 1100ms;
|
||||
pointer-events: none;
|
||||
will-change: opacity;
|
||||
}
|
||||
.carousel__track > .is-active {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: none;
|
||||
pointer-events: auto;
|
||||
transition: opacity 600ms var(--ease-strong), transform 600ms var(--ease-strong), visibility 0s;
|
||||
transition: opacity 1100ms cubic-bezier(0.65, 0, 0.35, 1), visibility 0s;
|
||||
}
|
||||
.carousel--stacked .carousel__track {
|
||||
display: flex;
|
||||
@@ -543,45 +587,84 @@ body::before {
|
||||
.carousel__dot:hover { background: var(--ink-2); }
|
||||
.carousel__dot.is-active { background: var(--accent); transform: scale(1.35); }
|
||||
|
||||
/* ───── testimonial card ───── */
|
||||
/* ───── testimonial — "of the page", no chrome ───── */
|
||||
.card--testimonial {
|
||||
padding: clamp(1.75rem, 3vw, 2.75rem);
|
||||
padding: 0.5rem 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
display: flex; flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-height: clamp(220px, 24vw, 300px);
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
gap: 0.85rem;
|
||||
min-height: 0;
|
||||
isolation: auto;
|
||||
}
|
||||
.card--testimonial::after { display: none; }
|
||||
.card--testimonial:hover {
|
||||
transform: none;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
.card--testimonial::after,
|
||||
.card--testimonial::before { display: none; }
|
||||
.testimonial__mark {
|
||||
font-family: var(--display);
|
||||
font-size: clamp(4rem, 6vw, 6rem);
|
||||
line-height: 0.6;
|
||||
font-size: clamp(2.5rem, 3.5vw, 3.5rem);
|
||||
line-height: 0.55;
|
||||
color: var(--accent);
|
||||
font-style: italic;
|
||||
font-variation-settings: "opsz" 144, "SOFT" 100;
|
||||
font-weight: 400;
|
||||
align-self: start;
|
||||
user-select: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.testimonial__quote {
|
||||
font-family: var(--display);
|
||||
font-size: clamp(1.15rem, 0.95rem + 0.7vw, 1.45rem);
|
||||
line-height: 1.45;
|
||||
font-size: var(--quote-fs, clamp(1.2rem, 0.95rem + 0.85vw, 1.55rem));
|
||||
line-height: var(--quote-lh, 1.45);
|
||||
font-weight: 400;
|
||||
font-variation-settings: "opsz" 60, "SOFT" 100;
|
||||
color: var(--ink);
|
||||
margin: 0;
|
||||
letter-spacing: -0.005em;
|
||||
max-width: 60ch;
|
||||
max-width: 64ch;
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* length-driven scale: shorter quotes get the spotlight, longer ones tighten */
|
||||
.card--testimonial.is-xshort {
|
||||
--quote-fs: clamp(1.85rem, 1.2rem + 2.4vw, 3rem);
|
||||
--quote-lh: 1.20;
|
||||
}
|
||||
.card--testimonial.is-short {
|
||||
--quote-fs: clamp(1.5rem, 1.05rem + 1.7vw, 2.25rem);
|
||||
--quote-lh: 1.30;
|
||||
}
|
||||
.card--testimonial.is-medium {
|
||||
--quote-fs: clamp(1.2rem, 0.95rem + 0.85vw, 1.55rem);
|
||||
--quote-lh: 1.45;
|
||||
}
|
||||
.card--testimonial.is-long {
|
||||
--quote-fs: clamp(1.05rem, 0.95rem + 0.35vw, 1.20rem);
|
||||
--quote-lh: 1.55;
|
||||
}
|
||||
.card--testimonial.is-xlong {
|
||||
--quote-fs: clamp(0.95rem, 0.92rem + 0.18vw, 1.05rem);
|
||||
--quote-lh: 1.6;
|
||||
}
|
||||
|
||||
.testimonial__by {
|
||||
display: flex; align-items: center; gap: 0.85rem;
|
||||
margin-top: auto;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid var(--rule);
|
||||
display: flex; align-items: center; justify-content: center; gap: 0.7rem;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
width: auto;
|
||||
}
|
||||
.testimonial__avatar {
|
||||
flex: 0 0 auto;
|
||||
width: 44px; height: 44px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--paper-2);
|
||||
@@ -590,7 +673,7 @@ body::before {
|
||||
}
|
||||
.testimonial__avatar img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.testimonial__avatar img.is-favicon { width: 60%; height: 60%; object-fit: contain; }
|
||||
.testimonial__attr { display: flex; flex-direction: column; gap: 0.1rem; line-height: 1.3; min-width: 0; }
|
||||
.testimonial__attr { display: flex; flex-direction: column; gap: 0.1rem; line-height: 1.3; min-width: 0; text-align: left; }
|
||||
.testimonial__name {
|
||||
font-family: var(--body);
|
||||
font-weight: 600;
|
||||
@@ -648,3 +731,33 @@ body::before {
|
||||
}
|
||||
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
|
||||
/* warp toast — surfaces the active template after the easter egg fires */
|
||||
.warp-toast {
|
||||
position: fixed;
|
||||
bottom: clamp(1.25rem, 3vw, 2.25rem);
|
||||
left: 50%;
|
||||
z-index: 1000;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 1.1rem;
|
||||
background: var(--ink);
|
||||
color: var(--paper);
|
||||
font: 600 var(--fs-sm)/1 var(--mono);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 12px 30px -16px rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
transform: translate(-50%, 18px);
|
||||
animation: warp-in 360ms cubic-bezier(0.22, 1, 0.36, 1) forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
.warp-toast__arrow { color: var(--accent); font-size: 1.1em; }
|
||||
.warp-toast--out { animation: warp-out 600ms ease forwards; }
|
||||
@keyframes warp-in { to { opacity: 1; transform: translate(-50%, 0); } }
|
||||
@keyframes warp-out { to { opacity: 0; transform: translate(-50%, 18px); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.warp-toast { animation: none; opacity: 1; transform: translate(-50%, 0); }
|
||||
}
|
||||
|
||||
@@ -419,8 +419,8 @@
|
||||
}
|
||||
|
||||
/* ───── testimonial / carousel ───── */
|
||||
:root[data-template="swiss"] .card--testimonial { background: var(--paper-2); }
|
||||
:root[data-template="swiss"] .card--testimonial:hover { background: var(--paper-2); color: var(--ink); }
|
||||
:root[data-template="swiss"] .card--testimonial { background: transparent; }
|
||||
:root[data-template="swiss"] .card--testimonial:hover { background: transparent; color: var(--ink); }
|
||||
:root[data-template="swiss"] .card--testimonial:hover .testimonial__quote,
|
||||
:root[data-template="swiss"] .card--testimonial:hover .testimonial__name { color: var(--ink); }
|
||||
:root[data-template="swiss"] .testimonial__mark {
|
||||
|
||||
195
assets/js/app.js
195
assets/js/app.js
@@ -60,8 +60,9 @@
|
||||
}
|
||||
|
||||
function renderYouTube(it) {
|
||||
const wide = it.featured ? " card--wide" : "";
|
||||
return `
|
||||
<div class="card card--youtube reveal">
|
||||
<div class="card card--youtube${wide} reveal">
|
||||
<div class="yt" role="button" tabindex="0" aria-label="Play: ${esc(it.title)}" data-yt="${esc(it.id)}" style="background-image:url('${esc(ytThumb(it.id))}')">
|
||||
<div class="yt__play" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24"><path d="M8 5v14l11-7z" fill="currentColor"/></svg>
|
||||
@@ -117,8 +118,15 @@
|
||||
const isFavicon = !(it.image || it.icon);
|
||||
const initial = (it.name || it.org || "·").trim().charAt(0).toUpperCase();
|
||||
const meta = [it.role, it.org].filter(Boolean).join(", ");
|
||||
const len = [...String(it.quote || "")].length;
|
||||
const lengthClass =
|
||||
len <= 90 ? " is-xshort" :
|
||||
len <= 180 ? " is-short" :
|
||||
len <= 320 ? " is-medium" :
|
||||
len <= 520 ? " is-long" :
|
||||
" is-xlong";
|
||||
return `
|
||||
<${tag} class="card card--testimonial reveal" ${attrs}>
|
||||
<${tag} class="card card--testimonial reveal${lengthClass}" ${attrs}>
|
||||
<span class="testimonial__mark" aria-hidden="true">“</span>
|
||||
<blockquote class="testimonial__quote">${esc(it.quote)}</blockquote>
|
||||
<figcaption class="testimonial__by">
|
||||
@@ -148,26 +156,21 @@
|
||||
let body;
|
||||
if (isTestimonials) {
|
||||
const count = (sec.items || []).length;
|
||||
const dots = Array.from({ length: count }, (_, i) =>
|
||||
`<button type="button" class="carousel__dot${i === 0 ? " is-active" : ""}" aria-label="Show testimonial ${i + 1} of ${count}" data-slide="${i}"></button>`).join("");
|
||||
body = `
|
||||
<div class="carousel" data-carousel data-count="${count}">
|
||||
<div class="carousel carousel--inline" data-carousel data-count="${count}">
|
||||
${count > 1 ? `<button type="button" class="carousel__nav carousel__nav--prev" aria-label="Previous testimonial">‹</button>` : ""}
|
||||
<div class="carousel__viewport">
|
||||
<div class="carousel__track" aria-live="polite">${items}</div>
|
||||
</div>
|
||||
${count > 1 ? `
|
||||
<div class="carousel__controls">
|
||||
<button type="button" class="carousel__nav carousel__nav--prev" aria-label="Previous testimonial">‹</button>
|
||||
<div class="carousel__dots" role="tablist">${dots}</div>
|
||||
<button type="button" class="carousel__nav carousel__nav--next" aria-label="Next testimonial">›</button>
|
||||
</div>` : ""}
|
||||
${count > 1 ? `<button type="button" class="carousel__nav carousel__nav--next" aria-label="Next testimonial">›</button>` : ""}
|
||||
</div>`;
|
||||
} else {
|
||||
const gridClass = isClients ? "grid--clients" : "grid";
|
||||
body = `<div class="${gridClass}">${items}</div>`;
|
||||
}
|
||||
return `
|
||||
<section class="section">
|
||||
const headless = sec.headless === true;
|
||||
const cls = `section${headless ? " section--headless" : ""}`;
|
||||
const head = headless ? "" : `
|
||||
<header class="section__head">
|
||||
<div class="section__numwrap">
|
||||
<small>№</small>
|
||||
@@ -177,7 +180,10 @@
|
||||
<h2 class="section__title">${esc(sec.label)}</h2>
|
||||
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
|
||||
</div>
|
||||
</header>
|
||||
</header>`;
|
||||
return `
|
||||
<section class="${cls}" ${sec.id ? `id="${esc(sec.id)}"` : ""}>
|
||||
${head}
|
||||
${body}
|
||||
</section>`;
|
||||
}
|
||||
@@ -283,6 +289,10 @@
|
||||
const root = document.documentElement;
|
||||
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (reducedMotion) { root.classList.add("cosmos-static"); return; }
|
||||
attachCosmosCursor();
|
||||
attachCosmosComets();
|
||||
attachCosmosParallax();
|
||||
attachCosmosCardTilt();
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "cosmos-bg";
|
||||
@@ -300,6 +310,8 @@
|
||||
uniform vec2 u_res;
|
||||
uniform float u_time;
|
||||
uniform vec2 u_mouse;
|
||||
uniform vec2 u_ripple_pos;
|
||||
uniform float u_ripple_age;
|
||||
|
||||
float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
|
||||
float noise(vec2 p) {
|
||||
@@ -322,11 +334,20 @@
|
||||
vec2 uv = frag / u_res;
|
||||
vec2 p = (frag - 0.5 * u_res) / u_res.y;
|
||||
|
||||
// gravity well: pull sampling toward cursor very subtly
|
||||
// gravity well: pull sampling toward cursor — stronger now
|
||||
vec2 m = (u_mouse - 0.5 * u_res) / u_res.y;
|
||||
vec2 toM = p - m;
|
||||
float md = length(toM) + 0.001;
|
||||
p -= (toM / md) * 0.03 * exp(-md * 1.6);
|
||||
p -= (toM / md) * 0.06 * exp(-md * 1.2);
|
||||
|
||||
// click ripple — propagating ring of displacement and brightness
|
||||
vec2 rp = (u_ripple_pos - 0.5 * u_res) / u_res.y;
|
||||
float rd = length(p - rp);
|
||||
float rt = u_ripple_age;
|
||||
float ringR = rt * 1.4;
|
||||
float ringW = 0.16 + rt * 0.05;
|
||||
float ring = exp(-pow((rd - ringR) / ringW, 2.0)) * exp(-rt * 0.7);
|
||||
p -= normalize(p - rp + vec2(0.0001)) * ring * 0.10;
|
||||
|
||||
// nebula via warped fbm
|
||||
vec2 q = p * 1.25 + vec2(u_time * 0.015, u_time * 0.010);
|
||||
@@ -344,6 +365,10 @@
|
||||
col = mix(col, magenta, smoothstep(0.55, 0.92, n) * 0.85);
|
||||
col += cyan * smoothstep(0.78, 0.98, n) * 0.45;
|
||||
|
||||
// ripple adds a luminous cyan ring
|
||||
col += cyan * ring * 0.85;
|
||||
col += vec3(1.0, 0.6, 0.95) * ring * 0.35;
|
||||
|
||||
// soft vignette
|
||||
float vig = smoothstep(1.25, 0.30, length(p));
|
||||
col *= mix(0.55, 1.05, vig);
|
||||
@@ -413,6 +438,8 @@
|
||||
const uRes = gl.getUniformLocation(prog, "u_res");
|
||||
const uTime = gl.getUniformLocation(prog, "u_time");
|
||||
const uMouse = gl.getUniformLocation(prog, "u_mouse");
|
||||
const uRipplePos = gl.getUniformLocation(prog, "u_ripple_pos");
|
||||
const uRippleAge = gl.getUniformLocation(prog, "u_ripple_age");
|
||||
|
||||
document.body.prepend(canvas);
|
||||
|
||||
@@ -434,6 +461,15 @@
|
||||
target.y = 1 - e.clientY / window.innerHeight;
|
||||
}, { passive: true });
|
||||
|
||||
const t0 = performance.now();
|
||||
const ripple = { x: 0, y: 0, t0: -1e6 };
|
||||
window.addEventListener("pointerdown", (e) => {
|
||||
const dpr2 = Math.min(window.devicePixelRatio || 1, 1.5);
|
||||
ripple.x = e.clientX * dpr2;
|
||||
ripple.y = (window.innerHeight - e.clientY) * dpr2;
|
||||
ripple.t0 = (performance.now() - t0) / 1000;
|
||||
}, { passive: true });
|
||||
|
||||
let running = true;
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
const wasPaused = !running;
|
||||
@@ -441,20 +477,135 @@
|
||||
if (running && wasPaused) requestAnimationFrame(loop);
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
function loop(now) {
|
||||
if (!running) return;
|
||||
mouse.x += (target.x - mouse.x) * 0.05;
|
||||
mouse.y += (target.y - mouse.y) * 0.05;
|
||||
const tNow = (now - t0) / 1000;
|
||||
gl.uniform2f(uRes, w, h);
|
||||
gl.uniform1f(uTime, (now - t0) / 1000);
|
||||
gl.uniform1f(uTime, tNow);
|
||||
gl.uniform2f(uMouse, mouse.x * w, mouse.y * h);
|
||||
gl.uniform2f(uRipplePos, ripple.x, ripple.y);
|
||||
gl.uniform1f(uRippleAge, tNow - ripple.t0);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, 6);
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
requestAnimationFrame(loop);
|
||||
}
|
||||
|
||||
/* ───── cosmos overlays + interactions ───── */
|
||||
function attachCosmosCursor() {
|
||||
const root = document.documentElement;
|
||||
const halo = document.createElement("div");
|
||||
halo.className = "cosmos-halo";
|
||||
halo.setAttribute("aria-hidden", "true");
|
||||
document.body.appendChild(halo);
|
||||
|
||||
let x = window.innerWidth / 2, y = window.innerHeight / 2;
|
||||
let tx = x, ty = y;
|
||||
let active = false;
|
||||
const wakeUp = () => { if (!active) { active = true; root.classList.add("cosmos-cursor"); } };
|
||||
window.addEventListener("pointermove", (e) => {
|
||||
tx = e.clientX; ty = e.clientY; wakeUp();
|
||||
const nx = (e.clientX / window.innerWidth) - 0.5;
|
||||
const ny = (e.clientY / window.innerHeight) - 0.5;
|
||||
root.style.setProperty("--cm-x", nx.toFixed(3));
|
||||
root.style.setProperty("--cm-y", ny.toFixed(3));
|
||||
}, { passive: true });
|
||||
window.addEventListener("pointerleave", () => root.classList.remove("cosmos-cursor"));
|
||||
|
||||
(function tick() {
|
||||
x += (tx - x) * 0.14;
|
||||
y += (ty - y) * 0.14;
|
||||
halo.style.transform = `translate3d(${x - 300}px, ${y - 300}px, 0)`;
|
||||
requestAnimationFrame(tick);
|
||||
})();
|
||||
}
|
||||
|
||||
function attachCosmosComets() {
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const c = document.createElement("div");
|
||||
c.className = `cosmos-comet cosmos-comet--${i}`;
|
||||
c.setAttribute("aria-hidden", "true");
|
||||
frag.appendChild(c);
|
||||
}
|
||||
document.body.appendChild(frag);
|
||||
}
|
||||
|
||||
function attachCosmosParallax() {
|
||||
/* CSS reads --cm-x / --cm-y set in attachCosmosCursor */
|
||||
}
|
||||
|
||||
function attachCosmosCardTilt() {
|
||||
document.querySelectorAll(".card").forEach((card) => {
|
||||
if (card.classList.contains("card--testimonial")) return;
|
||||
if (card.classList.contains("card--client")) return;
|
||||
const sh = document.createElement("span");
|
||||
sh.className = "card__shimmer";
|
||||
sh.setAttribute("aria-hidden", "true");
|
||||
card.prepend(sh);
|
||||
let raf = 0;
|
||||
card.addEventListener("pointermove", (e) => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(() => {
|
||||
const rect = card.getBoundingClientRect();
|
||||
const x = (e.clientX - rect.left) / rect.width;
|
||||
const y = (e.clientY - rect.top) / rect.height;
|
||||
card.style.setProperty("--tx", (x - 0.5).toFixed(3));
|
||||
card.style.setProperty("--ty", (y - 0.5).toFixed(3));
|
||||
card.style.setProperty("--sx", `${(x * 100).toFixed(1)}%`);
|
||||
card.style.setProperty("--sy", `${(y * 100).toFixed(1)}%`);
|
||||
raf = 0;
|
||||
});
|
||||
});
|
||||
card.addEventListener("pointerleave", () => {
|
||||
card.style.setProperty("--tx", "0");
|
||||
card.style.setProperty("--ty", "0");
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function attachWarpEgg() {
|
||||
const order = ["editorial", "swiss", "cosmos"];
|
||||
let buf = "";
|
||||
const KEY = "warp";
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
const tgt = e.target;
|
||||
if (tgt && tgt.matches && tgt.matches("input, textarea, [contenteditable]")) return;
|
||||
if (!e.key || e.key.length !== 1) return;
|
||||
buf = (buf + e.key.toLowerCase()).slice(-KEY.length);
|
||||
if (buf !== KEY) return;
|
||||
buf = "";
|
||||
const cur = document.documentElement.dataset.template || "editorial";
|
||||
const next = order[(order.indexOf(cur) + 1) % order.length];
|
||||
try { sessionStorage.setItem("dlstack-warp-toast", next); } catch (err) {}
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("template", next);
|
||||
location.replace(url.toString());
|
||||
});
|
||||
|
||||
let pending = null;
|
||||
try { pending = sessionStorage.getItem("dlstack-warp-toast"); } catch (err) {}
|
||||
if (!pending) return;
|
||||
try { sessionStorage.removeItem("dlstack-warp-toast"); } catch (err) {}
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "warp-toast";
|
||||
toast.setAttribute("role", "status");
|
||||
const arrow = document.createElement("span");
|
||||
arrow.className = "warp-toast__arrow";
|
||||
arrow.setAttribute("aria-hidden", "true");
|
||||
arrow.textContent = "↯";
|
||||
const label = document.createElement("span");
|
||||
label.className = "warp-toast__label";
|
||||
label.textContent = pending;
|
||||
toast.append(arrow, label);
|
||||
document.body.appendChild(toast);
|
||||
setTimeout(() => toast.classList.add("warp-toast--out"), 1600);
|
||||
setTimeout(() => toast.remove(), 2500);
|
||||
}
|
||||
|
||||
function attachCarousels(root) {
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
$$("[data-carousel]", root).forEach((car) => {
|
||||
@@ -468,6 +619,7 @@
|
||||
i = (n + slides.length) % slides.length;
|
||||
slides.forEach((s, k) => s.classList.toggle("is-active", k === i));
|
||||
dots.forEach((d, k) => d.classList.toggle("is-active", k === i));
|
||||
car.setAttribute("aria-label", `Testimonial ${i + 1} of ${slides.length}`);
|
||||
};
|
||||
show(0);
|
||||
|
||||
@@ -549,7 +701,11 @@
|
||||
document.documentElement.style.setProperty("--accent", data.theme.accent);
|
||||
}
|
||||
const validTpl = new Set(["editorial", "swiss", "cosmos"]);
|
||||
const tpl = validTpl.has(data.theme?.template) ? data.theme.template : "editorial";
|
||||
let urlTpl = null;
|
||||
try { urlTpl = new URL(location.href).searchParams.get("template"); } catch (e) {}
|
||||
const tpl = validTpl.has(urlTpl) ? urlTpl
|
||||
: validTpl.has(data.theme?.template) ? data.theme.template
|
||||
: "editorial";
|
||||
document.documentElement.dataset.template = tpl;
|
||||
try { localStorage.setItem("dlstack-template", tpl); } catch (e) {}
|
||||
if (tpl === "cosmos") bootCosmos();
|
||||
@@ -592,6 +748,7 @@
|
||||
attachCarousels(app);
|
||||
attachReveal(app);
|
||||
attachTheme();
|
||||
attachWarpEgg();
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
|
||||
@@ -96,6 +96,7 @@
|
||||
"label": "Testimonials",
|
||||
"kicker": "What people say",
|
||||
"layout": "testimonials",
|
||||
"headless": true,
|
||||
"items": [
|
||||
{
|
||||
"type": "testimonial",
|
||||
|
||||
@@ -22,12 +22,19 @@
|
||||
<link href="https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700;900&family=DM+Serif+Display:ital@0;1&family=Fraunces:opsz,wght,SOFT,WONK@9..144,300..600,30..100,0..1&family=Geist:wght@300;400;500;600&family=Geist+Mono:wght@400;500&family=Orbitron:wght@400;500;700;900&display=swap" rel="stylesheet">
|
||||
|
||||
<script>
|
||||
// Apply saved theme + template before paint to avoid flash
|
||||
// Apply saved theme + template before paint to avoid flash.
|
||||
// URL ?template=… wins over the cached value so deep links work.
|
||||
try {
|
||||
var t = localStorage.getItem("dlstack-theme");
|
||||
if (t === "light" || t === "dark") document.documentElement.dataset.theme = t;
|
||||
var qtpl = null;
|
||||
try { qtpl = new URL(location.href).searchParams.get("template"); } catch (e) {}
|
||||
if (qtpl === "swiss" || qtpl === "editorial" || qtpl === "cosmos") {
|
||||
document.documentElement.dataset.template = qtpl;
|
||||
} else {
|
||||
var tpl = localStorage.getItem("dlstack-template");
|
||||
if (tpl === "swiss" || tpl === "editorial" || tpl === "cosmos") document.documentElement.dataset.template = tpl;
|
||||
}
|
||||
} catch (e) {}
|
||||
</script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user