Add testimonial item type with crossfade carousel layout
New `type: "testimonial"` with structured attribution (quote/name/role/org/url/image/date) plus a `layout: "testimonials"` section behavior that renders the items as a tasteful crossfade carousel — one quote at a time, 7s auto-advance, prev/next nav, dot indicators, swipe support, ←/→ keys, pause on hover/focus, ARIA live region. Reduced-motion users automatically get .carousel--stacked: every testimonial visible at once, controls hidden, no auto-advance. A single-item section also skips the carousel chrome. Per-template treatment: editorial uses Fraunces italic for the curly quote mark and the body, swiss strips uppercase from titles per prior fix, cosmos glows the mark with the cyan/violet accent stack. Section auto-detects the layout when the first item is a testimonial, matching how the clients layout already works.
This commit is contained in:
45
README.md
45
README.md
@@ -101,18 +101,21 @@ An ordered array of groups. Each section renders with a numbered masthead
|
|||||||
|
|
||||||
#### Layouts
|
#### Layouts
|
||||||
|
|
||||||
| `layout` | When to use | Grid behavior |
|
| `layout` | When to use | Grid behavior |
|
||||||
|--------------|----------------------------------------|----------------------------------------|
|
|------------------|----------------------------------------|----------------------------------------|
|
||||||
| *omitted* | Default bento mix | 12-column asymmetric grid (link cards span 4/12, projects/youtube span 6/12, featured spans 8/12, portfolio spans 12/12) |
|
| *omitted* | Default bento mix | 12-column asymmetric grid (link cards span 4/12, projects/youtube span 6/12, featured spans 8/12, portfolio spans 12/12) |
|
||||||
| `"clients"` | Logo wall of clients/partners | Auto-flowing square tiles, `minmax(108–124px, 1fr)` |
|
| `"clients"` | Logo wall of clients/partners | Auto-flowing square tiles, `minmax(108–124px, 1fr)` |
|
||||||
|
| `"testimonials"` | Quotes from clients / collaborators | Crossfade carousel — one quote at a time, auto-advancing every 7s, with prev/next nav, dot indicators, swipe + ←/→ keys, pause on hover/focus. Reduced-motion users see all quotes stacked. |
|
||||||
|
|
||||||
`layout: "clients"` is also auto-detected if the first item in the section
|
`layout: "clients"` is also auto-detected if the first item in the section
|
||||||
has `type: "client"`.
|
has `type: "client"`. Same goes for `layout: "testimonials"` when the first
|
||||||
|
item is `type: "testimonial"`.
|
||||||
|
|
||||||
### Items
|
### Items
|
||||||
|
|
||||||
Every item lives in a section's `items` array and has a `type` field. The
|
Every item lives in a section's `items` array and has a `type` field. The
|
||||||
five available types are documented below.
|
six available types are `link`, `card`, `youtube`, `client`, `portfolio`,
|
||||||
|
and `testimonial` — documented below.
|
||||||
|
|
||||||
#### Optional `date` (any item type)
|
#### Optional `date` (any item type)
|
||||||
|
|
||||||
@@ -229,6 +232,36 @@ below. Best in a section with `layout: "clients"`.
|
|||||||
| `image` | no | Custom logo. If omitted, an auto-favicon is used (rendered at 60% of the tile, contained). |
|
| `image` | no | Custom logo. If omitted, an auto-favicon is used (rendered at 60% of the tile, contained). |
|
||||||
| `icon` | no | Alias for `image`. |
|
| `icon` | no | Alias for `image`. |
|
||||||
|
|
||||||
|
#### `type: "testimonial"` — quote + attribution
|
||||||
|
|
||||||
|
Best paired with `layout: "testimonials"` (see above) to render as a
|
||||||
|
crossfade carousel. A section with a single testimonial just renders
|
||||||
|
the card statically.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "testimonial",
|
||||||
|
"quote": "Working with Joel transformed how our co-op thinks about technology.",
|
||||||
|
"name": "Jane Doe",
|
||||||
|
"role": "Executive Director",
|
||||||
|
"org": "Example Cooperative",
|
||||||
|
"url": "https://example.coop",
|
||||||
|
"image": "https://example.coop/avatars/jane.jpg",
|
||||||
|
"date": "2025-09"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| field | required? | notes |
|
||||||
|
|---------|-----------|-------|
|
||||||
|
| `quote` | yes | The quote body. Renders inside a `<blockquote>` with a large opening curly-quote mark above. |
|
||||||
|
| `name` | recommended | Person attributed. Bold below the quote. |
|
||||||
|
| `role` | no | Job title (e.g. "CEO"). Combined with `org` as `"Role, Org"`. |
|
||||||
|
| `org` | no | Company / organization. Combined with `role` if both present. |
|
||||||
|
| `url` | no | If present, the whole card becomes a link (opens in new tab). |
|
||||||
|
| `image` | no | Avatar image URL. Square-cropped to a 44px circle. If omitted but `url` is present, an auto-favicon is fetched as a tiny round mark instead. |
|
||||||
|
| `icon` | no | Alias for `image`. |
|
||||||
|
| `date` | no | Optional date. Format per the [date field](#optional-date-any-item-type). |
|
||||||
|
|
||||||
#### `type: "portfolio"` — wide design/showcase
|
#### `type: "portfolio"` — wide design/showcase
|
||||||
|
|
||||||
Always spans the full row. Use for featured design pieces, case-study
|
Always spans the full row. Use for featured design pieces, case-study
|
||||||
|
|||||||
@@ -570,6 +570,57 @@
|
|||||||
text-shadow: 0 0 16px color-mix(in oklch, var(--accent) 60%, transparent);
|
text-shadow: 0 0 16px color-mix(in oklch, var(--accent) 60%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───── testimonial / carousel ───── */
|
||||||
|
:root[data-template="cosmos"] .card--testimonial {
|
||||||
|
background: linear-gradient(135deg, rgba(30, 22, 64, 0.55), rgba(14, 8, 38, 0.55));
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .testimonial__mark {
|
||||||
|
color: var(--accent);
|
||||||
|
text-shadow:
|
||||||
|
0 0 18px color-mix(in oklch, var(--accent) 70%, transparent),
|
||||||
|
0 0 40px color-mix(in oklch, var(--accent-3) 50%, transparent);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .testimonial__quote {
|
||||||
|
font-family: var(--body);
|
||||||
|
font-weight: 400;
|
||||||
|
font-variation-settings: normal;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .testimonial__by { border-top-color: var(--rule); }
|
||||||
|
:root[data-template="cosmos"] .testimonial__name {
|
||||||
|
font-family: var(--display);
|
||||||
|
color: var(--ink);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .testimonial__meta {
|
||||||
|
font-family: var(--display);
|
||||||
|
letter-spacing: 0.10em;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .testimonial__avatar {
|
||||||
|
border-color: var(--rule);
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .carousel__nav {
|
||||||
|
background: rgba(20, 14, 50, 0.5);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-color: var(--rule);
|
||||||
|
color: var(--ink-2);
|
||||||
|
font-family: var(--display);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .carousel__nav:hover {
|
||||||
|
background: rgba(122, 247, 255, 0.12);
|
||||||
|
border-color: var(--accent);
|
||||||
|
color: var(--accent);
|
||||||
|
box-shadow: 0 0 18px color-mix(in oklch, var(--accent) 40%, transparent);
|
||||||
|
}
|
||||||
|
:root[data-template="cosmos"] .carousel__dot { background: var(--rule); }
|
||||||
|
:root[data-template="cosmos"] .carousel__dot.is-active {
|
||||||
|
background: var(--accent);
|
||||||
|
box-shadow: 0 0 10px var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
/* selection */
|
/* selection */
|
||||||
:root[data-template="cosmos"] ::selection {
|
:root[data-template="cosmos"] ::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
@@ -477,6 +477,138 @@ body::before {
|
|||||||
}
|
}
|
||||||
.yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
.yt iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }
|
||||||
|
|
||||||
|
/* ───── carousel (testimonials) ───── */
|
||||||
|
.carousel { position: relative; }
|
||||||
|
.carousel__viewport {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
}
|
||||||
|
.carousel__track {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas: "stack";
|
||||||
|
}
|
||||||
|
.carousel__track > * {
|
||||||
|
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;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.carousel--stacked .carousel__track {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.carousel--stacked .carousel__track > * {
|
||||||
|
opacity: 1; visibility: visible; transform: none; pointer-events: auto;
|
||||||
|
}
|
||||||
|
.carousel--stacked .carousel__controls { display: none; }
|
||||||
|
|
||||||
|
.carousel__controls {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.carousel__nav {
|
||||||
|
width: 38px; height: 38px;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--paper);
|
||||||
|
color: var(--ink-2);
|
||||||
|
font: 500 1.2rem/1 var(--display);
|
||||||
|
cursor: pointer;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
transition: border-color 200ms var(--ease), color 200ms var(--ease), background 200ms var(--ease);
|
||||||
|
}
|
||||||
|
.carousel__nav:hover { border-color: var(--ink); color: var(--ink); background: color-mix(in oklch, var(--ink) 4%, transparent); }
|
||||||
|
.carousel__dots { display: flex; gap: 0.5rem; align-items: center; }
|
||||||
|
.carousel__dot {
|
||||||
|
width: 8px; height: 8px;
|
||||||
|
border: 0; padding: 0;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--rule);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 220ms var(--ease), transform 220ms var(--ease);
|
||||||
|
}
|
||||||
|
.carousel__dot:hover { background: var(--ink-2); }
|
||||||
|
.carousel__dot.is-active { background: var(--accent); transform: scale(1.35); }
|
||||||
|
|
||||||
|
/* ───── testimonial card ───── */
|
||||||
|
.card--testimonial {
|
||||||
|
padding: clamp(1.75rem, 3vw, 2.75rem);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
gap: 1.5rem;
|
||||||
|
min-height: clamp(220px, 24vw, 300px);
|
||||||
|
}
|
||||||
|
.card--testimonial::after { display: none; }
|
||||||
|
.testimonial__mark {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: clamp(4rem, 6vw, 6rem);
|
||||||
|
line-height: 0.6;
|
||||||
|
color: var(--accent);
|
||||||
|
font-style: italic;
|
||||||
|
font-variation-settings: "opsz" 144, "SOFT" 100;
|
||||||
|
font-weight: 400;
|
||||||
|
align-self: start;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
.testimonial__quote {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-size: clamp(1.15rem, 0.95rem + 0.7vw, 1.45rem);
|
||||||
|
line-height: 1.45;
|
||||||
|
font-weight: 400;
|
||||||
|
font-variation-settings: "opsz" 60, "SOFT" 100;
|
||||||
|
color: var(--ink);
|
||||||
|
margin: 0;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
max-width: 60ch;
|
||||||
|
}
|
||||||
|
.testimonial__by {
|
||||||
|
display: flex; align-items: center; gap: 0.85rem;
|
||||||
|
margin-top: auto;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.testimonial__avatar {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 44px; height: 44px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--paper-2);
|
||||||
|
display: grid; place-items: center;
|
||||||
|
border: 1px solid var(--rule);
|
||||||
|
}
|
||||||
|
.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__name {
|
||||||
|
font-family: var(--body);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: var(--fs-sm);
|
||||||
|
letter-spacing: -0.005em;
|
||||||
|
}
|
||||||
|
.testimonial__meta {
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: var(--fs-mini);
|
||||||
|
color: var(--muted);
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
.testimonial__by .card__date {
|
||||||
|
font-size: var(--fs-mini);
|
||||||
|
margin-top: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* footer */
|
/* footer */
|
||||||
.foot {
|
.foot {
|
||||||
margin-top: clamp(4rem, 8vw, 7rem);
|
margin-top: clamp(4rem, 8vw, 7rem);
|
||||||
|
|||||||
@@ -418,6 +418,50 @@
|
|||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───── 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:hover .testimonial__quote,
|
||||||
|
:root[data-template="swiss"] .card--testimonial:hover .testimonial__name { color: var(--ink); }
|
||||||
|
:root[data-template="swiss"] .testimonial__mark {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 900;
|
||||||
|
font-variation-settings: normal;
|
||||||
|
color: var(--accent);
|
||||||
|
line-height: 0.7;
|
||||||
|
}
|
||||||
|
:root[data-template="swiss"] .testimonial__quote {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 500;
|
||||||
|
font-variation-settings: normal;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
}
|
||||||
|
:root[data-template="swiss"] .testimonial__by { border-top-color: var(--rule); }
|
||||||
|
:root[data-template="swiss"] .testimonial__name {
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
}
|
||||||
|
:root[data-template="swiss"] .testimonial__meta {
|
||||||
|
font-family: var(--display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
:root[data-template="swiss"] .testimonial__avatar { border-radius: 0; border: 0; }
|
||||||
|
:root[data-template="swiss"] .carousel__viewport { border-radius: 0; }
|
||||||
|
:root[data-template="swiss"] .carousel__nav {
|
||||||
|
border-radius: 0;
|
||||||
|
border-color: var(--rule);
|
||||||
|
font-family: var(--display);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
:root[data-template="swiss"] .carousel__nav:hover { background: var(--ink); color: var(--paper); border-color: var(--ink); }
|
||||||
|
:root[data-template="swiss"] .carousel__dot.is-active { background: var(--accent); }
|
||||||
|
|
||||||
/* selection */
|
/* selection */
|
||||||
:root[data-template="swiss"] ::selection {
|
:root[data-template="swiss"] ::selection {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
|
|||||||
112
assets/js/app.js
112
assets/js/app.js
@@ -110,18 +110,62 @@
|
|||||||
</${tag}>`;
|
</${tag}>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderTestimonial(it) {
|
||||||
|
const tag = it.url ? "a" : "div";
|
||||||
|
const attrs = it.url ? `href="${esc(it.url)}" target="_blank" rel="noopener noreferrer"` : "";
|
||||||
|
const avatar = it.image || it.icon || (it.url ? faviconFor(it.url) : "");
|
||||||
|
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(", ");
|
||||||
|
return `
|
||||||
|
<${tag} class="card card--testimonial reveal" ${attrs}>
|
||||||
|
<span class="testimonial__mark" aria-hidden="true">“</span>
|
||||||
|
<blockquote class="testimonial__quote">${esc(it.quote)}</blockquote>
|
||||||
|
<figcaption class="testimonial__by">
|
||||||
|
${avatar ? `<span class="testimonial__avatar"><img loading="lazy" alt="" src="${esc(avatar)}" data-fallback-initial="${esc(initial)}"${isFavicon ? ' class="is-favicon"' : ""}></span>` : ""}
|
||||||
|
<span class="testimonial__attr">
|
||||||
|
${it.name ? `<span class="testimonial__name">${esc(it.name)}</span>` : ""}
|
||||||
|
${meta ? `<span class="testimonial__meta">${esc(meta)}</span>` : ""}
|
||||||
|
${dateMarkup(it)}
|
||||||
|
</span>
|
||||||
|
</figcaption>
|
||||||
|
</${tag}>`;
|
||||||
|
}
|
||||||
|
|
||||||
const renderItem = (it) =>
|
const renderItem = (it) =>
|
||||||
it.type === "youtube" ? renderYouTube(it) :
|
it.type === "youtube" ? renderYouTube(it) :
|
||||||
it.type === "card" ? renderProject(it) :
|
it.type === "card" ? renderProject(it) :
|
||||||
it.type === "client" ? renderClient(it) :
|
it.type === "client" ? renderClient(it) :
|
||||||
it.type === "portfolio" ? renderPortfolio(it) :
|
it.type === "portfolio" ? renderPortfolio(it) :
|
||||||
renderLink(it);
|
it.type === "testimonial" ? renderTestimonial(it) :
|
||||||
|
renderLink(it);
|
||||||
|
|
||||||
function renderSection(sec, n) {
|
function renderSection(sec, n) {
|
||||||
const items = (sec.items || []).map(renderItem).join("");
|
const items = (sec.items || []).map(renderItem).join("");
|
||||||
const num = String(n).padStart(2, "0");
|
const num = String(n).padStart(2, "0");
|
||||||
const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client";
|
const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client";
|
||||||
const gridClass = isClients ? "grid--clients" : "grid";
|
const isTestimonials = sec.layout === "testimonials" || sec.items?.[0]?.type === "testimonial";
|
||||||
|
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__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>` : ""}
|
||||||
|
</div>`;
|
||||||
|
} else {
|
||||||
|
const gridClass = isClients ? "grid--clients" : "grid";
|
||||||
|
body = `<div class="${gridClass}">${items}</div>`;
|
||||||
|
}
|
||||||
return `
|
return `
|
||||||
<section class="section">
|
<section class="section">
|
||||||
<header class="section__head">
|
<header class="section__head">
|
||||||
@@ -134,7 +178,7 @@
|
|||||||
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
|
${sec.kicker ? `<span class="section__kicker">${esc(sec.kicker)}</span>` : ""}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="${gridClass}">${items}</div>
|
${body}
|
||||||
</section>`;
|
</section>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,6 +455,57 @@
|
|||||||
requestAnimationFrame(loop);
|
requestAnimationFrame(loop);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachCarousels(root) {
|
||||||
|
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||||
|
$$("[data-carousel]", root).forEach((car) => {
|
||||||
|
const slides = $$(".carousel__track > *", car);
|
||||||
|
const dots = $$(".carousel__dot", car);
|
||||||
|
if (slides.length <= 1) { slides.forEach((s) => s.classList.add("is-active")); return; }
|
||||||
|
if (reduced) { car.classList.add("carousel--stacked"); slides.forEach((s) => s.classList.add("is-active")); return; }
|
||||||
|
|
||||||
|
let i = 0;
|
||||||
|
const show = (n) => {
|
||||||
|
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));
|
||||||
|
};
|
||||||
|
show(0);
|
||||||
|
|
||||||
|
let timer = 0;
|
||||||
|
const ADVANCE_MS = 7000;
|
||||||
|
const play = () => { stop(); timer = setInterval(() => show(i + 1), ADVANCE_MS); };
|
||||||
|
const stop = () => { if (timer) { clearInterval(timer); timer = 0; } };
|
||||||
|
|
||||||
|
car.addEventListener("pointerenter", stop);
|
||||||
|
car.addEventListener("pointerleave", play);
|
||||||
|
car.addEventListener("focusin", stop);
|
||||||
|
car.addEventListener("focusout", play);
|
||||||
|
document.addEventListener("visibilitychange", () => { document.hidden ? stop() : play(); });
|
||||||
|
|
||||||
|
$(".carousel__nav--prev", car)?.addEventListener("click", () => { show(i - 1); play(); });
|
||||||
|
$(".carousel__nav--next", car)?.addEventListener("click", () => { show(i + 1); play(); });
|
||||||
|
dots.forEach((d) => d.addEventListener("click", () => { show(Number(d.dataset.slide)); play(); }));
|
||||||
|
|
||||||
|
car.tabIndex = 0;
|
||||||
|
car.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "ArrowLeft") { e.preventDefault(); show(i - 1); play(); }
|
||||||
|
if (e.key === "ArrowRight") { e.preventDefault(); show(i + 1); play(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
let touchX = null;
|
||||||
|
car.addEventListener("touchstart", (e) => { touchX = e.changedTouches[0].clientX; }, { passive: true });
|
||||||
|
car.addEventListener("touchend", (e) => {
|
||||||
|
if (touchX === null) return;
|
||||||
|
const dx = e.changedTouches[0].clientX - touchX;
|
||||||
|
if (Math.abs(dx) > 40) show(dx < 0 ? i + 1 : i - 1);
|
||||||
|
touchX = null;
|
||||||
|
play();
|
||||||
|
});
|
||||||
|
|
||||||
|
play();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function attachReveal(root) {
|
function attachReveal(root) {
|
||||||
if (!("IntersectionObserver" in window)) {
|
if (!("IntersectionObserver" in window)) {
|
||||||
$$(".reveal", root).forEach(el => el.classList.add("in"));
|
$$(".reveal", root).forEach(el => el.classList.add("in"));
|
||||||
@@ -494,6 +589,7 @@
|
|||||||
|
|
||||||
attachFaviconFallback(app);
|
attachFaviconFallback(app);
|
||||||
attachYouTube(app);
|
attachYouTube(app);
|
||||||
|
attachCarousels(app);
|
||||||
attachReveal(app);
|
attachReveal(app);
|
||||||
attachTheme();
|
attachTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,29 @@
|
|||||||
{ "type": "link", "title": "An essay title goes here", "url": "#", "description": "Essay · 8 min read" },
|
{ "type": "link", "title": "An essay title goes here", "url": "#", "description": "Essay · 8 min read" },
|
||||||
{ "type": "link", "title": "Another piece of writing", "url": "#", "description": "Field notes" }
|
{ "type": "link", "title": "Another piece of writing", "url": "#", "description": "Field notes" }
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "praise",
|
||||||
|
"label": "Testimonials",
|
||||||
|
"kicker": "What people say",
|
||||||
|
"layout": "testimonials",
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"type": "testimonial",
|
||||||
|
"quote": "Ada's notes on the Engine are the most penetrating remarks anyone has yet made on the subject of mechanical computation.",
|
||||||
|
"name": "Charles Babbage",
|
||||||
|
"role": "Mathematician",
|
||||||
|
"org": "Royal Society",
|
||||||
|
"date": "1843"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "testimonial",
|
||||||
|
"quote": "She has thrown her thoughts into the form of a mathematical poem — an analytical instrument set in motion by the human mind.",
|
||||||
|
"name": "Michael Faraday",
|
||||||
|
"role": "Natural Philosopher",
|
||||||
|
"url": "https://example.com/faraday"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"social": [
|
"social": [
|
||||||
|
|||||||
Reference in New Issue
Block a user