From e1b3bc7d43451053c14aac585f18525f0a945da3 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Fri, 15 May 2026 17:12:13 -0700 Subject: [PATCH] Add testimonial item type with crossfade carousel layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- README.md | 45 ++++++++++++-- assets/css/cosmos.css | 51 ++++++++++++++++ assets/css/styles.css | 132 ++++++++++++++++++++++++++++++++++++++++ assets/css/swiss.css | 44 ++++++++++++++ assets/js/app.js | 112 +++++++++++++++++++++++++++++++--- data/links.example.json | 23 +++++++ 6 files changed, 393 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index c093235..808cd86 100644 --- a/README.md +++ b/README.md @@ -101,18 +101,21 @@ An ordered array of groups. Each section renders with a numbered masthead #### Layouts -| `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) | -| `"clients"` | Logo wall of clients/partners | Auto-flowing square tiles, `minmax(108–124px, 1fr)` | +| `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) | +| `"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 -has `type: "client"`. +has `type: "client"`. Same goes for `layout: "testimonials"` when the first +item is `type: "testimonial"`. ### Items 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) @@ -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). | | `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 `
` 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 Always spans the full row. Use for featured design pieces, case-study diff --git a/assets/css/cosmos.css b/assets/css/cosmos.css index cf945c6..4871098 100644 --- a/assets/css/cosmos.css +++ b/assets/css/cosmos.css @@ -570,6 +570,57 @@ 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 */ :root[data-template="cosmos"] ::selection { background: var(--accent); diff --git a/assets/css/styles.css b/assets/css/styles.css index 4eb9a15..d7a6597 100644 --- a/assets/css/styles.css +++ b/assets/css/styles.css @@ -477,6 +477,138 @@ body::before { } .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 */ .foot { margin-top: clamp(4rem, 8vw, 7rem); diff --git a/assets/css/swiss.css b/assets/css/swiss.css index ee6f363..125ba30 100644 --- a/assets/css/swiss.css +++ b/assets/css/swiss.css @@ -418,6 +418,50 @@ 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 */ :root[data-template="swiss"] ::selection { background: var(--accent); diff --git a/assets/js/app.js b/assets/js/app.js index 00eacc6..831ab85 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -110,18 +110,62 @@ `; } + 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}> + +
${esc(it.quote)}
+
+ ${avatar ? `` : ""} + + ${it.name ? `${esc(it.name)}` : ""} + ${meta ? `${esc(meta)}` : ""} + ${dateMarkup(it)} + +
+ `; + } + const renderItem = (it) => - it.type === "youtube" ? renderYouTube(it) : - it.type === "card" ? renderProject(it) : - it.type === "client" ? renderClient(it) : - it.type === "portfolio" ? renderPortfolio(it) : - renderLink(it); + it.type === "youtube" ? renderYouTube(it) : + it.type === "card" ? renderProject(it) : + it.type === "client" ? renderClient(it) : + it.type === "portfolio" ? renderPortfolio(it) : + it.type === "testimonial" ? renderTestimonial(it) : + renderLink(it); function renderSection(sec, n) { const items = (sec.items || []).map(renderItem).join(""); const num = String(n).padStart(2, "0"); - const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client"; - const gridClass = isClients ? "grid--clients" : "grid"; + const isClients = sec.layout === "clients" || sec.items?.[0]?.type === "client"; + 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) => + ``).join(""); + body = ` + `; + } else { + const gridClass = isClients ? "grid--clients" : "grid"; + body = `
${items}
`; + } return `
@@ -134,7 +178,7 @@ ${sec.kicker ? `${esc(sec.kicker)}` : ""}
-
${items}
+ ${body}
`; } @@ -411,6 +455,57 @@ 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) { if (!("IntersectionObserver" in window)) { $$(".reveal", root).forEach(el => el.classList.add("in")); @@ -494,6 +589,7 @@ attachFaviconFallback(app); attachYouTube(app); + attachCarousels(app); attachReveal(app); attachTheme(); } diff --git a/data/links.example.json b/data/links.example.json index b924ad2..158e1d8 100644 --- a/data/links.example.json +++ b/data/links.example.json @@ -90,6 +90,29 @@ { "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" } ] + }, + { + "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": [