Compare commits

...

10 Commits

Author SHA1 Message Date
Joel Brock
89d7e8d8ca Videos: 2-per-row default, featured opt-in to wide layout
Reverted the global youtube wide treatment per user direction.
Videos default to span 6 (pair layout) at 600px+ again. Items with
`featured: true` in JSON get a new `card--wide` class that opts
them into the same 78%-centered + vertical-breathing layout
portfolio uses — meant to promote a hero video without making
every video imposing.
2026-05-18 20:13:12 -07:00
Joel Brock
bb0f0ec13a Fix: youtube cards rendered at 0 width when constrained
YouTube cards only have absolutely-positioned children
(.yt__play, .yt__title), so their intrinsic width is 0. With
max-width:78% + justify-self:center but no `width`, the cards
collapsed to nothing — explaining the empty Videos section.
Portfolio worked because its caption has text content giving the
card an intrinsic width.

Add width:100% to the constrained rule so max-width has a definite
basis to cap against. Both portfolio and youtube cards now render
at 78% of their grid track on 700px+.
2026-05-18 17:32:54 -07:00
Joel Brock
d357eba65f Videos: same portfolio treatment — 78% centered, stacked, breathing
YouTube cards now follow the portfolio pattern: full-bleed on
mobile, 78% max-width centered on 700px+, with margin-block to keep
neighbors at distance. Dropped the old span 6 (2-up pair) rules at
the 600/960px breakpoints — videos read better as deliberate single
plates than as a grid wall now that they take up real space.
2026-05-18 17:28:35 -07:00
Joel Brock
f9cb18bd74 Portfolio: 78% width centered, extra vertical breathing
Portfolio cards no longer span the full 12-column row on tablet+
sizes — they cap at 78% max-width and center themselves, so a
landscape image feels less like a wall and more like a plate inside
the layout. Stays full-bleed below 700px so it still feels right on
phones. Added margin-block to push neighbors away vertically.
2026-05-18 17:26:46 -07:00
Joel Brock
aabd08371c arrange: spacing scale + hero rhythm + opening beat
Targeted pass per impeccable:arrange — no markup changes, just sharper
spatial intent in the editorial base:

- New semantic spacing scale (--space-2xs through --space-3xl) at
  the top of :root. Subsequent rules pull from this scale instead of
  inline clamp() values; the scattered numbers stay only where they
  are type-specific (card padding, etc.).
- Hero internal rhythm rebalanced. Tagline keeps its generous gap
  (--space-xl), but bio now sits TIGHT against it (--space-sm) so
  they read as one thought, while the social rail jumps to
  --space-xl from the bio so it reads as a separate action layer.
  Previous mid-range 1.5/1.5/2rem values made everything feel
  equally connected.
- Opening beat: first .section after the hero (and the first child
  of <main>) gets --space-3xl top margin instead of --space-2xl, so
  the page actually breathes after the name before the masthead
  cadence settles in.
- Section masthead tightened: head gap nudged up, title weight
  350 -> 420 with a touch tighter tracking so Projects / Clients
  carry their poster weight.
- Marker bar pads asymmetrically now (--space-lg top, --space-md
  bottom) — typographic line of muted caps sits closer to the rule
  than to the page top, intentional.
