Compare commits

..

No commits in common. "5702859e3784fec7a975d3e609b7fa54996d27f6" and "a520e8534eae38f8e2eda4bd7219f40f74267d64" have entirely different histories.

41 changed files with 240 additions and 931 deletions

View file

@ -47,21 +47,7 @@
"Bash(curl -s -c /tmp/jar.txt -d \"email=lars@virk2.dk&password=cab123\" http://localhost:4321/login -o /dev/null)",
"Bash(curl -s -b /tmp/jar.txt http://localhost:4321/roadmap)",
"Bash(grep -nE \"\\\\.rr-fade-left, \\\\.rr-fade-right|rr-fade-left \\\\{|rr-fade-right \\\\{\" src/components/RoadmapRoute.astro)",
"Bash(awk -F: '{print $1}')",
"Bash(chmod +x /home/jonathan/.claude/statusline-command.sh)",
"Bash(bash -n /home/jonathan/.claude/statusline-command.sh)",
"Bash(command -v python3)",
"Bash(command -v python)",
"Bash(cd *)",
"Bash(break)",
"Bash(sed -n \"/export function createDispatch/,/^}/p\" src/lib/db.ts)",
"Bash(sed -n \"/export function dispatchSlug/,/^}/p\" src/lib/format.ts)",
"Bash(git check-ignore *)",
"Bash(sed -i -E 's/font-size: 15px/font-size: 16px/g; s/font-size: 14px/font-size: 15px/g; s/font-size: 13px/font-size: 14px/g; s/font-size: 12px/font-size: 13px/g; s/font-size: 11px/font-size: 12px/g; s/font-size: 10px/font-size: 11px/g; s/font-size: 9px/font-size: 10px/g' src/components/EventHeroCard.astro)",
"Bash(sed -n '/@media \\(max-width: 880px\\)/,/^ }/p' src/components/EventHeroCard.astro)",
"Bash(sed -n '1,80p' src/middleware.ts)",
"Bash(sed -n '1,80p' src/middleware/index.ts)",
"Bash(sed -n '1,40p' src/pages/api/join.ts)"
"Bash(awk -F: '{print $1}')"
]
}
}

3
.gitignore vendored
View file

@ -8,6 +8,3 @@ node_modules/
*.db-shm
*.db-wal
progress.md
# Uploaded event photos (runtime, persists on the VPS)
data/uploads/

View file

@ -255,7 +255,6 @@ Listed so Claude Code does not wander into them:
- Email sending (invites are copied manually; no transactional email provider)
- File uploads from participants (they write text; Fenja attaches documents to meetings via git)
- *Exception (added v1):* Fenja admins may upload a single event photo (png/jpg) per event via the admin panel. Stored on the VPS and served by the app. This is the only upload path; participants still cannot upload.
- Rich-text editor beyond markdown-lite
- Threaded discussion on contributions (replies from Fenja only, one level deep)
- Dark mode

View file

@ -1,39 +0,0 @@
-- Roadmap status enum gains a fifth value `planned` for items that are
-- committed and scheduled but not yet started — sitting between `in_beta`
-- and `exploring` in the progression.
--
-- SQLite can't widen a CHECK constraint in place, so this is a full table
-- rebuild (same approach as 0006). roadmap_attributions has an ON DELETE
-- CASCADE FK to roadmap_items(id), so foreign keys are toggled off around
-- the rebuild to preserve attribution rows across the DROP/RENAME. The
-- metadata_text column added in 0007 is carried through.
PRAGMA foreign_keys = OFF;
CREATE TABLE roadmap_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'exploring'
CHECK(status IN ('shipping','in_beta','planned','exploring','considering')),
target TEXT,
display_order INTEGER NOT NULL DEFAULT 0,
shipped_at TEXT,
metadata_text TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT INTO roadmap_items_new
(id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at)
SELECT
id, title, description, status, target, display_order, shipped_at, metadata_text, created_at, updated_at
FROM roadmap_items;
DROP TABLE roadmap_items;
ALTER TABLE roadmap_items_new RENAME TO roadmap_items;
CREATE INDEX idx_roadmap_status ON roadmap_items(status, display_order);
CREATE INDEX idx_roadmap_shipped ON roadmap_items(shipped_at);
PRAGMA foreign_keys = ON;

View file

