Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
fa94acd
perf(fonts,theme): single font request + flash-free theme init
radiolabme Jun 10, 2026
b2b11a6
type(fonts): self-host Atkinson Hyperlegible Next + JetBrains Mono
radiolabme Jun 10, 2026
24cbfe2
content: add June meeting and ARRL Field Day 2026 events
radiolabme Jun 10, 2026
49d142a
fix(dates): correct timezone off-by-one and tag-page event sort
radiolabme Jun 10, 2026
5cb4cae
type(scale): web-tuned modular ramp + de-duplicate type tokens
radiolabme Jun 10, 2026
ac9a4df
style(color): consolidate to a single blue accent; retire red
radiolabme Jun 10, 2026
dfb2fab
fix(nav): persist header across View Transitions to stop nav flash
radiolabme Jun 11, 2026
0efbb3c
fix(theme): reapply theme on astro:after-swap to stop navigation flicker
radiolabme Jun 11, 2026
bcd8721
type(scale): make the display band an exact 1.2/1.25 modular scale
radiolabme Jun 11, 2026
75c5024
refactor(home): extract inline styles into utilities/classes
radiolabme Jun 11, 2026
62b7632
fix(events): show endDate in datetime range for multi-day events
radiolabme Jun 12, 2026
5eae08a
fix(events): drop current-year and orphan timezone from date display
radiolabme Jun 12, 2026
6bd6bae
refactor(home): bento layout — pair hero with a quick-fact rail
radiolabme Jun 12, 2026
6a17804
style(motion): refined easing tokens + restrained micro-interactions
radiolabme Jun 12, 2026
9a5cc90
feat(motion): scroll-driven reveals + header tuning line
radiolabme Jun 12, 2026
bf02346
fix(home): clearer boundaries, de-dupe Field Day, drop redundant Events
radiolabme Jun 12, 2026
524b189
test(events): align datetime tests with current-year omission
radiolabme Jun 12, 2026
305cfc4
content: add MicroHAMS Field Day history article (draft)
radiolabme Jun 14, 2026
c3c1692
content: finalize Field Day article and cross-link from About
radiolabme Jun 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions .design-context.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Design Context — MicroHAMS

> Written by the `design-context` skill. Priming for all `design-*` work. Update the
> Design Context section in place rather than duplicating it.

## Design Context

### Users

MicroHAMS is an amateur radio club in the Puget Sound area. The site serves **two audiences
equally**:

- **Existing members & licensed hams** — want event details (monthly meetings, Field Day, swap
meets), technical articles, and reference docs. They value information density, accuracy, and
utility.
- **Prospective / newly licensed hams** — need an inviting front door: who the club is, when it
meets, how to show up. They value warmth, clear on-ramps, and low jargon.

Context of use: desktop and mobile, often checking "when/where is the next meeting" or reading a
long technical article. Both light and dark environments (shacks are dark; phones are everywhere).

### Brand Personality

Three words: **precise, credible, welcoming**. The voice is knowledgeable but not gatekeeping —
technical authority delivered plainly. Emotionally the interface should evoke **confidence and
calm competence** (this club knows its craft) without feeling cold or corporate. A subtle nod to
the hobby's signal/RF heritage is welcome (see the header's radio-wave motif).

### Aesthetic Direction

Deliberately **Swiss / editorial / typographic** — the codebase explicitly cites Vignelli, Tufte,
and Bringhurst. Characteristics:

- Achromatic base (black/white/grays in OKLCH) with restrained accent use.
- **Single accent: blue** (`--color-accent`, hue ~240). Red has been **retired** as an accent —
every interactive/emphasis role (links, focus rings, active nav, blockquote rule, tag hover,
badges, primary buttons, the logo, the header wave motif) uses the one blue. Reserve any future
red strictly for genuine error/danger semantics, never decoration.
- **Web-tuned type scale**: round-px text band (12–20px) + exact alternating 1.2/1.25 display band
(20·24·30·36·45px, h1 capped at 45px), 16px base (`1rem`, honors zoom), on a 24px
baseline grid — the old print-derived φ=1.618 ramp was too poster-aggressive for screens. Generous
measure (≈62ch body), Every-Layout primitives.
- **Self-hosted fonts via Fontsource** (no third-party request): **Atkinson Hyperlegible Next**
(body/UI — Braille Institute low-vision face, incl. italic for blockquotes) and **JetBrains Mono**
(headings + code). No serif face. Fallback stacks use only generic keywords (`ui-sans-serif`,
`ui-monospace`) — never named/system or banned fonts.
- Full light + dark mode, WCAG AA targeted throughout the tokens.