2026-05-18 09:42:01 -07:00
Joel Brock
4ac4a9c3bb Spacing: client tile breathing room, testimonial nest rhythm
- .card--client gets 0.85rem of bottom padding so wrapping client
  names ("Perennial Promise Growers' Cooperative", "Common Good
  Management Services") no longer butt up against the bottom edge of
  the tile row.
- .section--headless top margin grows ~2x (clamp 1.25-2 -> 2.75-4.5)
  so a nested testimonial sits comfortably below the section it's
  attached to.
- New .section--headless + .section selector shrinks the next
  section's top margin so the testimonial's bottom whitespace
  doesn't double-pad before whatever comes after it.
2026-05-18 09:36:55 -07:00
Joel Brock
ff0abee349 Drop turtle, honor ?template= URL param, add warp easter egg
- Removed the cosmos turtle (CSS rules + auto-load probe + JS hook).
  Reduced-motion turtle branch removed too.
- ?template=editorial|swiss|cosmos URL param now wins over the value
  in data/links.json (and the cached localStorage fallback). Wired in
  both the pre-paint boot script and main() so deep links work
  without flashing the wrong template.
- New hidden easter egg: type the letters w-a-r-p anywhere on the
  page (no input focused) and the page reloads on the next template
  in the cycle editorial -> swiss -> cosmos -> editorial. The URL is
  updated with the new ?template= param so the override survives
  refresh and is shareable. A small "↯ <name>" toast pops up after
  the reload via sessionStorage handoff, fades after ~2.5s, respects
  prefers-reduced-motion.
- README documents both the URL param and the warp keyword.
2026-05-16 09:35:45 -07:00
Joel Brock
c16ee37096 Cosmos: aurora cursor, card tilt, comets, ripples, turtle; headless sections
Pushed the cosmos theme further:

- Cursor aurora — 600px soft cyan/violet/magenta halo follows the
  pointer with lag (mix-blend-mode: screen, blur 8px). Fades in on
  first move, fades out on pointerleave.
- Card 3D tilt — every cosmos card rotates up to ~6deg toward the
  cursor on hover via custom-property-driven rotateX/rotateY, with a
  cursor-tracking radial shimmer painted inside via mix-blend-mode:
  screen. RAF-throttled. Skipped for testimonial + client tiles since
  they have no chrome.
- Hero parallax — name, tagline, and drift-orb all shift relative to
  cursor position via --cm-x / --cm-y custom properties set globally
  from the pointer move handler.
- Shader: doubled the gravity-well strength (0.03 -> 0.06) and added
  a click-driven ripple — pointerdown sets u_ripple_pos/u_ripple_age
  uniforms; the shader propagates a cyan/magenta luminous ring of
  displacement that decays over ~2s.
- Comets — 3 CSS-only streaks crossing the page on staggered 14/22/
  19s loops with cyan/violet drop-shadow trails.
- Turtle — new .cosmos-turtle img auto-loads if assets/img/turtle.png
  resolves (or theme.turtle URL is set). Floats in from the left,
  arcs across, fades out off the right; pulses its aurora glow on a
  4s bob. Reduced-motion users see it positioned statically.
- All new chrome respects prefers-reduced-motion: halo/comets hidden,
  card tilt + parallax disabled, turtle pinned static.

Also adds section.headless = true (per user request): renders the
section body with no header/kicker chrome and a tighter top margin so
the contents read as nested under the prior section. Example JSON now
uses this to slide the testimonials carousel under the clients wall.
2026-05-16 09:26:11 -07:00
Joel Brock
4868111a14 Testimonials: blend into page, inline nav arrows, drop dots
User wanted the testimonial section to feel "of the page" rather than
a card sitting on top of it, and to occupy less vertical space.

- Strip all chrome from .card--testimonial: transparent background,
  no border, no shadow, no hover lift. Same treatment applied in the
  swiss + cosmos overrides so per-theme backgrounds don't sneak back.
- Restructure the carousel markup to put the prev/next buttons
  directly beside the viewport (carousel--inline = flex row), no
  separate controls block, no dot indicators.
- Shrink the curly quote mark and the avatar (44 -> 32px); tighten
  internal gaps. Removed the rule above the attribution row.
- Keep healthy horizontal padding via clamp() so the arrows don't
  crowd the surrounding sections.
- Update aria-label on the carousel itself instead of the dots so
  screen readers still get position context.
2026-05-16 09:18:41 -07:00
Joel Brock
b257d92636 Testimonials: center, scale by length, smoother fade
Center-align the testimonial card (text + attribution row), then
size the quote based on character count so short, punchy quotes get
the spotlight and long ones tighten down:

  is-xshort (<=90  chars)  3rem cap
  is-short  (<=180 chars)  2.25rem cap
  is-medium (<=320 chars)  1.55rem cap (the previous baseline)
  is-long   (<=520 chars)  1.20rem cap
  is-xlong  (>520  chars)  1.05rem cap

Counts use [...str] so grapheme-cluster emoji (skin-tone, ZWJ) count
as a single visual character instead of inflating the length.

Crossfade: drop the secondary translateY (it stuttered against
centered content), bump duration 600ms -> 1100ms, swap easing to
cubic-bezier(0.65, 0, 0.35, 1) for a softer in/out, and add
will-change:opacity so the browser keeps the layer on the GPU.
2026-05-15 17:33:29 -07:00
7 changed files with 485 additions and 72 deletions

View File

@@ -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

View File

@@ -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%),

View File

@@ -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); }
}

View File

@@ -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 {

View File

@@ -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">&ldquo;</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") {

View File

@@ -96,6 +96,7 @@
"label": "Testimonials",
"kicker": "What people say",
"layout": "testimonials",
"headless": true,
"items": [
{
"type": "testimonial",

View File

@@ -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>