@ -802,7 +802,6 @@
.pill-declined { background: rgba(0, 0, 0, 0.04); color: var(--on-surface-muted); }
.pill-shipping { background: rgba(109, 140, 124, 0.18); color: #5a7268; }
.pill-in-beta { background: rgba(185, 107, 88, 0.10); color: #b96b58; }
.pill-planned { background: rgba(90, 109, 131, 0.12); color: #5a6d83; }
.pill-exploring { background: rgba(186, 186, 176, 0.20); color: var(--on-surface-variant); }
.pill-considering{ background: rgba(186, 186, 176, 0.10); color: var(--on-surface-muted); }
.pill-active { background: rgba(109, 140, 124, 0.15); color: #6d8c7c; }
@ -1416,17 +1415,3 @@
transition: opacity var(--duration-fast) var(--ease-standard);
}
.bs-copy-btn:hover { opacity: 0.88; }
/* Phone (600px)
The 767px breakpoint already stacks the sidebar + two-pane and the list
grid. This pass keeps the chrome from overflowing on a real phone:
lighter padding, a search box that can shrink, stacked summary rows and
panel footer buttons. Admin is fenja-only, so this is a "fit, don't
overflow" pass rather than a native mobile redesign. */
@media (max-width: 600px) {
.bs-topbar { padding: 0 var(--space-4); }
.bs-main { padding: var(--space-4) var(--space-4); }
.bs-search-form { min-width: 0; }
.bs-summary-row { grid-template-columns: 1fr; gap: var(--space-1); }
.bs-panel-foot { flex-direction: column; align-items: stretch; gap: var(--space-2); }
}

View file

@ -18,7 +18,6 @@ import DateField from './fields/DateField.astro';
import DatetimeField from './fields/DatetimeField.astro';
import NumberField from './fields/NumberField.astro';
import ReadonlyField from './fields/ReadonlyField.astro';
import ImageUploadField from './fields/ImageUploadField.astro';
import type { Field } from '../resource-types';
interface Props {
@ -48,7 +47,6 @@ const { field, value, error, item } = Astro.props;
{field.kind === 'datetime' && <DatetimeField field={field} value={value} />}
{field.kind === 'number' && <NumberField field={field} value={value} />}
{field.kind === 'readonly' && <ReadonlyField field={field} value={value} item={item} />}
{field.kind === 'image-upload' && <ImageUploadField field={field} value={value} />}
{field.helperText && (
<p class="bs-helper">{field.helperText}</p>

View file

@ -1,134 +0,0 @@
---
import type { ImageUploadField } from '../../resource-types';
interface Props {
field: ImageUploadField;
value: unknown;
}
const { field, value } = Astro.props;
const v = value == null ? '' : String(value);
---
<div class="bs-imgup" data-imgup>
<!-- Stored value: the image URL. Set by upload, or pasteable directly. -->
<input
type="text"
id={`f-${field.key}`}
name={field.key}
class="bs-input"
value={v}
placeholder="Upload below, or paste an image URL"
maxlength={field.maxLength}
data-imgup-value
/>
<div class="bs-imgup-row">
<label class="bs-imgup-pick">
Choose image…
<input type="file" accept="image/png,image/jpeg" data-imgup-file hidden />
</label>
<button type="button" class="bs-imgup-clear" data-imgup-clear hidden>Remove</button>
<span class="bs-imgup-status" data-imgup-status aria-live="polite"></span>
</div>
<div class="bs-imgup-preview" data-imgup-preview hidden>
<img src={v} alt="" data-imgup-img />
</div>
</div>
<style>
.bs-imgup { display: flex; flex-direction: column; gap: var(--space-2); }
.bs-imgup-row { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.bs-imgup-pick {
display: inline-flex;
align-items: center;
font-size: var(--text-label-md);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-container);
color: var(--on-surface);
cursor: pointer;
}
.bs-imgup-pick:hover { background: var(--surface-container-high); }
.bs-imgup-clear {
background: none;
border: none;
color: var(--on-surface-muted);
font-size: var(--text-label-md);
cursor: pointer;
padding: var(--space-2);
}
.bs-imgup-clear:hover { color: var(--color-danger); }
.bs-imgup-status { font-size: var(--text-label-md); color: var(--on-surface-variant); }
.bs-imgup-status.is-error { color: var(--color-danger); }
.bs-imgup-preview {
border-radius: var(--radius-md);
overflow: hidden;
max-width: 280px;
background: var(--surface-container);
}
.bs-imgup-preview img { display: block; width: 100%; height: auto; }
</style>
<script>
document.querySelectorAll<HTMLElement>('[data-imgup]').forEach((root) => {
const valueInput = root.querySelector<HTMLInputElement>('[data-imgup-value]');
const fileInput = root.querySelector<HTMLInputElement>('[data-imgup-file]');
const clearBtn = root.querySelector<HTMLButtonElement>('[data-imgup-clear]');
const status = root.querySelector<HTMLElement>('[data-imgup-status]');
const preview = root.querySelector<HTMLElement>('[data-imgup-preview]');
const img = root.querySelector<HTMLImageElement>('[data-imgup-img]');
if (!valueInput || !fileInput) return;
function setStatus(msg: string, isError = false) {
if (!status) return;
status.textContent = msg;
status.classList.toggle('is-error', isError);
}
function syncPreview() {
const url = valueInput!.value.trim();
const has = url.length > 0;
if (img && has) img.src = url;
preview?.toggleAttribute('hidden', !has);
clearBtn?.toggleAttribute('hidden', !has);
}
syncPreview();
valueInput.addEventListener('input', syncPreview);
clearBtn?.addEventListener('click', () => {
valueInput.value = '';
fileInput.value = '';
setStatus('');
syncPreview();
});
fileInput.addEventListener('change', async () => {
const file = fileInput.files?.[0];
if (!file) return;
if (!['image/png', 'image/jpeg'].includes(file.type)) {
setStatus('Only PNG or JPG images are allowed.', true);
fileInput.value = '';
return;
}
setStatus('Uploading…');
try {
const body = new FormData();
body.append('file', file);
const res = await fetch('/api/admin/upload', { method: 'POST', body });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.url) {
setStatus(data.error ?? 'Upload failed.', true);
return;
}
valueInput.value = data.url;
setStatus('Uploaded ✓');
syncPreview();
} catch {
setStatus('Upload failed. Try again.', true);
}
});
});
</script>

View file

@ -100,12 +100,6 @@ export interface ReadonlyField extends FieldBase {
kind: 'readonly';
render?: (value: unknown, item: Record<string, unknown> | null) => string;
}
/** Image upload uploads a png/jpg to /api/admin/upload and stores the
* returned URL as the field value (also pasteable as a plain URL). */
export interface ImageUploadField extends FieldBase {
kind: 'image-upload';
maxLength?: number;
}
export type Field =
| TextField
@ -118,8 +112,7 @@ export type Field =
| DateField
| DatetimeField
| NumberField
| ReadonlyField
| ImageUploadField;
| ReadonlyField;
// ── Columns ─────────────────────────────────────────────────────────────────
interface ColumnBase {

View file

@ -204,10 +204,10 @@ export const eventsResource: Resource<Event> = {
},
{
key: 'photo_url',
label: 'Event photo',
kind: 'image-upload',
label: 'Photo URL',
kind: 'text',
maxLength: 400,
helperText: 'Optional png/jpg shown as the event photo on the hub. Upload a file or paste an image URL.',
helperText: 'Optional hero image for the event detail page.',
},
{
key: 'notes_url',

View file

@ -48,7 +48,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
pillVariants: {
shipping: { label: 'Shipping', class: 'pill-shipping' },
in_beta: { label: 'In beta', class: 'pill-in-beta' },
planned: { label: 'Planned', class: 'pill-planned' },
exploring: { label: 'Exploring', class: 'pill-exploring' },
considering: { label: 'Considering', class: 'pill-considering' },
},
@ -70,7 +69,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
{ key: 'all', label: 'All', predicate: () => true, isDefault: true },
{ key: 'shipping', label: 'Shipping', predicate: (i) => i.status === 'shipping' },
{ key: 'in_beta', label: 'In beta', predicate: (i) => i.status === 'in_beta' },
{ key: 'planned', label: 'Planned', predicate: (i) => i.status === 'planned' },
{ key: 'exploring', label: 'Exploring', predicate: (i) => i.status === 'exploring' },
{ key: 'considering', label: 'Considering', predicate: (i) => i.status === 'considering' },
],
@ -101,7 +99,6 @@ export const roadmapResource: Resource<RoadmapItemWithAttribution> = {
options: [
{ value: 'shipping', label: 'Shipping' },
{ value: 'in_beta', label: 'In beta' },
{ value: 'planned', label: 'Planned' },
{ value: 'exploring', label: 'Exploring' },
{ value: 'considering', label: 'Considering' },
],

View file

@ -15,7 +15,6 @@ import {
getAllUsersPublic,
getUserPublicById,
updateUserAdminFields,
updateUserEmail,
updateUserProfile,
updateUserRole,
deactivateUser,
@ -218,15 +217,7 @@ export const usersResource: Resource<UserPublic> = {
'Changing the role has real access consequences. Setting to Council also allocates a member number.',
},
{ key: 'name', label: 'Name', kind: 'text', required: true, maxLength: 120 },
{
key: 'email',
label: 'Email',
kind: 'text',
required: true,
maxLength: 200,
pattern: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
helperText: 'The members login identity. Normalised to lowercase on save; must be unique.',
},
{ key: 'email', label: 'Email', kind: 'text', readOnly: true },
{ key: 'organisation', label: 'Organisation', kind: 'text', readOnly: true,
helperText: 'Set at sign-up; editing is not yet supported.' },
{ key: 'bio', label: 'Bio', kind: 'textarea', rows: 4 },
@ -291,13 +282,6 @@ export const usersResource: Resource<UserPublic> = {
const current = getUserPublicById(id);
if (!current) throw new Error(`User ${id} not found`);
// Email (login identity) — only written when changed. Throws on a
// collision, which the save handler surfaces as a form error.
const newEmail = String(data.email ?? '').trim().toLowerCase();
if (newEmail && newEmail !== current.email) {
updateUserEmail(id, newEmail);
}
// Profile fields (name + bio).
const newName = String(data.name ?? current.name);
const newBio = String(data.bio ?? current.bio ?? '');

View file

@ -110,14 +110,6 @@ function validateField(field: Field, value: unknown): string | null {
return null;
}
case 'image-upload': {
if (typeof value !== 'string') return `${field.label} must be a URL`;
if ('maxLength' in field && field.maxLength && value.length > field.maxLength) {
return `${field.label} must be ${field.maxLength} characters or fewer`;
}
return null;
}
case 'readonly':
return null;
}

View file

@ -1,18 +1,16 @@
---
import Avatar from './Avatar.astro';
import type { Event, UserPublic } from '../lib/db';
import { eventKindLabel } from '../lib/format';
import { eventKindLabel, redactName } from '../lib/format';
interface Props {
event: Event | null;
attendees: UserPublic[]; // confirmed (status='yes') — kept for caller compat, not rendered here
attendees: UserPublic[]; // confirmed (status='yes')
confirmedCount: number;
myRsvp: 'yes' | 'no' | 'interested' | null;
greetingPrefix: string; // e.g. "Good afternoon, "
firstName: string;
memberLabel?: string | null; // e.g. "MEMBER · 001"
}
const { event, confirmedCount, myRsvp, greetingPrefix, firstName, memberLabel = null } = Astro.props;
const { event, attendees, confirmedCount, myRsvp } = Astro.props;
function parseUtc(s: string): Date {
if (/T.*[Zz]$/.test(s) || /[+-]\d{2}:?\d{2}$/.test(s)) return new Date(s);
@ -26,34 +24,13 @@ const dayPadded = event ? String(parseUtc(event.starts_at).getUTCDate()).padSt
const weekday = event ? fmt({ weekday: 'long' }, event.starts_at).toUpperCase() : '';
const monthShort = event ? fmt({ month: 'short' }, event.starts_at).toUpperCase() : '';
const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: false }, event.starts_at) : '';
const visibleAttendees = attendees.slice(0, 3);
const overflow = Math.max(0, attendees.length - visibleAttendees.length);
---
<article
class:list={["hero", { "hero--empty": !event }]}
aria-label={event ? `Next gathering: ${event.title}` : 'Next gathering'}
>
{event?.photo_url && (
<div class="hero-photo" aria-hidden="true">
<img src={event.photo_url} alt="" loading="lazy" />
</div>
)}
{event ? (
<article class="hero" aria-label={`Next gathering: ${event.title}`}>
<!-- Greeting now lives inside the box, top of the card. -->
<div class="hero-greeting">
<h1 class="hero-greeting-line">{greetingPrefix}<em>{firstName}</em>.</h1>
{memberLabel && (
<div class="hero-greeting-meta">
<span class="hero-greeting-member">{memberLabel}</span>
<span class="hero-greeting-circle">Founding circle</span>
</div>
)}
</div>
{event ? (
<>
<div class="hero-event">
<!-- Label sits above the date + title so it's clear they describe
the next event. -->
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<div class="hero-top">
<div class="hero-date">
<span class="hero-weekday">{weekday}</span>
@ -62,10 +39,32 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
</div>
<div class="hero-mid">
<p class="hero-eyebrow">Next gathering · {eventKindLabel(event.kind).toUpperCase()}</p>
<h2 class="hero-title">{event.title}</h2>
<p class="hero-desc">{event.description}</p>
<p class="hero-location">{event.location}</p>
</div>
<div class="hero-meta">
{event.duration_label && (
<p class="hero-duration">{event.duration_label.toUpperCase()}</p>
)}
{visibleAttendees.length > 0 && (
<ul class="hero-attendees" aria-label="Confirmed attendees">
{visibleAttendees.map(u => (
<li class="hero-attendee">
<span class="hero-attendee-name">{redactName(u.name)}</span>
<Avatar id={u.id} name={u.name} size={18} />
</li>
))}
{overflow > 0 && (
<li class="hero-attendee">
<span class="hero-attendee-name">+{overflow} more</span>
<span class="hero-attendee-overflow" aria-hidden="true">+{overflow}</span>
</li>
)}
</ul>
)}
</div>
</div>
@ -90,76 +89,27 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
)}
</form>
</footer>
</>
) : (
</article>
) : (
<article class="hero hero--empty">
<p class="hero-empty"><em>Nothing scheduled yet — when we have something, you'll be the first to know.</em></p>
)}
</article>
</article>
)}
<style>
.hero {
position: relative;
overflow: hidden;
background: var(--ink);
color: var(--on-ink);
border-radius: 14px;
padding: 32px 36px 30px;
padding: 32px 36px 28px;
display: flex;
flex-direction: column;
gap: 44px; /* generous space between greeting and the event */
min-height: 480px; /* much taller — gives the photo room to breathe */
}
/* Event block: label stacked above the date + title grid. */
.hero-event {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
/* ── Greeting (inside the box, top) ──────────────────────────────── */
.hero-greeting {
position: relative;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 16px;
flex-wrap: wrap;
}
.hero-greeting-line {
font-family: var(--font-serif);
font-weight: 400;
font-size: 34px;
line-height: 1.05;
letter-spacing: var(--tracking-tight);
color: var(--on-ink);
margin: 0;
}
.hero-greeting-line em { font-style: italic; }
.hero-greeting-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
text-align: right;
}
.hero-greeting-member,
.hero-greeting-circle {
font-family: var(--font-sans);
font-size: var(--text-label-sm);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
gap: 22px;
}
.hero-top {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: 140px 1fr;
grid-template-columns: 140px 1fr auto;
gap: 32px;
align-items: start;
}
@ -172,7 +122,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
}
.hero-weekday {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -186,7 +136,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
}
.hero-month-time {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -201,7 +151,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
}
.hero-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -216,56 +166,71 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
margin: 0;
}
.hero-desc {
font-size: 14px;
font-size: 13px;
line-height: 1.55;
color: var(--on-ink-body);
margin: 0;
max-width: 380px;
}
.hero-location {
font-size: 13px;
font-size: 12px;
color: var(--on-ink-muted);
margin: 0;
}
/* ── Photo as card background ──────────────────────────────────
The image fills the whole card behind the content. A multi-stop
--ink gradient keeps the left (text) side solid and lets the photo
surface on the right, fading back into the box at the bottom so the
footer stays legible. --ink is rgb(44,58,82). */
.hero-photo {
position: absolute;
top: 0;
right: 0;
width: 70%; /* 30% smaller than the full card */
height: 70%;
z-index: 0;
/* ── Right meta column ───────────────────────────────────────── */
.hero-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 10px;
min-width: 140px;
}
.hero-photo img {
width: 100%;
height: 100%;
object-fit: cover;
object-position: center;
display: block;
.hero-duration {
font-family: var(--font-sans);
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
text-align: right;
margin: 0;
}
.hero-photo::after {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
/* Blend the inner edges (left + bottom) into the box; a light veil at
the top keeps the greeting meta legible where it overlaps. */
background:
linear-gradient(to left, rgba(44, 58, 82, 0) 38%, var(--ink) 100%),
linear-gradient(to bottom, rgba(44, 58, 82, 0) 48%, var(--ink) 100%),
linear-gradient(to top, rgba(44, 58, 82, 0) 78%, rgba(44, 58, 82, 0.45) 100%);
.hero-attendees {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 6px;
align-items: stretch;
}
.hero-attendee {
display: flex;
align-items: center;
gap: 8px;
justify-content: flex-end;
}
.hero-attendee-name {
font-family: var(--font-sans);
font-size: 11px;
color: var(--on-ink-muted);
}
.hero-attendee-overflow {
width: 18px;
height: 18px;
border-radius: 50%;
background: rgba(255, 252, 247, 0.15);
color: var(--on-ink);
font-family: var(--font-sans);
font-size: 9px;
font-weight: 600;
display: inline-flex;
align-items: center;
justify-content: center;
}
/* ── Bottom strip ────────────────────────────────────────────── */
.hero-foot {
position: relative;
z-index: 1;
margin-top: auto; /* pin to the bottom of the taller card */
border-top: 0.5px solid var(--ink-divider);
padding-top: 22px;
display: flex;
@ -276,7 +241,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
}
.hero-status {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-ink-muted);
@ -293,7 +258,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
@ -333,7 +298,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
border: none;
color: var(--on-ink-muted);
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
cursor: pointer;
@ -343,7 +308,9 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
/* ── Empty state ─────────────────────────────────────────────── */
.hero--empty {
min-height: 320px;
align-items: flex-start;
min-height: 200px;
justify-content: center;
}
.hero-empty {
font-family: var(--font-serif);
@ -357,14 +324,7 @@ const startTime = event ? fmt({ hour: '2-digit', minute: '2-digit', hour12: fa
/* ── Responsive ───────────────────────────────────────────────── */
@media (max-width: 880px) {
.hero-top { grid-template-columns: 1fr; }
}
/* Phone tuning: lighter padding, smaller display date, full-width copy. */
@media (max-width: 480px) {
.hero { padding: 24px 22px 22px; gap: 22px; min-height: 440px; }
.hero-greeting-line { font-size: 27px; }
.hero-day { font-size: 64px; }
.hero-title { font-size: 22px; }
.hero-desc { max-width: none; }
.hero-foot { gap: 12px; }
.hero-meta { align-items: flex-start; }
.hero-duration, .hero-attendee { justify-content: flex-start; }
}
</style>

View file

@ -85,14 +85,14 @@ const authorRole = latest?.author_title ?? 'team';
}
.rr-dispatch-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: 1.6px;
text-transform: uppercase;
color: var(--on-surface-variant);
}
.rr-dispatch-kind {
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: 0.8px;
padding: 2px 8px;
border-radius: 3px;
@ -106,7 +106,7 @@ const authorRole = latest?.author_title ?? 'team';
.rr-dispatch-all {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: 1px;
color: var(--on-surface-variant);
text-transform: uppercase;
@ -132,13 +132,13 @@ const authorRole = latest?.author_title ?? 'team';
}
.rr-dispatch-text { max-width: 720px; }
.rr-dispatch-p1 {
font-size: 15px;
font-size: 14px;
line-height: 1.7;
color: var(--on-surface);
margin: 0 0 10px;
}
.rr-dispatch-p2 {
font-size: 15px;
font-size: 14px;
line-height: 1.7;
color: var(--on-surface-variant);
margin: 0;
@ -158,12 +158,12 @@ const authorRole = latest?.author_title ?? 'team';
}
.rr-dispatch-author-text { text-align: right; }
.rr-dispatch-author-name {
font-size: 14px;
font-size: 13px;
margin: 0;
color: var(--on-surface);
}
.rr-dispatch-author-role {
font-size: 12px;
font-size: 11px;
margin: 1px 0 0;
color: var(--on-surface-variant);
}
@ -178,12 +178,12 @@ const authorRole = latest?.author_title ?? 'team';
justify-content: center;
font-family: var(--font-serif);
font-style: italic;
font-size: 15px;
font-size: 14px;
flex-shrink: 0;
}
.rr-dispatch-cta {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: 1.2px;
color: var(--pigment-terracotta);
text-transform: uppercase;

View file

@ -78,7 +78,7 @@ const tags = readFocusTags(member.focus_tags);
font-family: var(--font-serif);
font-style: italic;
font-weight: 700;
font-size: 14px;
font-size: 13px;
line-height: 1;
flex-shrink: 0;
}

View file

@ -10,7 +10,6 @@ const { items } = Astro.props;
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
@ -18,7 +17,6 @@ const STATUS_LABEL: Record<RoadmapStatus, string> = {
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
planned: 'var(--pigment-indigo)',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
@ -26,7 +24,6 @@ const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: 'var(--pigment-copper)',
in_beta: 'var(--pigment-terracotta)',
planned: 'var(--pigment-indigo)',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
@ -149,7 +146,7 @@ const hasArrows = items.length > 3;
}
.roadmap-all {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -233,7 +230,7 @@ const hasArrows = items.length > 3;
}
.card-status-label {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
}
@ -246,7 +243,7 @@ const hasArrows = items.length > 3;
margin: 0;
}
.card-desc {
font-size: 13px;
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;

View file

@ -15,39 +15,28 @@ const CONTENT_MAX = 1152;
const DEFAULT_PADDING = 60;
const paddingLeft = Math.max(DEFAULT_PADDING, (viewportWidth - CONTENT_MAX) / 2);
// Trailing room past the last milestone = a quarter of the viewport, so the
// final item can scroll until it sits halfway between the right edge and the
// screen centre (~0.75 of the viewport). The drawn line extends the same
// distance so it keeps going as that item arrives. (Client recompute redoes
// this with the real viewport; this is the SSR fallback.)
const trailing = Math.round(viewportWidth * 0.25);
const layout = computeRouteLayout({
itemCount: items.length,
viewportWidth,
paddingLeft,
paddingRight: trailing,
tailLength: trailing,
});
const travelledStop = travelledStopFor(items.map(i => i.status));
const STATUS_LABEL: Record<RoadmapStatus, string> = {
shipping: 'SHIPPING',
in_beta: 'IN BETA',
planned: 'PLANNED',
exploring: 'EXPLORING',
considering: 'CONSIDERING',
};
const STATUS_LABEL_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
planned: '#5a6d83',
exploring: '#b4b2a9',
considering: '#b4b2a9',
};
const STATUS_DOT_COLOR: Record<RoadmapStatus, string> = {
shipping: '#6d8c7c',
in_beta: '#b96b58',
planned: '#5a6d83',
exploring: '#b4b2a9',
considering: '#d4d2c8',
};
@ -180,7 +169,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const MIN_SPACING = 320;
const PADDING_X = 60;
const CONTENT_MAX = 1152; // matches --content-max (72rem)
const MID_Y = 210; // vertical centreline = track height (420) / 2
document.querySelectorAll<HTMLElement>('.route').forEach((section) => {
const scroll = section.querySelector<HTMLElement>('#rr-scroll');
@ -196,9 +184,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const itemCount = milestones.length;
const itemY: number[] = milestones.map(m => Number(m.dataset.y ?? 0));
// Current horizontal positions, kept in sync by recompute() — used by
// the scroll-proximity focus effect.
let itemXs: number[] = [];
/** Recompute trackWidth + itemX[] + pathD using the live viewport. */
function recompute() {
@ -209,10 +194,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
// Match the SSR offset — first item aligns with the content-column
// left edge so the route lines up with the dispatch banner below.
const paddingLeft = Math.max(PADDING_X, (vw - CONTENT_MAX) / 2);
// Trailing room = a quarter of the viewport so the final milestone can
// scroll until it sits halfway to the screen centre (~0.75 of vw).
const trailing = Math.round(vw * 0.25);
const trackWidth = paddingLeft + usableWidth + trailing;
const trackWidth = paddingLeft + usableWidth + PADDING_X;
const itemX: number[] = [];
for (let i = 0; i < itemCount; i += 1) {
@ -233,13 +215,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const cx = (itemX[i - 1] + itemX[i]) / 2;
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the line past the last milestone, easing
// back to the centreline so it keeps going as that item scrolls in.
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + trailing;
const tcx = (lastX + tailEndX) / 2;
d += ` C ${tcx} ${lastY}, ${tcx} ${MID_Y}, ${tailEndX} ${MID_Y}`;
}
// Apply.
@ -247,25 +222,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
svg!.setAttribute('width', String(trackWidth));
if (pathD && d) pathD.setAttribute('d', d);
milestones.forEach((m, i) => { m.style.left = `${itemX[i]}px`; });
itemXs = itemX;
}
/* Scroll-proximity focus: emphasise the milestone nearest the centre
of the viewport and let those toward the edges recede + dim. Driven
every frame that the track moves (via updateNav), so movement feels
alive rather than a flat pan. Not parallax — every milestone still
tracks the scroll 1:1; only scale + opacity shift with position. */
function updateFocus() {
if (!scroll || itemXs.length === 0) return;
const center = scroll.scrollLeft + scroll.clientWidth / 2;
const half = Math.max(1, scroll.clientWidth / 2);
milestones.forEach((m, i) => {
const t = Math.min(1, Math.abs((itemXs[i] ?? 0) - center) / half);
const scale = (1 - 0.10 * t).toFixed(3);
const op = (1 - 0.42 * t).toFixed(3);
m.style.transform = `translate(-50%, -50%) scale(${scale})`;
m.style.opacity = op;
});
}
/* Edge state — fades + advance disable. */
@ -276,7 +232,6 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
if (fadeL) fadeL.style.opacity = atStart ? '0' : '1';
if (fadeR) fadeR.style.opacity = atEnd ? '0' : '1';
if (advance) advance.classList.toggle('rr-at-end', atEnd);
updateFocus();
}
/* ── Unified scroll handling: wheel, drag, animated glide. ──
@ -293,13 +248,10 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
let velocity = 0; // px/ms, signed (positive = pointer moving right)
let momentumRAF: number | null = null;
let animateRAF: number | null = null;
let wheelRAF: number | null = null;
let wheelTarget = 0; // eased target scrollLeft for wheel/trackpad input
function cancelAnims() {
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
if (wheelRAF !== null) { cancelAnimationFrame(wheelRAF); wheelRAF = null; }
}
function animateScrollTo(target: number, durationMs: number) {
@ -324,34 +276,9 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
if (dx === 0) return;
e.preventDefault();
// Drop any drag-momentum or arrow glide that's mid-flight, but keep
// building onto the wheel target so quick successive ticks accumulate
// distance and the glide stays continuous.
if (momentumRAF !== null) { cancelAnimationFrame(momentumRAF); momentumRAF = null; }
if (animateRAF !== null) { cancelAnimationFrame(animateRAF); animateRAF = null; }
const max = scroll!.scrollWidth - scroll!.clientWidth;
const base = wheelRAF !== null ? wheelTarget : scroll!.scrollLeft;
wheelTarget = Math.max(0, Math.min(max, base + dx));
// Ease scrollLeft toward the target each frame (~0.2 of the remaining
// distance), so the wheel feels like a smooth glide rather than a jump.
if (wheelRAF === null) {
const step = () => {
const diff = wheelTarget - scroll!.scrollLeft;
if (Math.abs(diff) < 0.5) {
scroll!.scrollLeft = wheelTarget;
wheelRAF = null;
cancelAnims();
scroll!.scrollLeft += dx;
updateNav();
return;
}
scroll!.scrollLeft += diff * 0.2;
updateNav();
wheelRAF = requestAnimationFrame(step);
};
wheelRAF = requestAnimationFrame(step);
}
}, { passive: false });
// Drag — pointer events; momentum on release.
@ -500,22 +427,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-milestone {
position: absolute;
/* Inline transform/opacity are driven per-frame from JS based on each
milestone's distance from the viewport centre, so the track comes
alive as you move it (centre milestone emphasised, edges recede).
The short ease softens the per-frame updates into a glide. */
transform: translate(-50%, -50%);
transition: transform .2s ease-out, opacity .2s ease-out;
will-change: transform, opacity;
}
/* A hovered/focused card always reads at full size and brightness,
regardless of where it sits along the route — overrides the inline
focus styles JS sets. */
.rr-milestone:has(.rr-card:hover),
.rr-milestone:has(.rr-card:focus-visible) {
transform: translate(-50%, -50%) scale(1) !important;
opacity: 1 !important;
z-index: 10;
}
.rr-dot {
@ -562,8 +474,8 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-card {
display: block;
width: 240px;
padding: 14px 16px;
width: 220px;
padding: 12px 14px;
border-radius: 10px;
background: transparent;
color: inherit;
@ -589,16 +501,16 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
.rr-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0 0 7px;
margin: 0 0 6px;
font-weight: 600;
}
.rr-card-title {
font-family: var(--font-serif);
font-size: 20px;
line-height: 1.25;
font-size: 16px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
@ -614,20 +526,20 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rr-card:hover .rr-more,
.rr-card:focus-visible .rr-more {
max-height: 340px;
max-height: 280px;
opacity: 1;
margin-top: 12px;
margin-top: 10px;
}
.rr-desc {
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.6;
font-size: 12px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0 0 10px;
}
.rr-trail {
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);
@ -746,7 +658,7 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rrm-eyebrow {
font-family: var(--font-sans);
font-size: 11px;
font-size: 9px;
letter-spacing: 1.4px;
text-transform: uppercase;
margin: 0;
@ -754,21 +666,21 @@ const initialShippingX = lastShippingIndex >= 0 ? layout.itemX[lastShippingIndex
}
.rrm-title {
font-family: var(--font-serif);
font-size: 21px;
line-height: 1.25;
font-size: 18px;
line-height: 1.2;
color: var(--on-surface);
margin: 0;
}
.rrm-desc {
font-family: var(--font-sans);
font-size: 15px;
line-height: 1.6;
font-size: 13px;
line-height: 1.55;
color: var(--on-surface-variant);
margin: 0;
}
.rrm-trail {
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: 1px;
text-transform: uppercase;
color: var(--on-surface-muted);

View file

@ -31,21 +31,7 @@ const year = new Date().getFullYear();
<span class="wordmark-project">Project <em class="wordmark-bifrost">Bifrost</em></span>
</a>
<button
type="button"
class="nav-toggle"
id="nav-toggle"
aria-label="Menu"
aria-controls="nav-menu"
aria-expanded="false"
>
<svg viewBox="0 0 24 24" width="22" height="22" aria-hidden="true">
<path class="nav-toggle-bars" d="M3 6h18M3 12h18M3 18h18" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
<path class="nav-toggle-x" d="M5 5l14 14M19 5L5 19" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
<nav class="nav-right" id="nav-menu" aria-label="Main navigation">
<nav class="nav-right" aria-label="Main navigation">
{navLinks.map(({ href, label }) => (
<a
href={href}
@ -135,41 +121,40 @@ const year = new Date().getFullYear();
color: var(--on-surface);
}
.wordmark {
height: 30px; /* 50% larger than the prior 20px lockup */
height: 20px;
width: auto;
display: block;
}
.wordmark-sep {
/* Flex-centred against the logo height so the dot sits on the vertical
middle of the "Fenja AI" logo. */
display: inline-flex;
align-items: center;
height: 30px;
color: var(--on-surface-muted);
font-family: var(--font-serif);
font-size: 22px;
font-size: 18px;
line-height: 1;
/* Optical kern — the bullet's typographic centre sits slightly above
its baseline in Newsreader; this nudges it onto the visual midline. */
transform: translateY(-2px);
}
/* Project (regular) + Bifrost (italic) share a baseline. Italic Newsreader
renders a touch taller at the same size, so Bifrost is set 1px smaller so
the two words read at the same cap height. inline-block + small symmetric
padding keeps the gradient-clip bbox from chopping the italic flourish. */
/* Italic Newsreader renders ~10% visually taller than regular at the
same font-size — the cursive B has a flourish extending past the
cap line. Drop Bifrost to 16px so its cap+flourish optical height
matches Project's 18px cap, and use inline-block + tiny vertical
padding so the gradient-clip bbox doesn't chop the flourish off. */
.wordmark-project,
.wordmark-bifrost {
font-family: var(--font-serif);
font-weight: 400;
letter-spacing: var(--tracking-snug);
line-height: 1;
line-height: 1.4;
}
.wordmark-project {
font-size: 20px;
font-size: 18px;
color: var(--on-surface);
}
.wordmark-bifrost {
display: inline-block;
font-size: 19px;
font-size: 16px;
font-style: italic;
padding: 2px 0;
padding: 3px 0 1px;
vertical-align: baseline;
background-image: linear-gradient(
90deg,
@ -184,23 +169,6 @@ const year = new Date().getFullYear();
color: transparent;
}
/* ── Mobile menu toggle (hidden on desktop) ─────────────────────── */
.nav-toggle {
display: none;
margin-left: auto;
width: 40px;
height: 40px;
align-items: center;
justify-content: center;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--on-surface);
border-radius: var(--radius-sm);
}
.nav-toggle .nav-toggle-x { display: none; }
/* ── Nav links ──────────────────────────────────────────────────── */
.nav-right {
display: flex;
@ -334,80 +302,4 @@ const year = new Date().getFullYear();
color: var(--on-surface-variant);
border-bottom: none;
}
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.nav-inner { padding: 0 var(--space-5); gap: var(--space-3); }
.nav-toggle { display: inline-flex; }
.nav.open .nav-toggle-bars { display: none; }
.nav.open .nav-toggle-x { display: inline; }
/* Right-hand nav becomes a full-width dropdown under the bar. */
.nav-right {
position: absolute;
top: 56px;
left: 0;
right: 0;
margin-left: 0;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: var(--space-2) var(--space-5) var(--space-4);
background: var(--glass-surface);
backdrop-filter: var(--glass-blur);
-webkit-backdrop-filter: var(--glass-blur);
border-bottom: var(--ghost-border);
box-shadow: var(--shadow-float);
display: none;
}
.nav.open .nav-right { display: flex; }
/* Comfortable tap targets in the dropdown. */
.nav-right .nav-link,
.nav-right .nav-user-name,
.nav-right .logout-btn {
display: flex;
align-items: center;
min-height: 44px;
width: 100%;
padding: var(--space-2) var(--space-2);
font-size: var(--text-body-md);
border-radius: var(--radius-sm);
}
.nav-divider {
width: auto;
height: 1px;
margin: var(--space-2) 0;
transform: scaleY(0.5);
}
.nav-logout-form { width: 100%; }
.logout-btn { justify-content: flex-start; }
/* Footer stacks. */
.footer-inner {
flex-direction: column;
align-items: flex-start;
gap: var(--space-4);
padding: 0 var(--space-5);
}
}
</style>
<script>
// Mobile nav: toggle the dropdown, keep aria in sync, close on link tap or
// when the viewport grows back to desktop.
const nav = document.querySelector<HTMLElement>('.nav');
const toggle = nav?.querySelector<HTMLButtonElement>('#nav-toggle');
const menu = nav?.querySelector<HTMLElement>('#nav-menu');
function setOpen(open: boolean) {
if (!nav || !toggle) return;
nav.classList.toggle('open', open);
toggle.setAttribute('aria-expanded', open ? 'true' : 'false');
}
toggle?.addEventListener('click', () => setOpen(!nav!.classList.contains('open')));
menu?.querySelectorAll('a').forEach((a) => a.addEventListener('click', () => setOpen(false)));
window.addEventListener('resize', () => { if (window.innerWidth > 767) setOpen(false); });
</script>

View file

@ -152,15 +152,6 @@ export function updateUserProfile(id: number, name: string, bio: string): void {
db.prepare('UPDATE users SET name = ?, bio = ? WHERE id = ?').run(name, bio, id);
}
/** Update a user's email their login identity. Throws if the address is
* already used by another account (the column is UNIQUE). */
export function updateUserEmail(id: number, email: string): void {
const clash = db.prepare('SELECT id FROM users WHERE email = ? AND id != ?')
.get(email, id) as { id: number } | undefined;
if (clash) throw new Error('That email is already in use by another account.');
db.prepare('UPDATE users SET email = ? WHERE id = ?').run(email, id);
}
/** Returns the newly-allocated member_number when the transition lands on
* cab and the user had none; null otherwise. Callers may ignore. */
export function updateUserRole(id: number, role: Role): { allocated: number | null } {
@ -222,8 +213,7 @@ export function updateUserAdminFields(id: number, data: {
export function slugifyName(name: string): string {
return name
.toLowerCase()
.replace(/ø/g, 'o').replace(/æ/g, 'ae').replace(/å/g, 'a') // Danish letters NFKD leaves intact
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip remaining diacritics
.normalize('NFKD').replace(/[̀-ͯ]/g, '') // strip diacritics
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
}
@ -698,7 +688,7 @@ export function countPulseParticipants(pulseId: number): number {
// ── Roadmap items ────────────────────────────────────────────────
export type RoadmapStatus = 'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering';
export type RoadmapStatus = 'shipping' | 'in_beta' | 'exploring' | 'considering';
export interface RoadmapItem {
id: number;

View file

@ -22,9 +22,6 @@ export interface LayoutOpts {
paddingX?: number; // default 60 — symmetric leading + trailing padding
paddingLeft?: number; // overrides paddingX on the leading edge only
paddingRight?: number; // overrides paddingX on the trailing edge only
tailLength?: number; // px to extend the drawn path past the final
// milestone, easing back to the centreline — lets
// the line keep going as the last item scrolls in
}
export interface LayoutResult {
@ -106,17 +103,6 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
d += ` C ${cx} ${itemY[i - 1]}, ${cx} ${itemY[i]}, ${itemX[i]} ${itemY[i]}`;
}
// Trailing tail: continue the path past the last milestone, easing it back
// to the centreline so the line keeps going while the final item scrolls
// toward the middle. Tangent stays flat at the last dot (control y = lastY).
if (opts.tailLength && opts.tailLength > 0) {
const lastX = itemX[itemCount - 1];
const lastY = itemY[itemCount - 1];
const tailEndX = lastX + opts.tailLength;
const cx = (lastX + tailEndX) / 2;
d += ` C ${cx} ${lastY}, ${cx} ${midY}, ${tailEndX} ${midY}`;
}
return { trackWidth, pathD: d, itemX, itemY, cardSide, midY };
}
@ -127,7 +113,7 @@ export function computeRouteLayout(opts: LayoutOpts): LayoutResult {
* - Clamped to [0, 0.98] so the fade-to-ahead is always visible
*/
export function travelledStopFor(
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'planned' | 'exploring' | 'considering'>,
statuses: ReadonlyArray<'shipping' | 'in_beta' | 'exploring' | 'considering'>,
): number {
if (statuses.length === 0) return 0;
let last = -1;

View file

@ -1,35 +0,0 @@
/* ---------------------------------------------------------------------------
* Event photo uploads.
*
* Files are written outside the build output (so they survive redeploys) and
* served back through the /uploads/[file] route. Path defaults to
* <cwd>/data/uploads; override with BIFROST_UPLOAD_DIR on the VPS.
*
* Scope: admin-only event photos (see SPEC §8 exception). Participants cannot
* upload.
* ------------------------------------------------------------------------- */
import { join } from 'node:path';
export const UPLOAD_DIR =
process.env.BIFROST_UPLOAD_DIR ?? join(process.cwd(), 'data', 'uploads');
/** Accepted MIME types → file extension. */
export const ALLOWED_IMAGE_TYPES = new Map<string, string>([
['image/png', 'png'],
['image/jpeg', 'jpg'],
]);
export const MAX_UPLOAD_BYTES = 5 * 1024 * 1024; // 5 MB
/** Guards the served filename against path traversal. */
export function isSafeUploadName(name: string): boolean {
return /^[a-z0-9][a-z0-9._-]*$/i.test(name) && !name.includes('..');
}
/** Content-type for a stored upload, by extension. */
export const UPLOAD_CONTENT_TYPES: Record<string, string> = {
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
};

View file

@ -293,8 +293,4 @@ const saved = Astro.url.searchParams.get('saved') === '1';
color: var(--on-surface-muted);
margin: 0;
}
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style>

View file

@ -1,55 +0,0 @@
/* ---------------------------------------------------------------------------
* POST /api/admin/upload fenja-only event photo upload.
*
* Accepts multipart/form-data with a single `file` (png/jpg, 5 MB), writes it
* to the upload dir under a random name, and returns { url } pointing at the
* /uploads/[file] serve route. The caller (admin image-upload field) stores
* that url in the event's photo_url.
* ------------------------------------------------------------------------- */
import type { APIRoute } from 'astro';
import { mkdir, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { randomUUID } from 'node:crypto';
import { UPLOAD_DIR, ALLOWED_IMAGE_TYPES, MAX_UPLOAD_BYTES } from '../../../lib/uploads';
export const prerender = false;
function json(body: unknown, status: number): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
export const POST: APIRoute = async ({ locals, request }) => {
if (locals.user?.role !== 'fenja') return json({ error: 'Forbidden' }, 403);
let form: FormData;
try {
form = await request.formData();
} catch {
return json({ error: 'Expected multipart form data.' }, 400);
}
const file = form.get('file');
if (!(file instanceof File) || file.size === 0) {
return json({ error: 'No file provided.' }, 400);
}
const ext = ALLOWED_IMAGE_TYPES.get(file.type);
if (!ext) return json({ error: 'Only PNG or JPG images are allowed.' }, 415);
if (file.size > MAX_UPLOAD_BYTES) {
return json({ error: 'Image must be 5 MB or smaller.' }, 413);
}
const name = `${randomUUID()}.${ext}`;
try {
await mkdir(UPLOAD_DIR, { recursive: true });
await writeFile(join(UPLOAD_DIR, name), Buffer.from(await file.arrayBuffer()));
} catch {
return json({ error: 'Could not save the image. Try again.' }, 500);
}
return json({ url: `/uploads/${name}` }, 200);
};

View file

@ -524,10 +524,4 @@ const typeColors: Record<ContributionType, string> = {
white-space: nowrap;
border-width: 0;
}
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
/* Filter + sort tab groups wrap instead of overflowing. */
.feed-controls { flex-wrap: wrap; gap: var(--space-3); }
}
</style>

View file

@ -191,8 +191,4 @@ if (Astro.request.method === 'POST') {
transition: opacity var(--duration-fast) var(--ease-standard);
}
.btn-primary:hover { opacity: 0.9; }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style>

View file

@ -38,8 +38,4 @@ const user = Astro.locals.user;
.page-title { margin: 0; }
.reading-col { max-width: var(--reading-max); }
.lead { color: var(--on-surface-variant); }
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style>

View file

@ -46,7 +46,7 @@ const { prev, next } = getAdjacentDispatches(d.id);
function closeDayLabel(closesAt: string): string {
const parsed = closesAt.includes('T') ? new Date(closesAt) : new Date(closesAt.replace(' ', 'T') + 'Z');
return new Intl.DateTimeFormat('en-GB', { day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
return new Intl.DateTimeFormat('en-GB', { weekday: 'long', timeZone: 'Europe/Copenhagen' }).format(parsed);
}
function parseUtc(s: string): Date {
@ -268,8 +268,8 @@ const bodyHtml = renderMd(d.body);
/* ── Inline poll attached to the dispatch ──────────────────────── */
.inline-poll {
margin-top: var(--space-7);
background: rgba(185, 107, 88, 0.08);
border: none;
background: var(--surface-card);
border: 0.5px solid var(--surface-card-border);
border-radius: var(--radius-lg);
padding: var(--space-6);
display: flex;
@ -399,7 +399,6 @@ const bodyHtml = renderMd(d.body);
.adj-empty {} /* placeholder for missing prev/next slot */
@media (max-width: 640px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.adjacent { grid-template-columns: 1fr; }
.adj-next { text-align: left; align-items: flex-start; }
.adj-next .adj-kind-pill { align-self: flex-start; }

View file

@ -170,7 +170,6 @@ function fmt(iso: string): string {
}
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.d-link {
grid-template-columns: 1fr;
gap: var(--space-3);

View file

@ -504,7 +504,6 @@ const heroAudience = hero?.audience ?? 'Members only';
}
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.hero-body { grid-template-columns: 1fr; }
.hero-body::after { display: none; }
.past-grid { grid-template-columns: 1fr; }

View file

@ -154,7 +154,6 @@ function fmt(part: Intl.DateTimeFormatOptions, iso: string): string {
}
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.past-list { grid-template-columns: 1fr; }
}
</style>

View file

@ -563,12 +563,4 @@ const innofoundarLogoExists = existsSync(join(process.cwd(), 'public/innofounder
margin: 0;
line-height: var(--leading-relaxed);
}
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
/* Stack the two-column rows. */
.fenja-row,
.ask-grid { grid-template-columns: 1fr; }
}
</style>

View file

@ -199,7 +199,6 @@ function memberSinceLabel(member: { cab_joined_date: string | null; created_at:
}
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
.m-row { grid-template-columns: 52px 1fr; }
.m-tags {
grid-column: 1 / -1;

View file

@ -421,13 +421,4 @@ const tiers = [
margin: 0;
line-height: var(--leading-relaxed);
}
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
/* Stack the label / content / tag rows. */
.arch-row,
.tier-row,
.extends-item { grid-template-columns: 1fr; gap: var(--space-2); }
}
</style>

View file

@ -96,7 +96,7 @@ const featuredPreview = featured ? dispatchLongPreview(featured, 720) : '';
function closeDayLabel(closesAt: string): string {
return new Intl.DateTimeFormat('en-GB', {
day: 'numeric', month: 'long', timeZone: 'Europe/Copenhagen',
weekday: 'long', timeZone: 'Europe/Copenhagen',
}).format(parseUtc(closesAt));
}
@ -110,16 +110,26 @@ const members = getAllCabMembers();
<AppLayout title="Pulse" user={user}>
<div class="page">
<!-- ── Hero event card (--ink) — greeting now lives inside it ──── -->
<!-- ── Greeting ─────────────────────────────────────────────── -->
<section class="cascade greeting">
<div class="greeting-left">
<h1 class="greeting-line">{greetingPrefix}<em class="greeting-first">{firstName}</em>.</h1>
</div>
{memberNumberLabel && (
<div class="greeting-right">
<p class="greeting-member">{memberNumberLabel}</p>
<p class="greeting-circle">Founding circle</p>
</div>
)}
</section>
<!-- ── Hero event card (--ink) ──────────────────────────────── -->
<section class="cascade hero-slot" aria-label="Next gathering">
<EventHeroCard
event={hero}
attendees={heroAttendees}
confirmedCount={heroConfirmedCount}
myRsvp={heroMyRsvp}
greetingPrefix={greetingPrefix}
firstName={firstName}
memberLabel={memberNumberLabel}
/>
</section>
@ -281,7 +291,8 @@ const members = getAllCabMembers();
(96px between editorial / roadmap / council); editorial-row internal
dispatch → 'Earlier' gap stays tight at the original 48px because
they're the same story. */
.hero-slot { margin-top: 24px; } /* first section, below nav */
.greeting { margin-top: 64px; } /* below nav */
.hero-slot { margin-top: 80px; } /* greeting → hero */
.also-coming-up { margin-top: 18px; } /* hero → also (tight; pair) */
.editorial-row { margin-top: 96px; } /* also → editorial */
.roadmap-wrap { margin-top: 96px; } /* editorial → roadmap */
@ -333,7 +344,7 @@ const members = getAllCabMembers();
.greeting-first { font-style: italic; }
.greeting-member {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
@ -341,19 +352,14 @@ const members = getAllCabMembers();
}
.greeting-circle {
font-family: var(--font-serif);
font-size: 15px;
font-size: 14px;
color: var(--on-surface);
margin: 0;
}
@media (max-width: 720px) {
.page { padding: 32px 20px 64px; }
.greeting { grid-template-columns: 1fr; align-items: start; }
.greeting-right { align-items: flex-start; }
/* "Also coming up" strip stacks and wraps instead of overflowing. */
.also-coming-up { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
.also-list { flex-wrap: wrap; gap: 12px 18px; }
.also-title { max-width: 70vw; }
}
/* ── 'Also coming up' strip (plain text on cream) ─────────────── */
@ -397,7 +403,7 @@ const members = getAllCabMembers();
}
.also-eyebrow {
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -405,13 +411,13 @@ const members = getAllCabMembers();
}
.also-month-kind {
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
}
.also-title {
font-size: 14px;
font-size: 13px;
color: var(--on-surface);
white-space: nowrap;
overflow: hidden;
@ -420,7 +426,7 @@ const members = getAllCabMembers();
}
.also-link {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -446,13 +452,13 @@ const members = getAllCabMembers();
}
.dispatch-byline-name {
font-family: var(--font-sans);
font-size: 13px;
font-size: 12px;
font-weight: 600;
color: var(--on-surface);
}
.dispatch-byline-time {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
color: var(--on-surface-variant);
}
.dispatch-kind-pill {
@ -461,7 +467,7 @@ const members = getAllCabMembers();
padding: 2px 8px;
border-radius: 3px;
font-family: var(--font-sans);
font-size: 10px;
font-size: 9px;
letter-spacing: var(--tracking-wider);
font-weight: 600;
margin-left: auto;
@ -477,7 +483,7 @@ const members = getAllCabMembers();
}
.dispatch-para-lead {
margin: var(--space-2) 0 0;
font-size: 15px;
font-size: 14px;
line-height: 1.7;
color: var(--on-surface);
}
@ -485,7 +491,7 @@ const members = getAllCabMembers();
margin-top: var(--space-2);
align-self: flex-start;
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -505,7 +511,7 @@ const members = getAllCabMembers();
}
.earlier-label {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
@ -531,34 +537,26 @@ const members = getAllCabMembers();
.earlier-link:hover { border-bottom: none; opacity: 0.7; }
.earlier-title {
font-family: var(--font-serif);
font-size: 16px;
font-size: 15px;
line-height: 1.3;
}
.earlier-meta {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
white-space: nowrap;
}
/* Pulse column — tinted panel so the vote clearly stands apart from the
dispatch beside it. Tonal colour shift, no border (per design system). */
.pulse-col {
display: flex;
flex-direction: column;
gap: 14px;
background: rgba(185, 107, 88, 0.08);
border-radius: var(--radius-lg);
padding: var(--space-5) var(--space-5) var(--space-6);
}
/* Pulse column */
.pulse-col { display: flex; flex-direction: column; gap: 14px; }
.pulse-eyebrow {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -580,7 +578,7 @@ const members = getAllCabMembers();
}
.pulse-status {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--on-surface-variant);
@ -601,7 +599,7 @@ const members = getAllCabMembers();
border: none;
border-left: 2px solid rgba(0, 0, 0, 0.1);
font-family: var(--font-sans);
font-size: 14px;
font-size: 13px;
color: var(--on-surface);
text-align: left;
cursor: pointer;
@ -621,7 +619,7 @@ const members = getAllCabMembers();
.pulse-option-letter {
color: var(--on-surface-variant);
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
font-weight: 600;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
@ -635,7 +633,7 @@ const members = getAllCabMembers();
.pulse-option-pct {
margin-left: auto;
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
font-weight: 600;
letter-spacing: var(--tracking-wider);
color: var(--on-surface-variant);
@ -665,7 +663,7 @@ const members = getAllCabMembers();
.council-title em { font-style: italic; }
.council-all {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--pigment-terracotta);
@ -726,21 +724,21 @@ const members = getAllCabMembers();
.council-tile-name {
font-family: var(--font-serif);
font-weight: 400;
font-size: 16px;
font-size: 15px;
line-height: 1.15;
color: var(--on-surface);
white-space: nowrap;
}
.council-tile-title {
font-family: var(--font-sans);
font-size: 12px;
font-size: 11px;
color: var(--on-surface-variant);
line-height: 1.35;
white-space: nowrap;
}
.council-tile-org {
font-family: var(--font-sans);
font-size: 11px;
font-size: 10px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--on-surface-variant);

View file

@ -28,7 +28,6 @@ const items = getAllRoadmapItems()
<div class="roadmap-legend" aria-label="Status legend">
<span><i style="background:#6d8c7c"></i>Shipping</span>
<span><i style="background:#b96b58"></i>In beta</span>
<span><i style="background:#5a6d83"></i>Planned</span>
<span><i style="background:#b4b2a9"></i>Exploring</span>
<span><i style="background:#d4d2c8"></i>Considering</span>
</div>

View file

@ -240,8 +240,4 @@ const next = sorted[currentIndex - 1] ?? null;
.post-nav-link:hover .nav-title {
color: var(--on-surface);
}
@media (max-width: 767px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
}
</style>

View file

@ -168,11 +168,4 @@ const updates = allUpdates.sort(
color: var(--on-surface-variant);
border-bottom: none;
}
/* ── Mobile (≤720px) ────────────────────────────────────────────── */
@media (max-width: 720px) {
.page { padding: var(--space-8) var(--space-5) var(--space-12); }
/* Date stacks above the content instead of a cramped 9rem column. */
.update-item { grid-template-columns: 1fr; gap: var(--space-2); }
}
</style>

View file

@ -1,35 +0,0 @@
/* ---------------------------------------------------------------------------
* GET /uploads/[file] serve an uploaded event photo from the upload dir.
*
* Runtime-written files live outside the build output, so they're streamed
* here rather than served as static assets. Gated by the global auth
* middleware (viewers are logged in; the <img> request carries the session
* cookie). Filename is validated to prevent path traversal.
* ------------------------------------------------------------------------- */
import type { APIRoute } from 'astro';
import { readFile } from 'node:fs/promises';
import { join, extname } from 'node:path';
import { UPLOAD_DIR, isSafeUploadName, UPLOAD_CONTENT_TYPES } from '../../lib/uploads';
export const prerender = false;
export const GET: APIRoute = async ({ params }) => {
const file = params.file ?? '';
if (!isSafeUploadName(file)) return new Response('Not found', { status: 404 });
const type = UPLOAD_CONTENT_TYPES[extname(file).toLowerCase()];
if (!type) return new Response('Not found', { status: 404 });
try {
const buf = await readFile(join(UPLOAD_DIR, file));
return new Response(new Uint8Array(buf), {
headers: {
'Content-Type': type,
'Cache-Control': 'public, max-age=31536000, immutable',
},
});
} catch {
return new Response('Not found', { status: 404 });
}
};

View file

@ -289,10 +289,4 @@ const user = Astro.locals.user;
color: var(--on-surface-variant);
margin: 0;
}
/* ── Mobile (≤767px) ────────────────────────────────────────────── */
@media (max-width: 767px) {
.page { padding: 0 var(--space-5) var(--space-12); }
.bifrost-context-section { grid-template-columns: 1fr; gap: var(--space-3); }
}
</style>

View file

@ -71,14 +71,14 @@
--text-display-md: clamp(2.5rem, 4vw, 3.5rem);
--text-headline-lg: 2.25rem;
--text-headline-md: 1.75rem;
--text-headline-sm: 1.4375rem; /* 23px (was 22) */
--text-title-lg: 1.1875rem; /* 19px (was 18) */
--text-title-md: 1.0625rem; /* 17px (was 16) */
--text-body-lg: 1.125rem; /* 18px (was 17) */
--text-body-md: 1.0625rem; /* 17px (was 16) — base body */
--text-body-sm: 0.9375rem; /* 15px (was 14) */
--text-label-md: 0.875rem; /* 14px (was 13) */
--text-label-sm: 0.8125rem; /* 13px (was 12) */
--text-headline-sm: 1.375rem;
--text-title-lg: 1.125rem;
--text-title-md: 1rem;
--text-body-lg: 1.0625rem;
--text-body-md: 1rem;
--text-body-sm: 0.875rem;
--text-label-md: 0.8125rem;
--text-label-sm: 0.75rem;
/* --- Tracking --- */
--tracking-tight: -0.02em;

View file

@ -35,7 +35,6 @@ const KNOWN_FIELD_KINDS: ReadonlySet<Field['kind']> = new Set([
'datetime',
'number',
'readonly',
'image-upload',
]);
const KNOWN_COLUMN_KINDS: ReadonlySet<string> = new Set([