v1.5 - history and timeline as well as many datastructure updates added, polished, fixes

This commit is contained in:
Zaldimmar 2026-09-25 02:38:51 -05:00
parent 1f0aa3078f
commit 1d84400aef
63 changed files with 7927 additions and 208 deletions

View file

@ -1,5 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { splitByStatus, useEvents } from "../../data/eventData.js";
import { useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { splitByStatus, typesPresent, useEvents } from "../../data/eventData.js";
import { eventHref } from "../../lib/hrefs.ts";
import { EVENT_TYPES, eventTypeLabel } from "../../lib/eventTypes.ts";
/* ═══════════════════════════════════════════════════════════════
EVENT LIST — CARDS
@ -11,8 +14,14 @@ import { splitByStatus, useEvents } from "../../data/eventData.js";
<EventListCards section="national" view="carousel" />
<EventListCards host="northwest" view="grid" />
<EventListCards type={["class", "workshop"]} view="grid" />
`view` and `accent` come from the page's section manifest.
`type` pre-filters the band the way `section` and `host` do. Left
off, the band takes every kind it finds and grows a row of chips
to narrow by — but only once it holds more than one, so a band of
nothing but retreats shows no control at all.
═══════════════════════════════════════════════════════════════ */
const LOGO_FILES = import.meta.glob("../../assets/event-logos/*.svg", {
@ -105,6 +114,23 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
</svg>
);
/* The title is the way into the event's own page.
A link on the title rather than a wrapper around the whole card:
the footer already holds anchors, and an anchor inside an anchor
is invalid markup that every browser resolves by guessing. The
carousel has the same constraint — it needs the card's click to
mean "bring this one to the front" on anything that isn't the
active slide. */
function TitleLink({ ev, linked, children }) {
if (!linked) return <>{children}</>;
return (
<Link to={eventHref(ev.id)} className="hover:underline underline-offset-4">
{children}
</Link>
);
}
/* ═══════════════════════════════════════════════════════════════
EVENT CARD — one component, two sizes.
compact=false → the full card used in the carousel
@ -119,6 +145,9 @@ const InstagramIcon = ({ id = "ig-gradient" }) => (
Fields arrive pre-resolved from the API — `color` is the event's
own or its host's, `status` is derived from the dates when it
isn't set — so nothing here reimplements those rules.
`linked` is the one thing a caller turns off: a card on the
event's own page shouldn't link to the page it's already on.
═══════════════════════════════════════════════════════════════ */
export function Card({
ev,
@ -126,6 +155,8 @@ export function Card({
accent = TEAL,
compact = false,
interactive = true,
linked = true,
showType = false,
}) {
const past = ev.status === "past";
const color = ev.color || defaultColor;
@ -137,6 +168,19 @@ export function Card({
? `https://instagram.com/${igHandle.replace(/^@/, "")}`
: null;
/* Off unless the caller says the band is mixed. A "Retreat" badge
on every card in a row of nothing but retreats is noise, and
the card can't tell on its own — it only ever sees one event. */
const typeBadge =
showType && ev.event_type ? (
<span
className="inline-block rounded-full px-3 py-0.5 mb-2 text-xs font-700 uppercase tracking-wide"
style={{ border: `1px solid ${color}`, color }}
>
{eventTypeLabel(ev.event_type)}
</span>
) : null;
/* An ordered array, so a card can carry one paragraph or five
without the component changing. */
const descriptions = (ev.description ?? []).map((text, i) => (
@ -168,7 +212,12 @@ export function Card({
<div className="ev-grid mb-2">
<Logo file={orgLogo} className="ev-ngu h-11 w-auto mb-4" />
<div className="ev-info">
<h3 className="text-3xl font-900 leading-tight">{ev.title}</h3>
{typeBadge}
<h3 className="text-3xl font-900 leading-tight">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && (
<p className="text-xl font-300 font-bold">"{ev.theme}"</p>
)}
@ -190,7 +239,12 @@ export function Card({
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-8 mb-4">
<div className="md:col-span-2">
<Logo file={orgLogo} className="h-15 w-auto mb-6" />
<h3 className="text-4xl font-900">{ev.title}</h3>
{typeBadge}
<h3 className="text-4xl font-900">
<TitleLink ev={ev} linked={linked}>
{ev.title}
</TitleLink>
</h3>
{ev.theme && (
<p className="text-2xl font-300 font-bold">"{ev.theme}"</p>
)}
@ -211,10 +265,32 @@ export function Card({
</>
)}
{/* Footer — mt-auto pins it to the bottom so buttons line up
across every card in a grid row. */}
{links.length > 0 ? (
<div className={`flex flex-wrap justify-center gap-2 mt-auto ${compact ? "pt-6" : "pt-8 gap-3"}`}>
{/* Footer — mt-auto pins the whole block to the bottom so
buttons line up across every card in a grid row.
Three registration states, as before: links to follow, a
past event, or an announcement still to come. What's new
is that all three end in the same row, because every
event now has a page and a past one is often the more
worth reading — speakers, awards, what actually
happened. The notice is what changes; the way in
doesn't. */}
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
{links.length === 0 && (
<p className="text-center font-600" style={{ color: accent }}>
{past
? "This event has concluded — thank you to everyone who joined us!"
: igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
)}
<div
className={`flex flex-wrap justify-center items-center ${
compact ? "gap-2" : "gap-3"
} ${links.length === 0 ? "mt-4" : ""}`}
>
{links.map(item => (
<a
key={item.label}
@ -227,28 +303,17 @@ export function Card({
{item.label}
</a>
))}
</div>
) : past ? (
<p
className="mt-auto text-center font-600 pt-8"
style={{ color: accent }}
>
This event has concluded — thank you to everyone who joined us!
</p>
) : (
<div className={`mt-auto ${compact ? "pt-6" : "pt-8"}`}>
<p className="text-center font-600" style={{ color: accent }}>
{igHandle
? "Registration has not opened yet, follow our instagram for more details."
: "Registration has not opened yet — check back soon for more details."}
</p>
{igHandle && (
{/* Only when there's nothing to register for and the
event hasn't happened — the same condition as before,
just no longer nested inside that branch. */}
{igHandle && !past && links.length === 0 && (
<a
href={igUrl}
target="_blank"
rel="noopener noreferrer"
className={`ig-link mt-4 w-fit mx-auto flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] ${
compact ? "gap-3 py-2.5 px-5" : "gap-3 py-3 px-6"
className={`ig-link flex items-center justify-center rounded-xl font-700 transition-all duration-200 hover:scale-[1.02] gap-3 ${
compact ? "py-2.5 px-5" : "py-3 px-6"
}`}
style={{ border: `1px solid ${accent}`, color: accent }}
>
@ -256,13 +321,70 @@ export function Card({
{igHandle}
</a>
)}
{/* Last, so Register reads first when there is one. */}
{linked && (
<Link
to={eventHref(ev.id)}
className={`rounded-xl font-700 transition-all duration-200 hover:scale-105 text-center ${
compact ? "py-2.5 px-5" : "py-2.5 px-6"
}`}
style={{ border: `1px solid ${color}` }}
>
Event details
</Link>
)}
</div>
)}
</div>
</div>
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
TYPE FILTER
Drawn only when a band actually holds more than one kind, so it
costs nothing today — every event is a retreat — and appears on
its own the first time a class or a workshop lands in that band.
Nothing on Retreats.tsx has to be reconfigured for it.
Chips rather than a select: with four or five options, all of
them visible is one tap, and the row reads as what the section
contains rather than as a form control.
═══════════════════════════════════════════════════════════════ */
export function TypeFilter({ types, active, setActive, accent }) {
const chip = on => ({
border: `1px solid ${accent}`,
background: on ? accent : "transparent",
color: on ? "#ffffff" : accent,
});
const button = (id, label) => (
<button
key={id}
onClick={() => setActive(id)}
aria-pressed={active === id}
className="rounded-full py-1.5 px-4 text-sm font-700 transition-colors duration-200"
style={chip(active === id)}
>
{label}
</button>
);
return (
<div
className="mx-auto mb-6 flex flex-wrap gap-2 px-8 md:px-12 lg:px-16"
style={{ maxWidth: GRID_MAX }}
role="group"
aria-label="Filter events by type"
>
{button("all", "All")}
{types.map(entry => button(entry.id, entry.plural))}
</div>
);
}
/* ═══════════════════════════════════════════════════════════════
TOGGLE — the control for the section heading's action bar
═══════════════════════════════════════════════════════════════ */
@ -323,22 +445,57 @@ export default function EventListCards({
section,
host,
status,
type,
view = "carousel",
accent = TEAL,
defaultColor,
empty = "· Events coming soon, stay connected for announcements ·",
}) {
const { events, loading, error } = useEvents({ section, host, status });
const { events: fetched, loading, error } = useEvents({
section,
host,
status,
type,
});
const cardColor = defaultColor ?? accent;
const [index, setIndex] = useState(0);
const [showPast, setShowPast] = useState(false);
const [activeType, setActiveType] = useState("all");
/* What this band holds, which is what the chips offer — not the
full list of declared types, three quarters of which would be
dead buttons. */
const availableTypes = useMemo(
() => typesPresent(fetched, EVENT_TYPES),
[fetched],
);
const mixed = availableTypes.length > 1;
const events = useMemo(
() =>
activeType === "all"
? fetched
: fetched.filter(e => e.event_type === activeType),
[fetched, activeType],
);
/* Open on the first upcoming event. The list is empty on the
first render, so this can't be a useState initialiser — it has
to wait for the data and then run once. Clearing the guard when
the list empties means a refetch re-seeds. */
const seeded = useRef(false);
/* Changing the chip is a different list, so the carousel re-seeds
on the first upcoming event of that kind rather than holding an
index that may now be past the end. Declared before the seed
effect so the guard is already clear when it runs. */
useEffect(() => {
seeded.current = false;
setIndex(0);
}, [activeType]);
useEffect(() => {
if (events.length === 0) {
seeded.current = false;
@ -395,8 +552,24 @@ export default function EventListCards({
return (
<div className="overflow-hidden">
{mixed && (
<TypeFilter
types={availableTypes}
active={activeType}
setActive={setActiveType}
accent={accent}
/>
)}
{events.length === 0 ? (
notice(empty)
/* Two different empties. Nothing scheduled is news; nothing
of the kind you just picked is a filter you can undo, and
the chips are still on screen to undo it with. */
notice(
activeType === "all"
? empty
: `· No ${eventTypeLabel(activeType).toLowerCase()} events in this section yet ·`,
)
) : view === "grid" ? (
/* ── GRID VIEW — upcoming first, past events collapsed below ── */
<div className="mx-auto px-8 md:px-12 lg:px-16" style={{ maxWidth: GRID_MAX }}>
@ -412,6 +585,7 @@ export default function EventListCards({
defaultColor={cardColor}
accent={accent}
compact
showType={mixed}
/>
))}
</div>
@ -450,6 +624,7 @@ export default function EventListCards({
accent={accent}
compact
interactive={showPast}
showType={mixed}
/>
))}
</div>
@ -514,6 +689,7 @@ export default function EventListCards({
defaultColor={cardColor}
accent={accent}
interactive={active}
showType={mixed}
/>
</div>
);

View file

@ -0,0 +1,142 @@
import { useCallback, useMemo } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
defaultOpenYears,
groupTimeline,
groupYears,
partitionByDate,
type DecadeMeta,
type SortDirection,
type TimelineItem,
} from '../../../lib/timeline'
import TimelineDecade from './TimelineDecade'
import TimelineUpcoming from './TimelineUpcoming'
import './timeline.css'
const PARAM = 'y'
type Props = {
items: TimelineItem[]
decades: DecadeMeta[]
direction?: SortDirection
/** Decades ending before this year get the pre-program treatment. */
programStartYear?: number
fillGaps?: boolean
/** Injectable for tests and for pinning a date in a screenshot. */
now?: Date
}
export default function HistoryTimeline({
items,
decades,
direction = 'desc',
programStartYear = 2002,
fillGaps = true,
now,
}: Props) {
const [searchParams, setSearchParams] = useSearchParams()
// Upcoming vs recorded is a function of the clock, so an event crosses
// over on its own date with nothing to flip.
const { upcoming, past } = useMemo(
() => partitionByDate(items, now ?? new Date()),
[items, now],
)
const upcomingYears = useMemo(
() => groupYears(upcoming, { direction }),
[upcoming, direction],
)
const grouped = useMemo(
() => groupTimeline(past, decades, { direction, programStartYear }),
[past, decades, direction, programStartYear],
)
// `?y=2014,2022` deep-links straight to open years. Absence of the
// param means "not chosen yet", so fall back to the most recent
// featured year; an empty param means everything was collapsed on
// purpose.
const openYears = useMemo(() => {
if (!searchParams.has(PARAM)) return new Set(defaultOpenYears(grouped))
const raw = searchParams.get(PARAM) ?? ''
return new Set(
raw
.split(',')
.map((part) => Number(part.trim()))
.filter((n) => Number.isInteger(n)),
)
}, [searchParams, grouped])
const commit = useCallback(
(next: Set<number>) => {
const params = new URLSearchParams(searchParams)
params.set(PARAM, [...next].sort((a, b) => b - a).join(','))
// replace: toggling shouldn't fill the back button with history.
setSearchParams(params, { replace: true })
},
[searchParams, setSearchParams],
)
const toggleYear = useCallback(
(year: number) => {
const next = new Set(openYears)
if (next.has(year)) next.delete(year)
else next.add(year)
commit(next)
},
[openYears, commit],
)
const allYears = useMemo(() => {
const years = grouped.flatMap((decade) =>
decade.years.filter((y) => y.count > 0).map((y) => y.year),
)
return [...years, ...upcomingYears.map((y) => y.year)]
}, [grouped, upcomingYears])
const allOpen = allYears.length > 0 && allYears.every((y) => openYears.has(y))
if (grouped.length === 0 && upcomingYears.length === 0) {
return (
<div className="ngu-tl">
<p className="ngu-tl__emptyState">
The timeline is empty. Add a milestone to start the record.
</p>
</div>
)
}
return (
<div className="ngu-tl">
<div className="ngu-tl__toolbar">
<button
type="button"
className="ngu-tl__toolbarBtn"
onClick={() => commit(allOpen ? new Set() : new Set(allYears))}
>
{allOpen ? 'Collapse all years' : 'Expand all years'}
</button>
</div>
<TimelineUpcoming
years={upcomingYears}
count={upcoming.length}
direction={direction}
openYears={openYears}
onToggleYear={toggleYear}
/>
{grouped.map((decade) => (
<TimelineDecade
key={decade.decade}
decade={decade}
openYears={openYears}
onToggleYear={toggleYear}
direction={direction}
fillGaps={fillGaps}
/>
))}
</div>
)
}

View file

@ -0,0 +1,64 @@
import {
decadeLabel,
withGapYears,
type GroupedDecade,
type SortDirection,
} from '../../../lib/timeline'
import TimelineYear from './TimelineYear'
type Props = {
decade: GroupedDecade
openYears: Set<number>
onToggleYear: (year: number) => void
direction?: SortDirection
/** Show years with no records as muted nodes, so gaps stay visible. */
fillGaps?: boolean
}
export default function TimelineDecade({
decade,
openYears,
onToggleYear,
direction = 'desc',
fillGaps = true,
}: Props) {
const years = fillGaps ? withGapYears(decade.years, direction) : decade.years
return (
<section
className={`ngu-tl__decade${decade.preProgram ? ' ngu-tl__decade--pre' : ''}`}
aria-labelledby={`ngu-tl-decade-${decade.decade}`}
>
<header className="ngu-tl__decadeHead">
<span className="ngu-tl__decadeNum" aria-hidden="true">
{decadeLabel(decade.decade)}
</span>
<div>
<h3 className="ngu-tl__decadeTitle" id={`ngu-tl-decade-${decade.decade}`}>
<span className="ngu-tl__srOnly">{decadeLabel(decade.decade)}: </span>
{decade.title}
</h3>
{decade.tagline ? (
<p className="ngu-tl__decadeTagline">{decade.tagline}</p>
) : null}
{decade.blurb ? <p className="ngu-tl__decadeBlurb">{decade.blurb}</p> : null}
{decade.preProgram ? (
<p className="ngu-tl__gapNote">
<span aria-hidden="true">◌</span>
Before the program — kept for context, not part of our record
</p>
) : null}
</div>
</header>
{years.map((year) => (
<TimelineYear
key={year.year}
year={year}
open={openYears.has(year.year)}
onToggle={onToggleYear}
/>
))}
</section>
)
}

View file

@ -0,0 +1,155 @@
import { Link } from 'react-router-dom'
import { MONTH_LABELS, type PersonRef, type TimelineItem } from '../../../lib/timeline'
import { hrefFor, logoSrc, photoSrc } from '../../../lib/timelineRefs'
const KIND_NAMES: Record<TimelineItem['kind'], string> = {
milestone: 'Milestone',
event: 'Event',
organization: 'Organization',
award: 'Award',
people: 'People',
}
/** Day number only — the month node above already carries the month. */
function dayMarker(item: TimelineItem): string {
if (item.precision !== 'day') return ''
const day = Number(item.date.split('-')[2])
return Number.isFinite(day) ? String(day) : ''
}
/** Featured entries sit above the month nodes, so they spell out their date. */
function fullMarker(item: TimelineItem): string {
const [, m, d] = item.date.split('-')
if (item.precision === 'year' || !m) return 'Date unrecorded'
const month = MONTH_LABELS[Number(m) - 1] ?? ''
if (item.precision === 'month' || !d) return month
return `${month} ${Number(d)}`
}
/** Initials stand in when a person has no photo on file. */
function initials(name: string): string {
return name
.split(/\s+/)
.slice(0, 2)
.map((part) => part[0] ?? '')
.join('')
.toUpperCase()
}
function PersonChip({ person }: { person: PersonRef }) {
const src = photoSrc(person.photo)
return (
<li className="ngu-tl__person">
{src ? (
<img className="ngu-tl__personPhoto" src={src} alt="" loading="lazy" />
) : (
<span className="ngu-tl__personPhoto ngu-tl__personPhoto--blank" aria-hidden="true">
{initials(person.name)}
</span>
)}
<span className="ngu-tl__personName">
{person.name}
{person.title ? (
<span className="ngu-tl__personTitle">{person.title}</span>
) : null}
</span>
</li>
)
}
type Props = {
item: TimelineItem
/** Featured entries render larger and lead the year. */
variant?: 'inline' | 'featured'
}
export default function TimelineEntry({ item, variant = 'inline' }: Props) {
const featured = variant === 'featured'
const href = hrefFor(item)
const logo = logoSrc(item)
const className = [
'ngu-tl__entry',
featured ? 'ngu-tl__entry--featured' : '',
href ? 'ngu-tl__entry--linked' : '',
]
.filter(Boolean)
.join(' ')
const marker = (
<span className="ngu-tl__entryDay">
{featured ? fullMarker(item) : dayMarker(item)}
</span>
)
const body = (
<span className="ngu-tl__entryBody">
<span className="ngu-tl__entryTitle">
{logo ? (
<img className="ngu-tl__logo" src={logo} alt="" loading="lazy" />
) : (
<span
className={`ngu-tl__kind ngu-tl__kind--${item.kind}`}
aria-hidden="true"
/>
)}
{item.title}
</span>
{item.meta ? <span className="ngu-tl__entryMeta">{item.meta}</span> : null}
{item.blurb ? <p className="ngu-tl__entryBlurb">{item.blurb}</p> : null}
</span>
)
// The people list sits outside the link: each name is its own
// destination, and nesting anchors is invalid markup anyway.
const roster =
item.kind === 'people' && item.people && item.people.length > 0 ? (
<div
className={`ngu-tl__roster${featured ? ' ngu-tl__roster--featured' : ''}`}
>
{item.team ? (
<span className="ngu-tl__rosterTeam">
{item.team.name}
{item.team.orgName ? ` · ${item.team.orgName}` : ''}
</span>
) : null}
<ul className="ngu-tl__people">
{item.people.map((person) => (
<PersonChip key={person.id} person={person} />
))}
</ul>
</div>
) : null
const label = `${KIND_NAMES[item.kind]}: ${item.title}`
// An explicit href may point off-site; Link is for in-app routes only.
const external = !!href && /^(https?:)?\/\//.test(href)
return (
<li className="ngu-tl__entryWrap">
{href && external ? (
<a
href={href}
className={className}
aria-label={label}
target="_blank"
rel="noopener noreferrer"
>
{marker}
{body}
</a>
) : href ? (
<Link to={href} className={className} aria-label={label}>
{marker}
{body}
</Link>
) : (
<div className={className}>
{marker}
{body}
</div>
)}
{roster}
</li>
)
}

View file

@ -0,0 +1,69 @@
import { useState } from 'react'
import type { GroupedYear, SortDirection } from '../../../lib/timeline'
import TimelineYear from './TimelineYear'
type Props = {
years: GroupedYear[]
count: number
direction?: SortDirection
openYears: Set<number>
onToggleYear: (year: number) => void
}
/**
* Scheduled but not yet happened. Sits above the most recent decade and
* opens upward: the toggle is first in the DOM and the list is rendered
* after it, with `flex-direction: column-reverse` flipping the visual
* order. That keeps the button anchored next to the decade marker
* instead of drifting up the page as items appear.
*
* Membership is decided by date, not by a flag — see partitionByDate.
* An event moves into the record on its own, with nothing to update.
*/
export default function TimelineUpcoming({
years,
count,
direction = 'desc',
openYears,
onToggleYear,
}: Props) {
const [open, setOpen] = useState(false)
if (count === 0) return null
return (
<section
className={`ngu-tl__upcoming${open ? ' ngu-tl__upcoming--open' : ''}`}
aria-label="Upcoming"
>
<button
type="button"
className="ngu-tl__upcomingBtn"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<span className="ngu-tl__upcomingDot" aria-hidden="true" />
<span className="ngu-tl__upcomingLabel">
{open
? 'Hide what’s scheduled'
: `${count} ${count === 1 ? 'thing' : 'things'} still to come`}
</span>
<span className="ngu-tl__chevron" aria-hidden="true">
{open ? '▾' : '▴'}
</span>
</button>
{open ? (
<div className="ngu-tl__upcomingList">
{years.map((year) => (
<TimelineYear
key={year.year}
year={year}
open={openYears.has(year.year)}
onToggle={onToggleYear}
/>
))}
</div>
) : null}
</section>
)
}

View file

@ -0,0 +1,90 @@
import type { CSSProperties } from 'react'
import type { GroupedYear } from '../../../lib/timeline'
import TimelineEntry from './TimelineEntry'
/** Dot diameter scales with volume, so the rail reads as a density chart. */
function dotSize(count: number): string {
return `${(7 + Math.min(count, 14) * 0.55).toFixed(2)}px`
}
type Props = {
year: GroupedYear
open: boolean
onToggle: (year: number) => void
}
export default function TimelineYear({ year, open, onToggle }: Props) {
const empty = year.count === 0
const panelId = `ngu-tl-year-${year.year}`
const className = [
'ngu-tl__year',
year.featured ? 'ngu-tl__year--featured' : '',
open ? 'ngu-tl__year--open' : '',
empty ? 'ngu-tl__year--empty' : '',
]
.filter(Boolean)
.join(' ')
return (
<div className={className}>
<button
type="button"
className="ngu-tl__yearBtn"
aria-expanded={open}
aria-controls={panelId}
onClick={() => onToggle(year.year)}
>
<span
className="ngu-tl__dot"
style={{ '--tl-dot': dotSize(year.count) } as CSSProperties}
aria-hidden="true"
/>
<span className="ngu-tl__yearLabel">{year.year}</span>
<span className="ngu-tl__yearCount">
{empty ? '—' : `${year.count} ${year.count === 1 ? 'entry' : 'entries'}`}
</span>
</button>
{open ? (
empty ? (
<div className="ngu-tl__empty" id={panelId}>
Nothing recorded for {year.year} yet.
</div>
) : (
<div className="ngu-tl__panel" id={panelId}>
{year.featuredItems.length > 0 ? (
<ul className="ngu-tl__featured ngu-tl__list">
{year.featuredItems.map((item) => (
<TimelineEntry key={item.id} item={item} variant="featured" />
))}
</ul>
) : null}
{year.months.map((month) => (
<section className="ngu-tl__month" key={month.month}>
<h4 className="ngu-tl__monthLabel">{month.label}</h4>
<ul className="ngu-tl__list">
{month.items.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</ul>
</section>
))}
{year.undated.length > 0 ? (
<section className="ngu-tl__month ngu-tl__undated">
<h4 className="ngu-tl__monthLabel">Elsewhere in {year.year}</h4>
<ul className="ngu-tl__list">
{year.undated.map((item) => (
<TimelineEntry key={item.id} item={item} />
))}
</ul>
</section>
) : null}
</div>
)
) : null}
</div>
)
}

View file

@ -0,0 +1,663 @@
/**
* History timeline — scoped under .ngu-tl.
*
* Everything themeable is a custom property with a fallback, so rewiring to the
* site tokens is a one-block edit at the top. Typefaces are deliberately not
* set: the timeline inherits the page's type and only controls scale, weight
* and tracking.
*/
.ngu-tl {
/* Defaults match the site palette; History.tsx overrides accent and
surface per section so the rail sits on the right background. */
--tl-ink: #16343a;
--tl-muted: #4a6b72;
--tl-faint: #8aa7ad;
--tl-rail: #cfe4e8;
--tl-surface: #ffffff;
--tl-accent: #138ba0;
--tl-accent-soft: color-mix(in srgb, var(--tl-accent) 12%, transparent);
--tl-gutter: 5rem;
--tl-rail-x: 1.75rem;
--tl-rail-w: 2px;
color: var(--tl-ink);
}
@media (max-width: 640px) {
.ngu-tl {
--tl-gutter: 3rem;
--tl-rail-x: 1rem;
}
}
/* ——— Decade block ——————————————————————————————————————————————— */
.ngu-tl__decade {
position: relative;
}
/* The rail. One per decade block so the pre-program run can change style
without breaking the line's continuity. */
.ngu-tl__decade::before {
content: '';
position: absolute;
top: 0;
bottom: 0;
left: var(--tl-rail-x);
width: var(--tl-rail-w);
background: var(--tl-rail);
}
.ngu-tl__decade--pre::before {
background: none;
border-left: var(--tl-rail-w) dashed var(--tl-rail);
opacity: 0.7;
}
/* Last decade fades out rather than stopping dead. */
.ngu-tl__decade:last-child::before {
bottom: 2rem;
mask-image: linear-gradient(to bottom, #000 60%, transparent);
}
.ngu-tl__decade--pre {
color: var(--tl-muted);
}
/* ——— Decade header ——————————————————————————————————————————————— */
.ngu-tl__decadeHead {
position: relative;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr);
align-items: start;
padding-block: 3.5rem 1.75rem;
}
.ngu-tl__decade:first-child .ngu-tl__decadeHead {
padding-block-start: 0.5rem;
}
/* Numeral breaks the rail. */
.ngu-tl__decadeNum {
position: relative;
z-index: 1;
margin-left: calc(var(--tl-rail-x) * -1 + 0.125rem);
padding-block: 0.35rem;
background: var(--tl-surface);
font-size: 0.9375rem;
font-weight: 650;
font-variant-numeric: tabular-nums;
letter-spacing: 0.01em;
line-height: 1.1;
writing-mode: vertical-rl;
text-orientation: sideways;
color: var(--tl-accent);
}
.ngu-tl__decade--pre .ngu-tl__decadeNum {
color: var(--tl-faint);
}
.ngu-tl__decadeTitle {
font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2.125rem);
font-weight: 600;
line-height: 1.12;
letter-spacing: -0.018em;
margin: 0;
max-width: 22ch;
}
.ngu-tl__decadeTagline {
margin: 0.5rem 0 0;
font-size: 1.0625rem;
line-height: 1.5;
color: var(--tl-muted);
max-width: 54ch;
}
.ngu-tl__decadeBlurb {
margin: 0.875rem 0 0;
font-size: 0.9375rem;
line-height: 1.65;
color: var(--tl-muted);
max-width: 62ch;
}
.ngu-tl__gapNote {
margin: 1rem 0 0;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px dashed var(--tl-rail);
border-radius: 999px;
font-size: 0.8125rem;
color: var(--tl-faint);
}
/* ——— Year node ———————————————————————————————————————————————— */
.ngu-tl__year {
position: relative;
}
.ngu-tl__yearBtn {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
padding-block: 0.6875rem;
background: none;
border: 0;
text-align: left;
cursor: pointer;
color: inherit;
font: inherit;
border-radius: 0.375rem;
}
.ngu-tl__yearBtn:hover .ngu-tl__yearLabel {
color: var(--tl-accent);
}
.ngu-tl__yearBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
/* Dot sits on the rail; --tl-dot is set inline from the year's item count, so
scrolling the page gives you the organization's density at a glance. */
.ngu-tl__dot {
position: relative;
justify-self: start;
margin-left: calc(var(--tl-rail-x) - (var(--tl-dot, 8px) / 2) + (var(--tl-rail-w) / 2));
width: var(--tl-dot, 8px);
height: var(--tl-dot, 8px);
border-radius: 50%;
background: var(--tl-rail);
box-shadow: 0 0 0 4px var(--tl-surface);
transition: background-color 140ms ease, transform 140ms ease;
}
.ngu-tl__yearBtn:hover .ngu-tl__dot {
background: var(--tl-accent);
}
.ngu-tl__year--featured .ngu-tl__dot {
background: var(--tl-accent);
}
.ngu-tl__year--featured .ngu-tl__dot::after {
content: '';
position: absolute;
inset: -5px;
border: 1.5px solid var(--tl-accent);
border-radius: 50%;
opacity: 0.45;
}
.ngu-tl__year--open .ngu-tl__dot {
background: var(--tl-accent);
transform: scale(1.15);
}
.ngu-tl__decade--pre .ngu-tl__dot {
background: var(--tl-surface);
border: 1.5px solid var(--tl-rail);
}
.ngu-tl__yearLabel {
font-size: 1.125rem;
font-weight: 550;
font-variant-numeric: tabular-nums;
letter-spacing: -0.01em;
transition: color 140ms ease;
}
.ngu-tl__yearCount {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--tl-faint);
padding-right: 0.25rem;
}
.ngu-tl__year--empty .ngu-tl__yearLabel {
color: var(--tl-faint);
font-weight: 450;
}
/* ——— Expanded panel ——————————————————————————————————————————— */
.ngu-tl__panel {
padding: 0.25rem 0 1.5rem var(--tl-gutter);
animation: ngu-tl-reveal 220ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
@keyframes ngu-tl-reveal {
from {
opacity: 0;
transform: translateY(-4px);
}
}
.ngu-tl__month {
margin-top: 1.5rem;
}
.ngu-tl__month:first-child {
margin-top: 0.5rem;
}
.ngu-tl__monthLabel {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0 0 0.625rem;
font-size: 0.8125rem;
font-weight: 550;
color: var(--tl-muted);
}
.ngu-tl__monthLabel::after {
content: '';
flex: 1;
height: 1px;
background: var(--tl-rail);
opacity: 0.6;
}
.ngu-tl__undated .ngu-tl__monthLabel {
font-style: italic;
font-weight: 450;
color: var(--tl-faint);
}
.ngu-tl__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
/* ——— Entry ————————————————————————————————————————————————————— */
.ngu-tl__entry {
display: grid;
grid-template-columns: 2.75rem minmax(0, 1fr);
gap: 0.75rem;
padding: 0.5rem 0.75rem 0.5rem 0;
border-radius: 0.5rem;
text-decoration: none;
color: inherit;
}
a.ngu-tl__entry:hover {
background: var(--tl-accent-soft);
}
a.ngu-tl__entry:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 1px;
}
.ngu-tl__entryDay {
font-size: 0.8125rem;
font-variant-numeric: tabular-nums;
color: var(--tl-faint);
padding-top: 0.1875rem;
text-align: right;
}
.ngu-tl__entryTitle {
display: block;
font-size: 0.9375rem;
font-weight: 500;
line-height: 1.45;
}
a.ngu-tl__entry:hover .ngu-tl__entryTitle {
color: var(--tl-accent);
}
.ngu-tl__entryMeta {
display: block;
margin-top: 0.125rem;
font-size: 0.8125rem;
color: var(--tl-muted);
}
.ngu-tl__entryBlurb {
margin: 0.375rem 0 0;
font-size: 0.875rem;
line-height: 1.6;
color: var(--tl-muted);
max-width: 64ch;
}
/* Kind marker: a shape, not a colored pill, so a screenful of entries stays
calm and the type still carries the hierarchy. */
.ngu-tl__kind {
display: inline-block;
width: 0.5rem;
height: 0.5rem;
margin-right: 0.5rem;
vertical-align: 0.0625rem;
background: var(--tl-faint);
}
.ngu-tl__kind--milestone {
border-radius: 50%;
background: var(--tl-accent);
}
.ngu-tl__kind--event {
border-radius: 1px;
}
.ngu-tl__kind--award {
clip-path: polygon(50% 0, 100% 50%, 50% 100%, 0 50%);
}
.ngu-tl__kind--person {
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
/* ——— Featured entries ————————————————————————————————————————— */
.ngu-tl__featured {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 0.5rem;
}
.ngu-tl__entry--featured {
grid-template-columns: minmax(0, 1fr);
padding: 1rem 1.125rem;
border-left: 2px solid var(--tl-accent);
background: var(--tl-accent-soft);
border-radius: 0 0.5rem 0.5rem 0;
}
.ngu-tl__entry--featured .ngu-tl__entryTitle {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.008em;
}
.ngu-tl__entry--featured .ngu-tl__entryDay {
text-align: left;
padding: 0 0 0.25rem;
}
a.ngu-tl__entry--featured:hover {
background: color-mix(in srgb, var(--tl-accent) 18%, transparent);
}
/* ——— Empty state ——————————————————————————————————————————————— */
.ngu-tl__empty {
padding: 0.5rem 0 1.25rem var(--tl-gutter);
font-size: 0.875rem;
color: var(--tl-faint);
}
@media (prefers-reduced-motion: reduce) {
.ngu-tl *,
.ngu-tl *::before,
.ngu-tl *::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
/* ——— Toolbar + utilities ————————————————————————————————————— */
.ngu-tl__toolbar {
display: flex;
justify-content: flex-end;
padding-bottom: 1rem;
}
.ngu-tl__toolbarBtn {
padding: 0.375rem 0.75rem;
border: 1px solid var(--tl-rail);
border-radius: 999px;
background: none;
color: var(--tl-muted);
font: inherit;
font-size: 0.8125rem;
cursor: pointer;
transition: color 140ms ease, border-color 140ms ease;
}
.ngu-tl__toolbarBtn:hover {
color: var(--tl-accent);
border-color: var(--tl-accent);
}
.ngu-tl__toolbarBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
}
.ngu-tl__emptyState {
padding: 3rem 0;
color: var(--tl-muted);
font-size: 0.9375rem;
}
.ngu-tl__srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
border: 0;
}
/* ——— Upcoming block ——————————————————————————————————————————
Opens upward: the toggle is first in the DOM, column-reverse puts the
list above it. Faded because none of it has happened yet. */
.ngu-tl__upcoming {
position: relative;
display: flex;
flex-direction: column-reverse;
padding-bottom: 0.5rem;
}
.ngu-tl__upcoming::before {
content: '';
position: absolute;
top: 1.25rem;
bottom: 0;
left: var(--tl-rail-x);
border-left: var(--tl-rail-w) dashed var(--tl-rail);
}
.ngu-tl__upcomingBtn {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: var(--tl-gutter) minmax(0, 1fr) auto;
align-items: center;
width: 100%;
padding-block: 0.75rem;
background: none;
border: 0;
font: inherit;
color: var(--tl-muted);
text-align: left;
cursor: pointer;
}
.ngu-tl__upcomingBtn:hover {
color: var(--tl-accent);
}
.ngu-tl__upcomingBtn:focus-visible {
outline: 2px solid var(--tl-accent);
outline-offset: 2px;
border-radius: 0.375rem;
}
.ngu-tl__upcomingDot {
justify-self: start;
margin-left: calc(var(--tl-rail-x) - 4px + (var(--tl-rail-w) / 2));
width: 8px;
height: 8px;
border-radius: 50%;
border: 1.5px dashed var(--tl-accent);
box-shadow: 0 0 0 4px var(--tl-surface);
}
.ngu-tl__upcomingLabel {
font-size: 0.875rem;
}
.ngu-tl__chevron {
font-size: 0.75rem;
padding-right: 0.25rem;
}
/* Everything above the toggle is provisional, and reads that way. */
.ngu-tl__upcomingList {
opacity: 0.62;
animation: ngu-tl-rise 240ms cubic-bezier(0.2, 0.7, 0.3, 1) both;
}
.ngu-tl__upcomingList:hover,
.ngu-tl__upcomingList:focus-within {
opacity: 1;
}
.ngu-tl__upcomingList .ngu-tl__dot {
background: var(--tl-surface);
border: 1.5px dashed var(--tl-accent);
}
@keyframes ngu-tl-rise {
from {
opacity: 0;
transform: translateY(8px);
}
}
/* ——— Event / org / award logo ————————————————————————————————— */
.ngu-tl__logo {
display: inline-block;
width: 1.125rem;
height: 1.125rem;
margin-right: 0.5rem;
vertical-align: -0.1875rem;
object-fit: contain;
border-radius: 2px;
}
.ngu-tl__entry--featured .ngu-tl__logo {
width: 1.5rem;
height: 1.5rem;
vertical-align: -0.3125rem;
}
/* ——— People roster ——————————————————————————————————————————— */
.ngu-tl__entryWrap {
display: block;
}
.ngu-tl__roster {
padding: 0.125rem 0 0.5rem 3.5rem;
}
.ngu-tl__roster--featured {
padding: 0.5rem 1.125rem 1rem;
border-left: 2px solid var(--tl-accent);
background: var(--tl-accent-soft);
margin-top: -0.5rem;
border-radius: 0 0 0.5rem 0;
}
.ngu-tl__rosterTeam {
display: block;
margin-bottom: 0.5rem;
font-size: 0.75rem;
color: var(--tl-faint);
}
.ngu-tl__people {
display: flex;
flex-wrap: wrap;
gap: 0.375rem 1rem;
list-style: none;
margin: 0;
padding: 0;
}
.ngu-tl__person {
display: flex;
align-items: center;
gap: 0.5rem;
}
.ngu-tl__personPhoto {
width: 1.75rem;
height: 1.75rem;
border-radius: 50%;
object-fit: cover;
background: var(--tl-accent-soft);
flex: none;
}
.ngu-tl__personPhoto--blank {
display: grid;
place-items: center;
font-size: 0.625rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--tl-accent);
}
.ngu-tl__personName {
font-size: 0.8125rem;
line-height: 1.25;
}
.ngu-tl__personTitle {
display: block;
font-size: 0.6875rem;
color: var(--tl-faint);
}
/* ——— Kind markers, continued ————————————————————————————————— */
.ngu-tl__kind--organization {
border-radius: 2px;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
.ngu-tl__kind--people {
border-radius: 50%;
background: none;
box-shadow: inset 0 0 0 1.5px var(--tl-faint);
}
.ngu-tl__entryBody {
display: block;
min-width: 0;
}