**Anti-references:** generic AI-template aesthetics — purple/indigo gradients, glassmorphism,
gradient text, hero stat-counters, emoji-as-icons, drop-shadow soup. The site should look
_designed and edited_, not _generated_. Also banned as fonts (incl. fallback stacks): Inter,
Roboto, Arial, Open Sans, Helvetica Neue, Segoe UI, system-ui, Space Grotesk — and near-name
"kissing-cousins" of these. Nerd Fonts evaluated and rejected (no relevant glyphs, heavy payload).

### Design Principles

1. **Typography is the interface.** Hierarchy comes from the type scale and spacing, not
decoration. Protect the measure and the baseline rhythm.
2. **Systematic, not rigid.** Every value derives from tokens (space, type, color, radius). No
magic numbers in components; reach for a token or a primitive first.
3. **Accent with discipline.** Color is rare and meaningful. Keep the blue/red roles separated and
documented; never let an accent become decoration.
4. **AA is the floor, not the goal.** Contrast, focus visibility, semantics, and 44px touch
targets are non-negotiable; aim past AA where cheap.
5. **Fast and quiet.** A static club site should load instantly and never flash. Performance and a
stable first paint are part of the design, not an afterthought.
20 changes: 20 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@
"prepare": "husky"
},
"dependencies": {
"@fontsource-variable/atkinson-hyperlegible-next": "^5.2.6",
"@fontsource-variable/jetbrains-mono": "^5.2.8",
"@js-temporal/polyfill": "^0.5.1",
"@types/leaflet": "^1.9.21",
"astro": "^6.3.1",
Expand Down
3 changes: 2 additions & 1 deletion src/components/SiteHeader.astro
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
*/
---

<header class="site-header">
<header class="site-header" transition:persist="site-header">
<div class="scroll-progress" aria-hidden="true"></div>
<div class="wrapper">
<div class="site-header__inner">
<div class="site-header__brand">
Expand Down
8 changes: 8 additions & 0 deletions src/components/event/EventCard.astro
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ const dateStr = formatEventDate(event.data.eventDate, tzOptions);
<div class="featured-event__meta">
<EventDateTime
eventDate={event.data.eventDate}
endDate={event.data.endDate}
startTime={event.data.startTime}
endTime={event.data.endTime}
venue={event.data.venue}
Expand Down Expand Up @@ -135,6 +136,7 @@ const dateStr = formatEventDate(event.data.eventDate, tzOptions);
{event.data.startTime && (
<EventDateTime
eventDate={event.data.eventDate}
endDate={event.data.endDate}
startTime={event.data.startTime}
endTime={event.data.endTime}
venue={event.data.venue}
Expand Down Expand Up @@ -278,6 +280,12 @@ const dateStr = formatEventDate(event.data.eventDate, tzOptions);
block-size: 100%;
object-fit: cover;
aspect-ratio: 4 / 3;
transition: transform var(--transition-slow);
}

/* Slow, contained zoom — the image breathes, the frame crops it */
.featured-event:hover .featured-image {
transform: scale(1.03);
}

.featured-event__content {
Expand Down
72 changes: 52 additions & 20 deletions src/components/event/EventDateTime.astro
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
---
/**
* EventDateTime Component
*
*
* Interactive datetime display that toggles between:
* - Event timezone (default)
* - Browser local timezone
* - UTC
*
*
* Falls back to static display without JavaScript.
*/

import { getEventTimezone, getTimezoneAbbreviation } from '../../utils/event-time';

interface Props {
eventDate: Date;
endDate?: Date;
startTime?: string;
endTime?: string;
venue?: string;
Expand All @@ -22,32 +23,63 @@ interface Props {
style?: string;
}

const { eventDate, startTime, endTime, venue, timezone: explicitTz, class: className, style } = Astro.props;
const {
eventDate,
endDate,
startTime,
endTime,
venue,
timezone: explicitTz,
class: className,
style,
} = Astro.props;

const timezone = getEventTimezone(venue, explicitTz);
const tzAbbrev = getTimezoneAbbreviation(eventDate, timezone);

// Format static fallback
const dateStr = eventDate.toLocaleDateString('en-US', {
timeZone: 'UTC',
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
});
// Multi-day when an endDate is set and lands on a different calendar day
const isMultiDay = !!endDate && endDate.getTime() !== eventDate.getTime();

// Omit the year when the event falls in the current year — it's noise for
// near-term events and only earns its place for past/future-year dates.
const currentYear = new Date().getFullYear();
const longDate = (d: Date) =>
d.toLocaleDateString('en-US', {
timeZone: 'UTC',
weekday: 'long',
month: 'long',
day: 'numeric',
...(d.getUTCFullYear() === currentYear ? {} : { year: 'numeric' }),
});

const timeStr = startTime
? `${startTime}${endTime ? `–${endTime}` : ''} ${tzAbbrev}`
: '';
// Compact date for multi-day ranges (two dates already carry enough context)
const mediumDate = (d: Date) =>
d.toLocaleDateString('en-US', {
timeZone: 'UTC',
weekday: 'short',
month: 'short',
day: 'numeric',
});

const fullDisplay = timeStr ? `${dateStr} · ${timeStr}` : dateStr;
// Format static fallback
let fullDisplay: string;
if (isMultiDay) {
// "Sat, Jun 27, 11:00 AM – Sun, Jun 28, 11:00 AM PDT"
const start = startTime ? `${mediumDate(eventDate)}, ${startTime}` : mediumDate(eventDate);
const end = endTime ? `${mediumDate(endDate!)}, ${endTime}` : mediumDate(endDate!);
fullDisplay = `${start} – ${end}${tzAbbrev ? ` ${tzAbbrev}` : ''}`;
} else {
const timeStr = startTime ? `${startTime}${endTime ? `–${endTime}` : ''} ${tzAbbrev}` : '';
fullDisplay = timeStr ? `${longDate(eventDate)} · ${timeStr}` : longDate(eventDate);
}
---

<button
<button
type="button"
class:list={["event-datetime", className]}
class:list={['event-datetime', className]}
style={style}
data-event-datetime={eventDate.toISOString()}
data-end-date={endDate?.toISOString()}
data-start-time={startTime}
data-end-time={endTime}
data-timezone={timezone}
Expand All @@ -70,19 +102,19 @@ const fullDisplay = timeStr ? `${dateStr} · ${timeStr}` : dateStr;
cursor: pointer;
text-align: start;
}

.event-datetime:hover {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 2px;
}

.event-datetime:focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
border-radius: var(--radius-sm);
}

.datetime-mode {
font-size: var(--text-xs);
color: var(--color-text-subtle);
Expand Down
65 changes: 60 additions & 5 deletions src/components/event/EventLogistics.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,15 @@ import { getVenue, getOnlineMeeting } from '../../site.config';
interface Props {
/** Physical venue key from site.config.ts */
venue?: string;
/** One-off venue details for non-recurring locations (use instead of venue key) */
customVenue?: {
name: string;
address: string;
room?: string;
mapUrl?: string;
latitude?: number;
longitude?: number;
};
/** Custom location string when venue is not in config */
location?: string;
/** Override map coordinates (uses venue config if not provided) */
Expand All @@ -39,6 +48,7 @@ interface Props {

const {
venue: venueKey,
customVenue,
location,
latitude: propLatitude,
longitude: propLongitude,
Expand All @@ -55,15 +65,15 @@ const venue = venueKey ? getVenue(venueKey) : undefined;
const meeting = meetingKey ? getOnlineMeeting(meetingKey) : undefined;
const teams = meeting || legacyTeams;

// Resolve coordinates: props override venue config
const latitude = propLatitude ?? venue?.latitude;
const longitude = propLongitude ?? venue?.longitude;
// Resolve coordinates: props override venue config, then one-off venue
const latitude = propLatitude ?? venue?.latitude ?? customVenue?.latitude;
const longitude = propLongitude ?? venue?.longitude ?? customVenue?.longitude;

// Resolve coord frequency: props override venue config
const coordFrequency = propFreq || venue?.coordFrequency;

// Location display: props override venue config
const displayLocation = location || venue?.name;
// Location display: props override venue config, then one-off venue
const displayLocation = location || venue?.name || customVenue?.name;

// Simple markdown bold to HTML conversion
function mdBold(text: string): string {
Expand Down Expand Up @@ -126,6 +136,51 @@ const escortHtml = venue?.arrival?.escort
)
}

{
!venue && customVenue && (
<div id="location">
<h2>Join In Person</h2>

<details id="directions-details" style="margin-block-start: var(--space-3);" open>
<summary class="h6" style="cursor: pointer;">
Directions & Map
</summary>
<div class="stack-4" style="padding-block-start: var(--space-3);">
<div class="stack-3">
<p>
<>
<strong>{customVenue.name}</strong>
<br />
</>
<span class="text-muted">{customVenue.address}</span>
{customVenue.room && (
<>
<br />
<span class="text-muted">{customVenue.room}</span>
</>
)}
</p>
{customVenue.mapUrl && (
<p>
<a href={customVenue.mapUrl} target="_blank" rel="noopener noreferrer">
Open in Google Maps →
</a>
</p>
)}
{latitude && longitude && (
<EventMap
location={displayLocation || ''}
latitude={latitude}
longitude={longitude}
/>
)}
</div>
</div>
</details>
</div>
)
}

{
teams && (
<div>
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/content/articles/field-day-camp-freeman.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added src/content/articles/field-day-rv-operator.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading