Compare commits

...
4 Commits
119 changed files with 5319 additions and 2481 deletions
+1
View File
@@ -1,3 +1,4 @@
node_modules/ node_modules/
dist/ dist/
.astro/ .astro/
.DS_Store
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<!-- Screenshot probe: headless Chrome clamps --window-size below ~500px, so a
fixed-width iframe gives a true narrow layout viewport. Copied into dist/
by shoot.sh; not part of the site. -->
<meta charset="utf-8">
<title>probe</title>
<style>html,body{margin:0;background:#b0b4c0}iframe{border:0;display:block}</style>
<script>
var q = new URLSearchParams(location.search);
var f = document.createElement('iframe');
f.src = q.get('p') || '/';
f.style.width = (q.get('w') || 390) + 'px';
f.style.height = (q.get('h') || 2000) + 'px';
f.onload = function () {
var s = +(q.get('s') || 0);
// behavior:instant — the site sets scroll-behavior:smooth, which would
// leave the screenshot mid-animation
if (s) f.contentWindow.scrollTo({ top: s, behavior: 'instant' });
};
document.addEventListener('DOMContentLoaded', function () { document.body.appendChild(f); });
</script>
Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 594 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 453 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 595 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 669 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 600 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 346 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 750 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 424 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 535 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

+45
View File
@@ -0,0 +1,45 @@
#!/bin/zsh
# Screenshot helper for the CSS polish pass. $1 = output dir (before|after)
# Narrow widths go through dist/__probe.html (a fixed-width iframe): headless
# Chrome clamps --window-size below ~500px, so 360/390/480 shots would silently
# be 500px-wide renders cropped to the window.
set -e
CH="/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
OUT="$(cd "$(dirname "$0")" && pwd)/$1"
mkdir -p "$OUT"
cp "$(dirname "$0")/__probe.html" "$ROOT/dist/__probe.html"
shot() { # name url w h
"$CH" --headless=new --disable-gpu --hide-scrollbars --window-size=$3,$4 \
--screenshot="$OUT/$1.png" "$2" >/dev/null 2>&1
}
wide() { shot "$1" "http://localhost:4330$2" $3 $4; } # name path w h
narrow() { # name path w h (h = iframe height)
shot "$1" "http://localhost:4330/__probe.html?w=$3&h=$4&p=$2" $(($3 + 20)) $(($4 + 10))
}
W='/news/ctao-and-cabildo-of-la-palma-organise-women-of-ctao-2026'
wide home "/" 1440 2600
narrow home-390 "/" 390 2200
wide news "/news" 1440 2600
narrow news-390 "/news" 390 2400
wide news-768 "/news" 768 2200
wide art6 "/news/6" 1440 2600
narrow art6-390 "/news/6" 390 2600
wide artwomen "$W" 1440 2600
narrow artwomen-390 "$W" 390 2600
wide artwomen-1280 "$W" 1280 2000
wide search "/search?q=lst" 1440 2000
narrow search-390 "/search?q=lst" 390 1800
wide support "/support" 1440 1800
narrow support-360 "/support" 360 1500
wide privacy "/pages/privacy" 1440 1400
narrow privacy-390 "/pages/privacy" 390 1500
wide nf "/404-nonexistent" 1440 1200
narrow nf-390 "/404-nonexistent" 390 1300
wide proposals "/proposals" 1440 1600
narrow proposals-390 "/proposals" 390 1700
wide dashboard "/dashboard" 1440 1600
narrow dashboard-480 "/dashboard" 480 1900
wide login "/login" 1440 1200
narrow login-390 "/login" 390 1100
echo "shots in $OUT"
+27 -332
View File
@@ -1,337 +1,32 @@
# CTAO Science Portal — design system # Design handoff
Single source of truth for look-and-feel. All values live as tokens in All styling lives in **`src/styles/global.css`** — design tokens in `:root`
`src/styles/global.css`; this file states the rules. Brand basis: CTAO Brand (colors, type scale, spacing, radius, shadows, `--header-h`) + plain-CSS
Guidelines (Apr 2024) + techniques observed on ctao.org (photography-led hero, components below them. No frameworks, no preprocessors, no runtime JS for
nebula motion, generous spacing, 15/30/60 rounding, quiet-fade hovers). presentation. Pages are `.astro` templates (plain HTML).
No frameworks, no JS for presentation.
## Color tokens (brand-fixed — never add hues) ## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
| Token | Value | Use | - Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
|---|---|---| `#F5F5F5`, Cosmic Azure `#007AFF` (links/focus), Interstellar Indigo
| `--galaxy` | `#00004A` | Headings, header/footer bands, hero scrim | `#00009C`. Cherenkov is an ACCENT (brand book rations it — currently:
| `--cherenkov` | `#00E4D8` | ONLY: primary CTA, flash, hero subtitle, logo cluster (+ functional: `<mark>`, TOC active rule). Never small text on light bg | primary CTA, hero subtitle, logo flash); it fails AA as text on white.
| `--moon` | `#F5F5F5` | Utility surfaces (bands, neutral hovers) | - Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
| `--azure` | `#007AFF` | Focus rings and field-focus borders ONLY (no hover borders) | communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
| `--indigo` | `#00009C` | Gradient depth, link hover | - Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
| `--text` / `--muted` / `--border` / `--link` | derived | Body ink; `--link #0057C2` is azure darkened to pass AA |
Accent discipline: Cherenkov is rationed to the four brand moments above. ## Practical notes (the non-obvious ones)
Status pills, category/date chips and metadata are plain muted text — the
label carries the meaning, not the chrome.
## Typography - **`public/admin/preview.css` mirrors the `.prose` styles** so the CMS
editor preview matches articles 1:1 — when you touch `.prose` or its
| Role | Font | Rule | tokens, update the mirror (both files carry a KEEP IN SYNC comment).
|---|---|---| - Fonts ship **inlined as data: URIs** in global.css (no font requests, no
| h1, h2 | Space Grotesk (`--font-display`) | Headlines ONLY (brand D.3.2), weight **500** (D.3.4 regular/medium), letter-spacing −0.025em, line-height 1.1 | swap flash). The woff2 files in `public/fonts/` look unused but feed the
| Everything else | Inter (`--font-body`) | Includes h3+ (weight 600), cards, UI | CMS preview — keep them.
| Exception: `.card--featured h3` | Space Grotesk | The news-lead card IS a communicative headline (D.3.2 role, not outline level) — weight 500, `--fs-xl` | - All `animation`/`transition` rules sit in the single
| Exception: `.panel-title` (h2) | Inter | Panel titles are UI labels, not headlines — weight 600, `--fs-l`. These are the ONLY two exceptions | `prefers-reduced-motion: no-preference` block at the end of global.css.
- Scroll-driven effects need `vite.build.cssMinify: 'esbuild'`
Type scale — the ONLY font sizes in the codebase (no ad-hoc rem values): (astro.config.mjs) — the default minifier breaks `animation-timeline`.
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
| Token | Value | Use | progress); change the header height only through the token.
|---|---|---| - Deterministic gate before pushing: `npm run check` (build + link check).
| `--fs-xs` | 0.8125rem | Meta, badges, labels, eyebrow, dates |
| `--fs-s` | 0.9rem | Secondary text, nav, forms, footer, TOC |
| (base) | 1rem | Body, line-height 1.5 (1.65 only on `.prose`; 1.55 `.standfirst`; 1.45 cards/footer) |
| `--fs-l` | 1.125rem | Card titles, standfirst, panel titles, hero subtitle |
| `--fs-xl` | 1.5rem | Prose h2, featured title, auth h1 |
| `--fs-h2` | clamp(1.75–2.5rem) | Section heads |
| `--fs-h1` | clamp(2.25–3.5rem) | Page/article headlines |
| `--fs-display` | clamp(2.5–4.75rem) | Home hero only |
Always end `font-family` with a generic (`sans-serif`) — lint requirement.
Font loading (decided after the navigation-blink incident): self-hosted latin
woff2, preloaded in `Base.astro`, `font-display: swap` + metric-matched Arial
fallbacks for BOTH families (`Inter Fallback`, `Space Grotesk Fallback` —
size-adjust/ascent/descent computed from the real font metrics, capsize
method). Never `optional`: its ~100ms window loses to per-navigation
revalidation (dev/preview serve `Cache-Control: no-cache`), which randomly
committed whole pageviews to Arial — the "fonts flash between navigations"
bug. With swap + matched metrics every page converges on brand fonts and the
swap is layout-neutral.
## Spacing scale (fluid; mobile ≈ min, desktop ≈ max)
| Token | Value | Use |
|---|---|---|
| `--gutter` | `clamp(18px, 4vw, 28px)` | Container/menu side padding |
| `--space-s` | `12px` | Chip gaps, small stacks |
| `--space-m` | `24px` | Card/panel padding, grid gaps |
| `--space-l` | `24–40px` | Article top, meta/cover margins, featured body |
| `--space-xl` | `clamp(56px, 7vw, 120px)` | Section rhythm: every `.page` block, footer top |
| `--space-band` | `clamp(72px, 10vw, 160px)` | Hero/auth band + landing-head padding |
Rule: section/container/card padding comes from this table — no ad-hoc px.
Prose-internal margins (h2/p/blockquote) are content-local and stay as written.
## Radius & elevation
Radius: `--radius 16px` (cards/panels), `--radius-pill` (buttons, chips, inputs).
Only two shadows exist, both Galaxy-tinted, tuned soft/large/low-alpha
(2025/26 elevation convention: big blur, single-digit alpha at rest):
| Token | Use |
|---|---|
| `--shadow-ambient` | Resting cards, panels, search rows |
| `--shadow-lifted` | Hover/focus cards, dropdowns (suggest, menu, search row), auth card |
Never write a literal `box-shadow`; compose focus rings as
`0 0 0 2px var(--azure), var(--shadow-lifted)`.
## Surfaces
ONE surface per page — surface differentiation is by page archetype, never a
mid-page seam (consistency heuristic: a surface change must read as deliberate
system, not "why is this one gray?"):
- **Editorial pages** (`/news`, articles, static pages, home news section) are
white — reading surfaces.
- **Utility/app pages** (`/search`, `/proposals`, `/dashboard`, `/support`)
are `.band--moon` from the landing head down to the footer — the app canvas
against which borderless white cards/panels pop (standard app-vs-editorial
surface split; home's Services section uses the same Moon band).
- `/` and `/login` own the navy bands (photo hero / nebula).
Cards and panels are always white; ON MOON they are borderless (white-on-moon
separates by itself), on white they keep the hairline. Chrome is
single-signal: hover = the elevate gesture (shadow + 2px lift as ONE motion);
no hover borders, no cover zoom.
## Motion policy (CSS-only, Apple-tier restraint)
ctao.org drifts looping nebula videos under Galaxy vignettes; our translation:
1. **Hero glow (home)** — the photo hero keeps its scrimmed photograph; a
`--nebula-glow` layer (Cherenkov .30 / Azure .28, no indigo over
photography) drifts above it inside the `.band-bg` clip frame: 16s
alternate, transform-only, clearly perceptible within ~10s. `.band-bg`
(first child of the band) owns `overflow: hidden` — NEVER put overflow on
the band itself (it would clip the search-suggest dropdown).
2. **Auth nebula (/login)** — the full `--nebula` stack (Cherenkov .38 /
Azure .33 / Indigo .80), same 16s drifting layer; static gradient is the
reduced-motion fallback.
3. **Whole-page ambient** — `.has-ambient::before`: fixed brand-hue wash
(Cherenkov .16 / Azure .12 / Indigo .10) drifting on a 36s alternate
cycle. Opt-in via `<Base ambient>`: **home only**. Body text NEVER sits on
a moving gradient — cards, panels and bands are solid layers above it.
4. One-shot hero `rise` stagger; unified hover system (below).
5. **CSS scroll-driven effects** (2026 pass; `animation-timeline`, no JS —
"no scroll-triggered motion" below means no JS/observer motion): card
reveal on viewport entry (`view()`, entry 0–35%), header shadow fading in
over the first 80px of scroll, article reading-progress bar (2px Cherenkov
under the header — functional navigation state, same budget line as the
TOC scrollspy). All behind `@supports (animation-timeline: view())`,
compositor-only (the header shadow animates a pseudo-element's OPACITY,
never box-shadow in keyframes), auto-killed by the reduce block.
6. Micro-interactions: nav underline-grow (2px white rule, `scaleX` 0→1 on
hover, held on `aria-current`) and 1px press-down on `.btn`/navlink
`:active`. Nothing else.
**One hover language** (mid-pass audit: hovers were inconsistent per element;
ctao.org uses a single quiet-fade token — we do the same): every interactive
element transitions color/background/border/shadow on `--dur: 0.25s` +
`--ease: ease-out` — buttons and links fade, cards/search rows elevate
(shadow + 2px lift), pagination fills Moon. No width/layout animation.
Rules:
- ALL `animation`/`transition` declarations live inside the single
`@media (prefers-reduced-motion: no-preference)` block at the end of
global.css; the `reduce` block additionally force-disables everything.
- NO cross-document view transitions (`@view-transition`). The root crossfade
snapshots the incoming page at its first render opportunity — on slow
networks that is a half-parsed page, so every navigation read as a
full-page blink (the diagnosed symptom); scrolled→top navigations crossfade
two unrelated frames even when it works. Navigation feel comes from static
HTML + Astro prefetch, not from animating the swap.
- Animate `transform`/`opacity` only (compositor-only); never
background-position/box-shadow in keyframes. No `will-change`. No
`filter: blur` on large layers and no `background-attachment: fixed`
(broken on iOS). No JS, no scroll-triggered motion.
- If an effect calls attention to itself as an effect, cut it.
## Photographic hero (home)
Image-led hero per brand D.6 (photography theme: the project): LST-1 under
the night sky, copied from the content library to `public/brand/hero.jpg`
(NEVER link `public/uploads` directly — that directory is content-synced).
Text safety comes from `--hero-scrim` (Galaxy, `to right`:
.84 → .76 @52% → .44 @78% → .26); ≤640px a flat .80 scrim replaces it (text
spans the full width on mobile). The logo never sits on the photograph — it
stays in the header bar (B.1.2 satisfied by construction).
Computed worst-case contrast over photo + scrim + drifting glow (glow fringe
α .15 Cherenkov / core .28 Azure over the text zone; Cherenkov core is pinned
≥80% band width by the blob geometry + drift range):
- Small-text zone (eyebrow/subtitle/CTA labels, ≤52% band width): white
7.68:1 · Cherenkov subtitle 4.79:1 · eyebrow `#C7CDE9` 4.88:1 (all ≥4.5).
- Display h1 zone (≤72% band width): white ≥5.20:1 (large text needs 3.0).
- Mobile flat scrim: white 8.47:1 · subtitle 5.28:1 · eyebrow 5.38:1.
Recompute (script kept in the session scratchpad pattern: composite scrim →
glow → WCAG luminance per pixel) if the photo, scrim stops, glow alphas or
blob positions change.
## WCAG 2.1 AA notes (computed, worst-case animation frames)
- Photo hero: see table above. Auth nebula over Galaxy: white 7.9:1,
Cherenkov subtitle 4.9:1 at the worst blob frame; Cherenkov/Azure CORES stay
in opposite corners. Ceilings: nebula ≤ .38 Cherenkov / ≤ .35 Azure; glow
≤ .30/.28; wash ≤ .16/.12/.10 — recompute if any alpha or placement changes.
- Translucent header (`color-mix` 88% Galaxy + backdrop blur, solid fallback
via `@supports`): worst underlying content (white) → bar ≈ rgb(31,31,96);
white text 14.8:1, muted nav `#C7CDE9` 9.4:1, Cherenkov sub-brand 9.2:1.
- On Moon: `--link` 6.1:1, `--muted` 7.1:1, `--text` 16.9:1. Ambient wash
worst overlap: `--link` 5.3:1, `--muted` 6.2:1.
- Focus visible everywhere; tap targets ≥ 44px; skip link first.
## Article TOC pattern
Rendered only when ≥3 h2/h3. Two renderings of the same list (only one ever
displayed), both in the quiet Stripe/Apple-docs voice — muted small links,
Galaxy on hover, no heavy box:
- `<nav class="toc toc--rail">` (≥1200px): sticky whitespace column with a
hairline LEFT rule, outside the 70ch column via the `.article-layout` grid;
sticky offset 80px (56px header + 24), own scroll when long.
- `<details class="toc toc--inline">` (<1200px): light disclosure row
("On this page" + chevron from the `.ico` family), comfortable tap rows.
Scrollspy (Starlight pattern, minimal vanilla): an IntersectionObserver band
under the sticky header maps the visible prose block to its governing heading
and sets `aria-current="true"` on the TOC link — Galaxy + 600, and on the rail
a 2px Cherenkov segment over the hairline. Functional navigation state, not
decoration (outside the one-accent budget), same precedent as the search
`<mark>` highlight (Cherenkov tint) and the `.pagination` current pill.
The TOC works fully without JS.
## Header
56px bar (translucent Galaxy via `color-mix` + backdrop blur where supported,
solid Galaxy fallback — see WCAG notes). One row always: `.nav > *` is
`flex: none`, only the spacer flexes; nothing can wrap. Tiers: ≥1025px full
nav · ≤1024px disclosure menu (`<details>`) · ≤640px sub-brand hidden ·
≤480px icon-only menu · ≤360px compact paddings. Logo ≥22px tall everywhere
(≈105px wide — brand minimum 100px digital).
Header search — decided by research, not aesthetics: NN/g's magnifying-glass
-icon findings (icon-only search measurably reduces discoverability and use;
show a visible box when search is an important task) beat the icon-only
minimal header for this content-heavy, search-required portal (SPEC §3.3.1).
Desktop ≥1025px: a VISIBLE quiet field (hairline on navy, 140–200px flex —
the only flexing item, so the row never wraps). ≤1024px: the 44px magnifier
`<details>` reveals a search row under the bar (NN/g mobile-search pattern).
Both are plain `GET /search` (full no-JS path); `<details name="header-panel">`
makes search + menu a native exclusive pair. `/search` stays the full-results
and deep-link target. The footer "Search" link STAYS: SPEC §3.3.1 literally
places "a search functionality" in the bottom menu, and the footer is the
end-of-page fallback (NN/g footers) — one quiet word, clean compliance trail.
Sign in is a ghost/outline pill — Cherenkov fill is reserved for the page's
primary CTA.
Nav content rule: the header nav reflects end-user tasks only (NN/g). The
content editor is internal-team tooling (SPEC §3.2) → discreet "Content
editor" utility link in the footer; /admin keeps working as the direct URL.
## Content & microcopy
- Card metadata must differentiate (NN/g): cards show date · reading time
(body words ÷ 220 wpm, computed at build). Category and author remain on
the article page as one quiet muted meta line (category · author · date ·
read time — plain text, no chips).
- Honesty lives at the INTERACTION POINT (REQUIREMENTS §5): whisper badges
("Mock — <what's missing>", "Planned integration — <team>") appear where a
mock interaction actually happens — /proposals panel, /dashboard, /login,
/support page head. Navigation tiles carry no status chrome; planned
services are subdued tiles (muted, no link) with "— planned" folded into
the description. There is NO phase banner (removed as clutter — the footer
demo note + interaction-point badges carry the disclosure).
- Terminology fixed: "Sign in" (never Log in), "Editor", "Search", "News".
Buttons start with a verb, sentence case. Real ellipsis (…), never "...".
- Search links (`.suggest a`, `.search-results a`) lay out as flowing text —
never `display: flex` on the `<a>` (mark-splitting). Dates are plain muted
text, not chips.
- Overflow containment happens at the source: wide `.prose` tables scroll in
their own box, long words/URLs wrap via `overflow-wrap`. The
`html, body { overflow-x: clip }` rule is a regression guard, not a fix.
- Articles end with `.post-nav` (Newer ← → Older). Lead image is borderless
and breaks out of the text column ≥900px. Image captions: italic muted line
under the image.
### Element rules (condensed)
| Element | Rule | Source |
|---|---|---|
| Header nav | End-user tasks only; order News · Data · Proposals · Dashboard · Support | NN/g serial-position |
| Header search | Icon-reveal row, all widths; plain GET /search | NN/g site search |
| Hero (home) | Photo (LST under night sky) + Galaxy scrim + eyebrow + headline + Cherenkov subtitle + CTA pair: primary "Browse news & announcements" (/news — the spec-first, fully real service; SPEC §3.1/§3.3.1), ghost "Explore the data ↗" (real external tool). Mocks never get hero billing. Nothing else — no teaser, no widgets, no second search | Apple hero restraint, SPEC §3.1 |
| Badges | Quiet hairline pill, muted sentence case, Galaxy dot on mocks; ONLY at mock interaction points | REQUIREMENTS §5 |
| Cards | Cover · title (clamp 3) · desc (clamp 2) · date · read time; borderless on Moon; hover = elevate only | NN/g metadata |
| Pagination | Newer/Older + windowed numbers, 44px targets, aria-current, Moon hover fill. Page 1 holds 25 items (featured lead + 24 = even 3-col rows), later pages 24 — custom slicing, paginate() can't vary size | NN/g pagination |
| Article | Back "← All news"; h1 → standfirst → muted meta line → borderless breakout cover → inline TOC → prose → post-nav | Guardian/BBC anatomy |
| Forms | Visible labels above fields; placeholders are examples only; buttons start with a verb | GOV.UK forms, NN/g |
| Proposals | Integration landing, NOT a submission form — PHS is APC's product, the portal only integrates it: intro + bullets of the portal's contributions (menu entry + AAI single sign-on hand-off, Dashboard status feedback, news notifications) + disabled primary CTA ("… — integration planned", real `disabled` semantics, Moon fill); whisper badges stay | SPEC §3.3.3 |
| Login | One line + Cherenkov primary CTA + one small-print line; whisper badge top-right of the card (in flow ≤480px); no reassurance prose | auth brevity |
| Footer | Identity line → Contact · Search · RSS · Disclaimer · Privacy · Site settings · Content editor → © line | NN/g footers, SPEC §3.3.1 |
| Search results | Links lay out as flowing text (mark-safe); plain muted date; "Press Enter for all results" as a real list row (aria-hidden like group labels — the status line announces) | — |
## Page composition (archetypes)
Every page follows one of three archetypes; a new page picks one instead of
inventing a section order. The navy band is reserved for `/` and `/login`.
| Archetype | Pages | Order |
|---|---|---|
| Portal landing | `/` | Photo hero (h1 + subtitle + CTA pair) → Services tiles (Moon) → Latest news 3-up + "All news" (white) |
| Section landing | `/news`, `/search`, `/proposals`, `/dashboard`, `/support` | Landing head (`.page-head`: display Galaxy h1 + standfirst, `--space-band` top padding, no eyebrow, no navy band) → content → footer. The WHOLE page sits on its archetype surface: white (editorial /news) or Moon (utility) — head included, one surface per page |
| Document | `/news/<slug>`, `/pages/<slug>` | Back link → h1 → standfirst → meta → cover → inline TOC → prose (→ post-nav on articles); no hero band — the document IS the page |
- Home is task-first (NN/g): Services directly under the hero; the news
section below is the browse layer.
- Document order is press anatomy (Guardian/BBC/Reuters). The inline TOC sits
between lead image and body.
- Back affordances: articles "← All news", static pages "← Home". Auth
(`/login`) is the sole exception to the archetypes: one focused card on the
nebula band, no hero, no sections.
- Service tiles carry a stroke glyph (`.ico` family: currentColor, round
caps, aria-hidden; 26px Galaxy) ABOVE the label — never instead of it.
Glyphs are wayfinding, not decoration.
## Serving the demo
Phone/tunnel demos run the **production build**: `npm run demo`
(= `astro build && astro preview`, same port 4321 so the tunnel URL keeps
working). `astro dev` through the tunnel adds HMR + dev-toolbar module
traffic and serves everything `no-cache` — every navigation re-negotiates
assets over the tunnel, which reads as blinking/slow paints that do NOT
exist in the built site. Dev mode is for local editing only.
## Do not
- No new hues, tints, or grays — derive via `color-mix` from brand tokens only.
- No Space Grotesk outside h1/h2 (sole exception: `.card--featured h3` — see
Typography); headline weight stays 500 (brand D.3.4).
- No font sizes outside the type-scale tokens; no ad-hoc spacing/shadow/radius.
- No animation/transition outside the `no-preference` block; no JS motion;
no transition durations/easings outside `--dur`/`--ease`; no
`@view-transition` (see Motion policy).
- No parser-blocking `<script>` in the body — enhancement scripts are
`defer`/`type="module"` (a sync script stalls rendering below the header
on every navigation).
- No dependencies/frameworks (CSS or JS). No removal of mock disclosure at
interaction points. No phase banner — do not reintroduce it.
- No Cherenkov outside: primary CTA, flash, hero subtitle, logo cluster
(+ the two functional states listed under Color tokens). No hover borders.
- Don't lighten text over navy/photo bands below the computed ceilings above.
- No clip-path/CSS approximations of brand assets — the flash is the real SVG
path from the official logo, or nothing.
- Decoration budget: max ONE accent element per view (currently: footer
flash, static). No twinkles, floating shapes, or stacked decorations.
- Never link `public/uploads` from layout/brand surfaces — copy to
`public/brand/` instead (uploads is content-synced).
+78
View File
@@ -0,0 +1,78 @@
# CTAO Science Portal — static site + git-based CMS
Public portal for CTAO news, built as a fully static site. Editors get a
browser WYSIWYG editor (Sveltia CMS) that commits Markdown to a Gitea repo;
a build job turns commits into static HTML. No application or database runs
on the public path.
Live demo: <https://astro.isl-dev.grid.cyfronet.pl> ·
Gitea on the demo machine: <https://astro-git.isl-dev.grid.cyfronet.pl>
## Repository layout & deployment
Code and content are SEPARATE repositories, so editorial commits never mix
with development:
| Repo | Holds | Source of truth | Who commits |
|---|---|---|---|
| `ctao-portal` (this one, Bitbucket) | Templates, CSS, deploy | development | the team |
| `ctao/content` (Gitea on the demo machine) | `news/`, `pages/`, `uploads/` | editorial | Sveltia CMS / editors |
The demo machine polls ITS Gitea (`ctao/portal` mirror + `ctao/content`) and
republishes on a push to either — so a push to Bitbucket does NOT deploy;
deploying code = pushing it to the machine's Gitea (a deliberate step). The
build overlays content onto code (`deploy/build.sh`); the content paths are
gitignored here.
## Stack
- **Astro** (static output). Pages are `.astro` templates (plain HTML with a
JS frontmatter block). No React/Vue/Svelte components, no client-side
framework.
- **Plain CSS** — everything lives in `src/styles/global.css` as design
tokens (custom properties) + rules. No Tailwind, no preprocessor.
The design system (tokens, type scale, spacing, motion policy) is
documented in `DESIGN.md` — read it before touching styles.
- **Sveltia CMS** — a single prebuilt JS bundle, vendored in
`public/vendor/sveltia-cms.js` and loaded on `/admin/`
(`src/pages/admin/index.astro`), configured by `public/admin/config.yml`.
It runs entirely in the editor's browser and talks to the Gitea API
(OAuth PKCE). There is no CMS server. To update it:
`curl -sL https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js -o public/vendor/sveltia-cms.js`
- Runtime dependencies: none beyond Astro. This is deliberate — keep it
that way.
## Develop
```
npm ci
git clone https://astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git ../ctao-content
./scripts/link-content.sh # copies news/pages/uploads into the dev tree
npm run dev # http://localhost:4321
npm run build # static output in dist/
npm run check # deterministic gate: build + internal-link check
```
## Layout
| Path | What |
|---|---|
| `src/pages/` | Routes (`.astro` templates), incl. `news/`, `search`, RSS/sitemap |
| `src/layouts/Base.astro` | HTML shell: head, header/nav, footer |
| `src/content/news/*.md` | Articles (from the content repo — gitignored here; schema in `src/content.config.ts`) |
| `src/styles/global.css` | All CSS: tokens + components + prose |
| `DESIGN.md` | Design handoff: where the tokens live, brand basics, practical notes |
| `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) |
| `public/uploads/` | Editor-uploaded media (from the content repo — gitignored here) |
| `deploy/` | Runbook + container/systemd units for the demo machine — see `deploy/README.md` |
## Editing content
Editors use `/admin/` (link in the footer) and sign in with a Gitea account.
Saving commits to `main`; the machine polls and republishes automatically
(seconds). Full history/rollback = git history in Gitea. Articles with
`draft: true` are excluded from the build.
Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules
from `global.css` so the editor preview matches the site 1:1 — keep them in
sync (both files carry a KEEP IN SYNC comment).
+3 -10
View File
@@ -3,9 +3,9 @@ import { defineConfig } from 'astro/config';
// Static output (zero runtime) — the whole point of the git-based approach. // Static output (zero runtime) — the whole point of the git-based approach.
export default defineConfig({ export default defineConfig({
output: 'static', output: 'static',
// Placeholder domain until the real one exists — only used to build absolute // Public origin — used to build absolute URLs (canonical, og:*, RSS,
// URLs (canonical, og:*, RSS, sitemap). Swap once the portal has a home. // sitemap). Change when the portal moves to its production domain.
site: 'https://portal.ctao.org', site: 'https://astro.isl-dev.grid.cyfronet.pl',
// Built-in prefetch on every internal link (no per-link attributes needed). // Built-in prefetch on every internal link (no per-link attributes needed).
// Default 'hover' strategy: near-instant navigation without the bandwidth // Default 'hover' strategy: near-instant navigation without the bandwidth
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on // cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
@@ -24,13 +24,6 @@ export default defineConfig({
// Vite blocks unknown Host headers by default; the demo is viewed through an // Vite blocks unknown Host headers by default; the demo is viewed through an
// ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host. // ephemeral cloudflared quick tunnel, so allow any *.trycloudflare.com host.
allowedHosts: ['.trycloudflare.com'], allowedHosts: ['.trycloudflare.com'],
// Same-origin path for the Sveltia/Decap local backend proxy (default port
// moved to 8082 to avoid colliding with Metro on 8081). Proxying it here
// means the CMS admin talks to one origin, so editing also works through the
// cloudflared tunnel from the phone.
proxy: {
'/api/v1': { target: 'http://localhost:8082', changeOrigin: true },
},
}, },
}, },
}); });
+2 -1
View File
@@ -1,6 +1,7 @@
# Build image for the CTAO portal demo: pinned Node + git. # Build image for the CTAO portal demo: pinned Node + git.
# Why: the host has node 24 but NO git (and we have no sudo); a 2-line image # Why: the host has node 24 but NO git (and we have no sudo); a 2-line image
# keeps the whole toolchain pinned and independent of host packages. # keeps node pinned and independent of host packages (git comes from the
# alpine repo unpinned — alpine drops old package versions, pinning is moot).
# Built ONCE at install, never pulled again at runtime: # Built ONCE at install, never pulled again at runtime:
# podman build -t localhost/ctao-portal-build:1 -f Containerfile.build . # podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .
FROM docker.io/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd FROM docker.io/library/node:24-alpine@sha256:a0b9bf06e4e6193cf7a0f58816cc935ff8c2a908f81e6f1a95432d679c54fbfd
+65 -43
View File
@@ -4,69 +4,81 @@ Everything runs rootless as user `strapi`. Footprint on the machine:
ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`. ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`.
Uninstall restores the machine exactly (see bottom). No secrets in any file. Uninstall restores the machine exactly (see bottom). No secrets in any file.
Two Gitea repos by design — `ctao/portal` (code, developed by the team) and
`ctao/content` (Markdown + uploads, committed by the CMS). The build overlays
content onto code; a push to EITHER republishes the site.
``` ```
~/ctao-portal-demo/ ~/ctao-portal-demo/
├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up ├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up
├── gitea-config/ # Gitea app.ini (generated from env) ├── gitea-config/ # Gitea app.ini (generated from env)
├── repo/ # clone of the portal repo (created by first build) ├── repo/ # clone of the code repo (created by first build)
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves) ├── content/ # clone of the content repo (created by first build)
├── state/ # last-built SHA ├── releases/ # <code>-<content>/ dirs + `current` symlink (what nginx serves)
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo) ├── state/ # last-built release id
├── npm-cache/ # npm cache for the build container (created by build.sh)
├── bin/build.sh # copied from deploy/ (source of truth stays in the code repo)
└── config/nginx.conf └── config/nginx.conf
``` ```
| Port | What | Exposed how | Host prerequisites: `podman`, `curl`, `jq` (build.sh checks and says which is
missing). Everything else runs inside containers.
| Port | What | Exposed as |
|---|---|---| |---|---|---|
| 3000 | Gitea | ingress vhost (TODO: ask Hubert) | | 3000 | Gitea | https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost) |
| 8080 | portal (nginx, static) | ingress vhost (TODO: ask Hubert) | | 8080 | portal (nginx, static) | https://astro.isl-dev.grid.cyfronet.pl (ingress vhost) |
## Install (each step reviewed before running; [W] = writes to the machine) ## Install (each step reviewed before running; [W] = writes to the machine)
1. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user` 1. **[W]** `loginctl enable-linger $USER` — without lingering every user unit
2. **[W]** Copy files from this dir (scp from the Mac): dies at logout and nothing starts after a reboot. Verify:
`loginctl show-user $USER -p Linger` → `Linger=yes`.
2. **[W]** `mkdir -p ~/ctao-portal-demo/{gitea-data,gitea-config,releases,state,bin,config} ~/.config/containers/systemd ~/.config/systemd/user`
3. **[W]** Copy files from this dir (scp):
- `ctao-demo-gitea.container`, `ctao-demo-web.container` → `~/.config/containers/systemd/` - `ctao-demo-gitea.container`, `ctao-demo-web.container` → `~/.config/containers/systemd/`
- `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/` - `ctao-portal-build.service`, `ctao-portal-build.timer` → `~/.config/systemd/user/`
- `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`) - `build.sh` → `~/ctao-portal-demo/bin/` (`chmod +x`)
- `nginx.conf` → `~/ctao-portal-demo/config/` - `nginx.conf` → `~/ctao-portal-demo/config/`
3. **[W]** Pull + pin images (one-time, needs internet): - `Containerfile.build` → anywhere (needed once, for the next step)
`podman pull docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine` 4. **[W]** Pull the images at the digests pinned in the unit files (one-time,
needs internet) and build the build image:
`podman pull docker.io/gitea/gitea@sha256:<digest from ctao-demo-gitea.container>`
`podman pull docker.io/library/nginx@sha256:<digest from ctao-demo-web.container>`
`podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .` `podman build -t localhost/ctao-portal-build:1 -f Containerfile.build .`
Then `podman images --digests` → paste the sha256 digests into both `.container` files. (Upgrading later = pick new digests deliberately, update the pins in the
4. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea` `.container` files / `Containerfile.build`, re-pull, re-build.)
5. **[W]** Create the Gitea admin — run YOURSELF in your own terminal (password 5. **[W]** `systemctl --user daemon-reload && systemctl --user start ctao-demo-gitea`
is prompted/printed there only; never goes through chat or shell history): 6. **[W]** Create the Gitea admin — run interactively in a terminal so the
password never lands in a file or shell history:
`podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password` `podman exec -it ctao-demo-gitea gitea admin user create --admin --username <you> --email <you@…> --random-password`
6. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read). 7. **[W]** In the Gitea UI: create org `ctao` with repos `portal` and
Push from the Mac through an SSH port-forward: `content` (both public read). Then, from your workstation, push both over
`ssh -L 3300:localhost:3000 strapi-experimental.cyfronet` then the vhost with a repo-scoped token:
`git remote add machine http://localhost:3300/ctao/portal.git && git push machine main` `git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
7. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run `git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/content.git main`
8. **[W]** `systemctl --user enable --now ctao-portal-build.timer` — first run
clones + `npm ci` + builds (minutes); later runs are seconds. Wait until clones + `npm ci` + builds (minutes); later runs are seconds. Wait until
`journalctl --user -u ctao-portal-build -n 5` shows `published <sha>` `journalctl --user -u ctao-portal-build -n 5` shows `published <sha>`
(starting nginx earlier just serves 404s until the first build lands). (starting nginx earlier just serves 404s until the first build lands).
8. **[W]** `systemctl --user start ctao-demo-web` 9. **[W]** `systemctl --user start ctao-demo-web`
9. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/` 10. **[R]** Verify: `curl -s -o /dev/null -w '%{http_code}' http://localhost:8080/`
and `journalctl --user -u ctao-portal-build -n 20` (shows measured build times). and `journalctl --user -u ctao-portal-build -n 20` (shows measured build times).
## After Hubert assigns the vhosts ## CMS sign-in (Sveltia ↔ Gitea OAuth)
1. Replace both `TODO(vhost)` values in `ctao-demo-gitea.container` - Gitea OAuth2 app (PKCE, `confidential_client=false`, no secret) with
(ROOT_URL → gitea vhost, CORS `*` → portal origin, add redirect `https://astro.isl-dev.grid.cyfronet.pl/admin/`; its client id is
`GITEA__cors__SCHEME=https`); `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`. the `app_id` in `public/admin/config.yml`.
2. In Gitea UI: Settings → Applications → new OAuth2 app for Sveltia - CORS is pinned in `ctao-demo-gitea.container`: `ALLOW_DOMAIN` takes the
(redirect: `https://<portal-vhost>/admin/`), PKCE, no client secret. FULL portal origin with scheme (a bare hostname silently disables CORS in
3. Put the Gitea vhost URL + client id into `public/admin/config.yml` in the Gitea 1.27; there is no `SCHEME` key) and `HEADERS` must include
portal repo, commit, push — the timer publishes it like any other change. `Authorization` or authenticated API calls from the browser fail.
- OAuth requires a secure context: the vhosts must stay HTTPS.
- Changing origins later = edit the quadlet env + the OAuth app's redirect
URI + `config.yml`, then `systemctl --user daemon-reload && systemctl --user restart ctao-demo-gitea`.
## Ask Hubert (one message) ## Uninstall (leaves only podman's own storage metadata)
1. Two ingress vhosts → `192.168.10.15:8080` (portal) and `:3000` (gitea) —
same mechanism as `strapi.isl-dev…:1337`.
2. Are vhosts public-internet or VPN-scopable? (Gitea preferably VPN-only.)
3. Does Cyfronet offer static-file hosting on the ingress itself? If yes, we
drop our nginx container entirely and rsync builds there instead.
## Uninstall (leaves zero traces)
``` ```
systemctl --user disable --now ctao-portal-build.timer systemctl --user disable --now ctao-portal-build.timer
@@ -74,14 +86,23 @@ systemctl --user stop ctao-demo-web ctao-demo-gitea
rm ~/.config/containers/systemd/ctao-demo-*.container \ rm ~/.config/containers/systemd/ctao-demo-*.container \
~/.config/systemd/user/ctao-portal-build.{service,timer} ~/.config/systemd/user/ctao-portal-build.{service,timer}
systemctl --user daemon-reload systemctl --user daemon-reload
podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless docker.io/library/nginx:stable-alpine podman rmi localhost/ctao-portal-build:1 docker.io/gitea/gitea:1.27-rootless \
docker.io/library/nginx:stable-alpine docker.io/library/node:24-alpine
rm -rf ~/ctao-portal-demo rm -rf ~/ctao-portal-demo
loginctl disable-linger $USER # only if nothing else of yours should survive logout
``` ```
## Notes ## Notes
- Publish latency = poll (≤10 s) + build (measured 1 s on M-series; expect - **Only pushes to the machine's Gitea auto-deploy** (code and content repos
4–8 s on the 2 vCPU VM — every build's time lands in the journal). alike). Changes to `deploy/*` need a manual
re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` +
`systemctl --user restart ctao-demo-web`, unit files →
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
deliberate: the build pipeline must not execute host-side code straight
from the content repo.
- Publish latency = poll (≤10 s) + build (measured: astro build 5–7 s on the
2 vCPU VM, whole build.sh 16–21 s; every build's time lands in the journal).
- Internet needed only for: image pulls (install) and `npm ci` when the - Internet needed only for: image pulls (install) and `npm ci` when the
lockfile changes. Routine rebuilds are fully offline. lockfile changes. Routine rebuilds are fully offline.
- Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi; - Memory caps (`MemoryHigh`) keep us polite next to Outline + Strapi;
@@ -93,4 +114,5 @@ rm -rf ~/ctao-portal-demo
Gitea on localhost:3000 but cannot reach host loopback services. (Rootless Gitea on localhost:3000 but cannot reach host loopback services. (Rootless
netavark bridges don't work here: no `ip_tables` kernel module, no sudo.) netavark bridges don't work here: no `ip_tables` kernel module, no sudo.)
- Secrets inventory: Gitea admin password (typed interactively, lives only - Secrets inventory: Gitea admin password (typed interactively, lives only
in Gitea's DB) — that's the complete list. Build/poll/serve use none. in Gitea's DB) and repo-scoped push tokens (managed in Gitea) — that's the
complete list. Build/poll/serve use none.
+76 -34
View File
@@ -1,15 +1,22 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# CTAO portal demo — poll the content repo, rebuild, publish atomically. # CTAO portal — poll the code AND content repos, rebuild on any change,
# Triggered every 10 s by ctao-portal-build.timer; systemd oneshot semantics # publish atomically. Triggered every 10 s by ctao-portal-build.timer; systemd
# guarantee runs never overlap. The 99.9% case is one local curl and exit 0. # oneshot semantics guarantee runs never overlap. The 99.9% case is two local
# No secrets anywhere: the portal repo is public-read on the local Gitea. # curls and exit 0. No secrets anywhere: both repos are public-read on the
# local Gitea.
#
# Two repositories by design: code (templates/CSS, developed by the team) and
# content (Markdown + uploads, committed by the CMS). The build overlays
# content onto code, so an editor publishing an article and a developer
# shipping CSS never mix histories — either change republishes the site.
set -euo pipefail set -euo pipefail
# Defaults match the machine; every var is env-overridable so the whole # Defaults match the machine; every var is env-overridable so the whole
# pipeline can be tested locally against a sandbox dir + local Gitea. # pipeline can be tested locally against a sandbox dir + local Gitea.
BASE="${BASE:-$HOME/ctao-portal-demo}" BASE="${BASE:-$HOME/ctao-portal-demo}"
GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea
REPO="${REPO:-ctao/portal}" # owner/repo in Gitea CODE_REPO="${CODE_REPO:-ctao/portal}" # owner/repo in Gitea
CONTENT_REPO="${CONTENT_REPO:-ctao/content}"
BRANCH="${BRANCH:-main}" BRANCH="${BRANCH:-main}"
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}" BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# The build joins the Gitea container's network namespace: localhost inside # The build joins the Gitea container's network namespace: localhost inside
@@ -17,71 +24,106 @@ BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# (A netavark bridge would be equivalent, but rootless bridges need the # (A netavark bridge would be equivalent, but rootless bridges need the
# ip_tables kernel module, absent on the machine — pasta needs nothing.) # ip_tables kernel module, absent on the machine — pasta needs nothing.)
BUILD_NETNS="${BUILD_NETNS:-container:ctao-demo-gitea}" BUILD_NETNS="${BUILD_NETNS:-container:ctao-demo-gitea}"
REPO_INTERNAL="${REPO_INTERNAL:-http://localhost:3000/$REPO.git}" CODE_URL="${CODE_URL:-http://localhost:3000/$CODE_REPO.git}"
CONTENT_URL="${CONTENT_URL:-http://localhost:3000/$CONTENT_REPO.git}"
KEEP="${KEEP:-3}" # released builds to retain KEEP="${KEEP:-3}" # released builds to retain
mkdir -p "$BASE/repo" "$BASE/releases" "$BASE/state" "$BASE/npm-cache" mkdir -p "$BASE/repo" "$BASE/content" "$BASE/releases" "$BASE/state" "$BASE/npm-cache"
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) --- # Host prerequisites (everything else runs inside containers). Fail loud —
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \ # a missing tool is permanent, unlike a Gitea hiccup below.
| jq -r '.commit.id' || true) for tool in curl jq podman; do
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
done
# --- 1. Cheap poll: both branch heads via the local Gitea API ---
head_of() {
curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$1/branches/$BRANCH" \
| jq -r '.commit.id' || true
}
code_sha=$(head_of "$CODE_REPO")
content_sha=$(head_of "$CONTENT_REPO")
# Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly # Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly
# instead of painting the journal red every 10 s. # instead of painting the journal red every 10 s.
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then for sha in "$code_sha" "$content_sha"; do
echo "poll failed (gitea unreachable?) — skipping this tick" if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
exit 0 echo "poll failed (gitea unreachable?) — skipping this tick"
fi exit 0
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" ]] && exit 0 fi
done
release="${code_sha:0:12}-${content_sha:0:12}" # code+content pin the release
# Skip only if this pair is both recorded AND still present in releases/
# (a deleted release dir must trigger a rebuild, not an eternal skip).
[[ "$release" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$release" ]] && exit 0
echo "building $sha" echo "building code=$code_sha content=$content_sha"
t0=$(date +%s) t0=$(date +%s)
# --- 2. Build in the ephemeral container (git + pinned node live there). # --- 2. Build in the ephemeral container (git + pinned node live there).
# SECURITY: the container runs npm lifecycle scripts from the repo, so it is # SECURITY: the container runs npm lifecycle scripts from the repo, so it is
# confined to Gitea's netns — it reaches Gitea on localhost:3000 and the # confined to Gitea's netns — it reaches Gitea on localhost:3000 and the
# internet (for `npm ci` when the lockfile changed), but NOT the host's # internet (for `npm ci` when the lockfile changed), but NOT the host's
# loopback services (code-server). Never use --network=host here. # loopback services. Never use --network=host here.
# node_modules and .deps-hash are untracked, so they survive checkouts. # node_modules and .deps-hash are untracked, so they survive checkouts.
podman run --rm --network="$BUILD_NETNS" --memory=1g \ podman run --rm --network="$BUILD_NETNS" --memory=1g \
-e ASTRO_TELEMETRY_DISABLED=1 \ -e ASTRO_TELEMETRY_DISABLED=1 \
-e SHA="$sha" -e REPO_URL="$REPO_INTERNAL" -e BRANCH="$BRANCH" \ -e CODE_SHA="$code_sha" -e CODE_URL="$CODE_URL" \
-e CONTENT_SHA="$content_sha" -e CONTENT_URL="$CONTENT_URL" \
-e RELEASE="$release" -e BRANCH="$BRANCH" \
-v "$BASE/repo:/work/repo:z" \ -v "$BASE/repo:/work/repo:z" \
-v "$BASE/content:/work/content:z" \
-v "$BASE/releases:/work/releases:z" \ -v "$BASE/releases:/work/releases:z" \
-v "$BASE/npm-cache:/root/.npm:z" \ -v "$BASE/npm-cache:/root/.npm:z" \
-w /work "$BUILD_IMAGE" sh -ec ' -w /work "$BUILD_IMAGE" sh -ec '
git config --global safe.directory "*" git config --global safe.directory "/work/repo"
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo git config --global --add safe.directory "/work/content"
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes sync_clone() { # $1 dir $2 url $3 sha
git -C repo fetch --quiet origin "$BRANCH" [ -d "$1/.git" ] || git clone --branch "$BRANCH" "$2" "$1"
git -C repo checkout --quiet "$SHA" git -C "$1" remote set-url origin "$2" # self-heal if the URL changes
git -C "$1" fetch --quiet origin "$BRANCH"
# --force: the working copy is disposable; a stray tracked-file edit
# must not wedge every future build.
git -C "$1" checkout --quiet --force "$3"
}
sync_clone repo "$CODE_URL" "$CODE_SHA"
sync_clone content "$CONTENT_URL" "$CONTENT_SHA"
# Overlay content onto code (these paths are gitignored in the code repo)
rm -rf repo/src/content/news repo/src/content/pages repo/public/uploads
cp -a content/news repo/src/content/news
cp -a content/pages repo/src/content/pages
cp -a content/uploads repo/public/uploads
cd repo cd repo
lock=$(sha256sum package-lock.json | cut -d" " -f1) lock=$(sha256sum package-lock.json | cut -d" " -f1)
if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then if [ ! -d node_modules ] || [ "$lock" != "$(cat .deps-hash 2>/dev/null)" ]; then
# --ignore-scripts: (1) kills the malicious-postinstall vector from npm # --ignore-scripts: (1) removes the install-time postinstall vector from
# deps entirely, (2) avoids the esbuild ETXTBSY postinstall race in # npm deps (build-time repo code still runs `npm run build` below — the
# rootless containers. esbuild ships its binary as an optional dep, so # netns confinement is the control for that), (2) avoids the esbuild
# nothing needed here actually requires lifecycle scripts. # ETXTBSY postinstall race in rootless containers. esbuild ships its
# binary as an optional dep, so nothing here needs lifecycle scripts.
npm ci --ignore-scripts --no-audit --no-fund npm ci --ignore-scripts --no-audit --no-fund
echo "$lock" > .deps-hash echo "$lock" > .deps-hash
fi fi
npm run build npm run build
rm -rf "../releases/$SHA" rm -rf "../releases/$RELEASE"
cp -a dist "../releases/$SHA" cp -a dist "../releases/$RELEASE"
' '
# --- 3. Atomic publish: symlink flip via rename(2) — no half-published moment. # --- 3. Atomic publish: symlink flip via rename(2) — no half-published moment.
# mv -T is GNU (the target host is Rocky); when testing on macOS put a # mv -T is GNU (the target host is Rocky); when testing on macOS put a
# coreutils `mv` (gmv) first in PATH. # coreutils `mv` (gmv) first in PATH.
rm -f "$BASE/releases/".current.* # stale temps from a crash mid-flip rm -f "$BASE/releases/".current.* # stale temps from a crash mid-flip
ln -s "$sha" "$BASE/releases/.current.$$" ln -s "$release" "$BASE/releases/.current.$$"
mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current" mv -Tf "$BASE/releases/.current.$$" "$BASE/releases/current"
echo "$sha" > "$BASE/state/last-built" echo "$release" > "$BASE/state/last-built"
# --- 4. Prune old releases (never touches `current` — it is always newest). # --- 4. Prune old releases. `current`'s target is excluded explicitly —
# mtime ordering makes it newest today, but nothing should depend on that.
# `|| true`: an empty match must not fail the unit after a successful publish # `|| true`: an empty match must not fail the unit after a successful publish
# (grep exits 1 under pipefail when there is nothing to prune). # (grep exits 1 under pipefail when there is nothing to prune).
cd "$BASE/releases" cd "$BASE/releases"
ls -1t | grep -vx current | tail -n +"$((KEEP + 1))" | while read -r old; do cur=$(readlink current || true)
ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | while read -r old; do
rm -rf -- "$old" rm -rf -- "$old"
done || true done || true
echo "published $sha in $(( $(date +%s) - t0 ))s" echo "published $release in $(( $(date +%s) - t0 ))s"
+7 -6
View File
@@ -22,25 +22,26 @@ UserNS=keep-id:uid=1000,gid=1000
# so it sees Gitea on localhost:3000 while the HOST loopback stays invisible. # so it sees Gitea on localhost:3000 while the HOST loopback stays invisible.
Volume=%h/ctao-portal-demo/gitea-data:/var/lib/gitea:Z Volume=%h/ctao-portal-demo/gitea-data:/var/lib/gitea:Z
Volume=%h/ctao-portal-demo/gitea-config:/etc/gitea:Z Volume=%h/ctao-portal-demo/gitea-config:/etc/gitea:Z
# Bound on all interfaces DELIBERATELY: the ingress that terminates the
# public vhost runs on a separate box and reaches this VM over the network —
# a 127.0.0.1 bind would cut it off.
PublishPort=3000:3000 PublishPort=3000:3000
# Env-driven config — re-applied on every start, no hand-edited app.ini # Env-driven config — re-applied on every start, no hand-edited app.ini
# (see .skills/gitea/SKILL.md). Secrets: none here; the admin account is # (see .skills/gitea/SKILL.md). Secrets: none here; the admin account is
# created interactively after first start (README). # created interactively after first start (README).
Environment=GITEA__server__HTTP_PORT=3000 Environment=GITEA__server__HTTP_PORT=3000
# Public URL via the Cyfronet ingress (vhost by Hubert, 2026-07-29). # Public URL via the Cyfronet ingress vhost.
Environment=GITEA__server__ROOT_URL=https://astro-git.isl-dev.grid.cyfronet.pl/ Environment=GITEA__server__ROOT_URL=https://astro-git.isl-dev.grid.cyfronet.pl/
Environment=GITEA__server__DISABLE_SSH=true Environment=GITEA__server__DISABLE_SSH=true
Environment=GITEA__database__DB_TYPE=sqlite3 Environment=GITEA__database__DB_TYPE=sqlite3
Environment=GITEA__security__INSTALL_LOCK=true Environment=GITEA__security__INSTALL_LOCK=true
Environment=GITEA__service__DISABLE_REGISTRATION=true Environment=GITEA__service__DISABLE_REGISTRATION=true
Environment=GITEA__mailer__ENABLED=false Environment=GITEA__mailer__ENABLED=false
# First `git push` auto-creates the repo (no UI step); public so the build # Repos default to public so the build pipeline can clone anonymously —
# pipeline can clone anonymously — content is the public site anyway. # content is the public site anyway. The `ctao/portal` repo itself is
Environment=GITEA__repository__ENABLE_PUSH_CREATE_USER=true # created in the UI (README step 6).
Environment=GITEA__repository__DEFAULT_PRIVATE=public Environment=GITEA__repository__DEFAULT_PRIVATE=public
# Push-created repos have their OWN default (true = private) — learned the hard way:
Environment=GITEA__repository__DEFAULT_PUSH_CREATE_PRIVATE=false
# Sveltia is served from the portal vhost and calls the Gitea API cross-origin # Sveltia is served from the portal vhost and calls the Gitea API cross-origin
# — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with # — CORS locked to exactly that origin. ALLOW_DOMAIN takes FULL origins with
# scheme (verified in the 1.27 config cheat sheet; a SCHEME key no longer # scheme (verified in the 1.27 config cheat sheet; a SCHEME key no longer
+3
View File
@@ -15,11 +15,14 @@ Image=docker.io/library/nginx:stable-alpine@sha256:97d490c12ba55b4946b01546d1c3e
# the config file is exclusive to nginx (:Z). # the config file is exclusive to nginx (:Z).
Volume=%h/ctao-portal-demo/releases:/srv/releases:ro,z Volume=%h/ctao-portal-demo/releases:/srv/releases:ro,z
Volume=%h/ctao-portal-demo/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z Volume=%h/ctao-portal-demo/config/nginx.conf:/etc/nginx/conf.d/default.conf:ro,Z
# All-interfaces bind is deliberate — the ingress box reaches us over the
# network (see the matching note in ctao-demo-gitea.container).
PublishPort=8080:80 PublishPort=8080:80
[Service] [Service]
Restart=on-failure Restart=on-failure
MemoryHigh=64M MemoryHigh=64M
MemoryMax=128M
[Install] [Install]
WantedBy=default.target WantedBy=default.target
+19 -4
View File
@@ -7,28 +7,43 @@ server {
server_name _; server_name _;
root /srv/releases/current; root /srv/releases/current;
charset utf-8; charset utf-8;
server_tokens off;
error_page 404 /404.html; # Astro emits 404.html at the site root error_page 404 /404.html; # Astro emits 404.html at the site root
# Directory redirects (/admin -> /admin/) must stay relative: an absolute # Directory redirects (/admin -> /admin/) must stay relative: an absolute
# redirect is built from listen port 80 and loses the real port whenever the # redirect is built from listen port 80 and loses the real port whenever the
# site is reached through a tunnel or a proxy on a non-default port. # site is reached through a tunnel or a proxy on a non-default port.
absolute_redirect off; absolute_redirect off;
# Security headers are REPEATED in every location on purpose: nginx
# `add_header` inheritance is all-or-nothing — any add_header in a location
# discards ALL server-level ones, so server-level headers would silently
# vanish. `always` keeps them on error responses (404) too.
# Fingerprinted build assets (/_astro/<name>.<hash>.*) — immutable # Fingerprinted build assets (/_astro/<name>.<hash>.*) — immutable
location /_astro/ { location /_astro/ {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
} }
# Editor-uploaded media (stable paths, may be re-uploaded) — short cache # Editor-uploaded media (stable paths, may be re-uploaded) — short cache
location /uploads/ { location /uploads/ {
add_header Cache-Control "public, max-age=3600"; add_header Cache-Control "public, max-age=3600" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
} }
# Everything else: HTML pages, feeds, /admin (Sveltia is static files too) # Everything else: HTML pages, feeds, /admin (Sveltia is static files too)
location / { location / {
try_files $uri $uri/ =404; try_files $uri $uri/ =404;
add_header Cache-Control "no-cache"; add_header Cache-Control "no-cache" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header X-Frame-Options "SAMEORIGIN" always;
} }
gzip on; gzip on;
gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml; gzip_types text/css application/javascript application/json image/svg+xml application/rss+xml text/xml application/xml;
} }
+1254 -1833
View File
File diff suppressed because it is too large Load Diff
+7 -7
View File
@@ -1,19 +1,19 @@
{ {
"name": "ctao-demo-git-cms", "name": "ctao-portal",
"type": "module", "type": "module",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "astro dev --port 4321 --host", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview --port 4321 --host", "preview": "astro preview",
"demo": "astro build && astro preview --port 4321 --host", "check": "astro build && node scripts/check-links.mjs"
"cms-proxy": "PORT=8082 decap-server"
}, },
"dependencies": { "dependencies": {
"astro": "latest" "astro": "^7.2.10"
}, },
"devDependencies": { "devDependencies": {
"decap-server": "latest" "@astrojs/check": "^0.9.10",
"typescript": "^6.0.3"
} }
} }
+7 -4
View File
@@ -4,7 +4,9 @@
# these origins in deploy/ctao-demo-gitea.container. # these origins in deploy/ctao-demo-gitea.container.
backend: backend:
name: gitea name: gitea
repo: ctao/portal # Content lives in its OWN repo (editorial history separate from code —
# the build overlays it onto the portal code, see deploy/build.sh).
repo: ctao/content
branch: main branch: main
base_url: https://astro-git.isl-dev.grid.cyfronet.pl base_url: https://astro-git.isl-dev.grid.cyfronet.pl
api_root: https://astro-git.isl-dev.grid.cyfronet.pl/api/v1 api_root: https://astro-git.isl-dev.grid.cyfronet.pl/api/v1
@@ -22,7 +24,7 @@ app_title: CTAO Content Editor
logo: logo:
src: /brand/CTAO_Logo_login.svg src: /brand/CTAO_Logo_login.svg
media_folder: "public/uploads" media_folder: "uploads"
public_folder: "/uploads" public_folder: "/uploads"
# Keep the git repo lean: images are stored as plain git blobs (no LFS), so # Keep the git repo lean: images are stored as plain git blobs (no LFS), so
@@ -46,7 +48,7 @@ collections:
- name: news - name: news
label: "News" label: "News"
label_singular: "Article" label_singular: "Article"
folder: "src/content/news" folder: "news"
create: true create: true
slug: "{{slug}}" slug: "{{slug}}"
extension: md extension: md
@@ -59,13 +61,14 @@ collections:
- { name: category, label: "Category", widget: string, default: "news" } - { name: category, label: "Category", widget: string, default: "news" }
- { name: author, label: "Author", widget: string, default: "CTAO" } - { name: author, label: "Author", widget: string, default: "CTAO" }
- { name: cover, label: "Cover image", widget: image, required: false } - { name: cover, label: "Cover image", widget: image, required: false }
- { name: lang, label: "Language (if not English, e.g. pl)", widget: string, required: false }
- { name: draft, label: "Draft (unpublished)", widget: boolean, default: false } - { name: draft, label: "Draft (unpublished)", widget: boolean, default: false }
- { name: body, label: "Body", widget: markdown } - { name: body, label: "Body", widget: markdown }
- name: pages - name: pages
label: "Pages" label: "Pages"
label_singular: "Page" label_singular: "Page"
folder: "src/content/pages" folder: "pages"
create: true create: true
slug: "{{slug}}" slug: "{{slug}}"
extension: md extension: md
+15 -5
View File
@@ -5,9 +5,17 @@
dropped from selectors (the preview pane has no such wrapper). dropped from selectors (the preview pane has no such wrapper).
KEEP IN SYNC: when you touch `.prose` or the tokens in global.css, update KEEP IN SYNC: when you touch `.prose` or the tokens in global.css, update
the corresponding value here (a matching reminder sits next to `.prose`). the corresponding value here (a matching reminder sits next to `.prose`).
Fonts: same families as the site (site inlines them; the preview loads them Fonts: same self-hosted woff2 files the site uses, same origin. (The
from Google Fonts — editors' browsers are online by definition). */ Sveltia bundle still loads its own UI fonts from jsDelivr — only the
@import url("https://fonts.googleapis.com/css2?family=Inter:wght@400..700&family=Space+Grotesk:wght@500..700&display=swap"); PREVIEW pane is CDN-free.) */
@font-face {
font-family: "Inter"; font-weight: 400 700; font-display: swap;
src: url("/fonts/inter-latin.woff2") format("woff2");
}
@font-face {
font-family: "Space Grotesk"; font-weight: 500 700; font-display: swap;
src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
}
:root { :root {
/* tokens copied from global.css :root */ /* tokens copied from global.css :root */
@@ -22,6 +30,7 @@
--fs-s: 0.9rem; --fs-s: 0.9rem;
--fs-l: 1.125rem; --fs-l: 1.125rem;
--fs-xl: 1.5rem; --fs-xl: 1.5rem;
--fs-h2: clamp(1.75rem, 3.5vw, 2.5rem);
--radius: 16px; --radius: 16px;
} }
@@ -42,7 +51,8 @@ a { color: var(--link); text-decoration: underline; text-underline-offset: 3px;
/* = global.css `.prose` rules, selectors unwrapped = */ /* = global.css `.prose` rules, selectors unwrapped = */
body { line-height: 1.65; overflow-wrap: break-word; } body { line-height: 1.65; overflow-wrap: break-word; }
h1 { color: var(--galaxy); } a { overflow-wrap: anywhere; }
h1 { color: var(--galaxy); font-size: var(--fs-h2); } /* mirrors `.article h1` */
h2 { color: var(--galaxy); margin: 44px 0 12px; font-size: var(--fs-xl); } h2 { color: var(--galaxy); margin: 44px 0 12px; font-size: var(--fs-xl); }
h3 { color: var(--galaxy); margin: 30px 0 8px; font-size: var(--fs-l); } h3 { color: var(--galaxy); margin: 30px 0 8px; font-size: var(--fs-l); }
p { margin: 0 0 16px; text-wrap: pretty; } p { margin: 0 0 16px; text-wrap: pretty; }
@@ -52,7 +62,7 @@ blockquote {
margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy); margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy);
background: var(--moon); color: var(--text); background: var(--moon); color: var(--text);
} }
img { max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); } img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); }
img + em, p > em:only-child { color: var(--muted); font-size: var(--fs-s); } img + em, p > em:only-child { color: var(--muted); font-size: var(--fs-s); }
hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; } hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: var(--fs-s); } table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: var(--fs-s); }
+29 -6
View File
@@ -26,7 +26,15 @@
let index = null; let index = null;
let timer; let timer;
async function ensureIndex() { async function ensureIndex() {
if (!index) index = await (await fetch('/search.json')).json(); if (index) return index;
try {
index = await (await fetch('/search.json')).json();
} catch {
// Network hiccup: say so instead of failing silently; index stays
// null so the next keystroke retries.
status.textContent = 'Search is unavailable right now.';
return null;
}
return index; return index;
} }
// First title match wrapped in <mark> (Starlight search pattern) — built // First title match wrapped in <mark> (Starlight search pattern) — built
@@ -77,7 +85,9 @@
frag.append(label('Recent')); frag.append(label('Recent'));
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term)); for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
} }
const fresh = (await ensureIndex()).slice(0, Math.max(1, 5 - r.length)); const idx = await ensureIndex();
if (!idx) return;
const fresh = idx.slice(0, Math.max(1, 5 - r.length));
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
frag.append(label('Latest news')); frag.append(label('Latest news'));
for (const p of fresh) { for (const p of fresh) {
@@ -95,6 +105,7 @@
const q = input.value.trim().toLowerCase(); const q = input.value.trim().toLowerCase();
if (q.length < 2) { list.replaceChildren(); idle(); return; } if (q.length < 2) { list.replaceChildren(); idle(); return; }
const idx = await ensureIndex(); const idx = await ensureIndex();
if (!idx) { list.replaceChildren(); return; }
const hits = idx const hits = idx
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q)) .filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
.slice(0, opts.limit); .slice(0, opts.limit);
@@ -111,6 +122,17 @@
li.append(small); li.append(small);
frag.append(li); frag.append(li);
} }
// Dropdown only: an empty list would collapse (.suggest:empty) and the
// panel would just vanish — show the no-results line sighted users too
// (the status line above is sr-only in the header; /search shows a
// visible notice instead).
if (!hits.length && opts.dismiss) {
const li = document.createElement('li');
li.className = 'suggest-hint';
li.setAttribute('aria-hidden', 'true'); // the status line announces it
li.textContent = 'No results for “' + input.value.trim() + '”';
frag.append(li);
}
list.replaceChildren(frag); list.replaceChildren(frag);
hint(); hint();
} }
@@ -122,13 +144,14 @@
const a = e.target.closest && e.target.closest('a'); const a = e.target.closest && e.target.closest('a');
remember((a && a.dataset.q) || input.value); remember((a && a.dataset.q) || input.value);
}); });
// Dropdown dismiss (opt-in — /search keeps its results list). A suggestion // Dropdown dismiss (opt-in — /search keeps its results list). The
// list is a NON-MODAL combobox popup (ARIA combobox pattern): interacting // suggestion list is a NON-MODAL popup (combobox-style; plain
// outside the field+list closes it but the interaction is NOT swallowed — // Tab-reachable links, no roving arrow-key focus): interacting outside
// the field+list closes it but the interaction is NOT swallowed —
// unlike the modal header panels, which scrim-dismiss in Base.astro. // unlike the modal header panels, which scrim-dismiss in Base.astro.
if (opts.dismiss) { if (opts.dismiss) {
const box = input.closest('form') || input.parentElement; const box = input.closest('form') || input.parentElement;
const hide = () => { clearTimeout(timer); list.innerHTML = ''; status.textContent = ''; }; const hide = () => { clearTimeout(timer); list.replaceChildren(); status.textContent = ''; };
document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); }); document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); });
// relatedTarget may be null mid-click on our own links — pointerdown covers that path // relatedTarget may be null mid-click on our own links — pointerdown covers that path
box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); }); box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 357 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 122 KiB

+3418
View File
File diff suppressed because one or more lines are too long
+38
View File
@@ -0,0 +1,38 @@
// Deterministic internal-link check over the built site (zero dependencies).
// Run AFTER `astro build`: scans dist/**/*.html for root-relative href/src
// values and fails (exit 1) if any target has no file in dist/. External
// URLs, mailto:, data: and pure-#fragment links are out of scope.
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
const DIST = new URL('../dist', import.meta.url).pathname;
if (!existsSync(DIST)) { console.error('dist/ not found — run `npm run build` first'); process.exit(1); }
const htmlFiles = [];
(function walk(dir) {
for (const name of readdirSync(dir)) {
const p = join(dir, name);
if (statSync(p).isDirectory()) walk(p);
else if (name.endsWith('.html')) htmlFiles.push(p);
}
})(DIST);
const resolves = (path) => {
const clean = decodeURI(path.split(/[?#]/)[0]);
if (clean === '/') return true;
return ['', '.html', '/index.html'].some((suffix) =>
existsSync(join(DIST, clean.replace(/\/$/, '') + suffix)));
};
let broken = 0;
for (const file of htmlFiles) {
const html = readFileSync(file, 'utf8');
for (const [, , url] of html.matchAll(/\s(href|src)="(\/[^"]*)"/g)) {
if (!resolves(url)) {
console.error(`broken: ${url} (in ${file.slice(DIST.length + 1)})`);
broken++;
}
}
}
console.log(broken ? `${broken} broken internal link(s)` : `OK — ${htmlFiles.length} pages, all internal links resolve`);
process.exit(broken ? 1 : 0);
+15
View File
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Local dev: put the content repo's files where the build expects them.
# Usage: ./scripts/link-content.sh [path-to-content-clone] (default ../ctao-content)
# Copies (not symlinks) so the dev tree behaves exactly like the machine
# build overlay; re-run after pulling content changes.
set -euo pipefail
cd "$(dirname "$0")/.."
src="${1:-../ctao-content}"
[ -d "$src/news" ] || { echo "content clone not found at $src (git clone <gitea>/ctao/content.git first)"; exit 1; }
rm -rf src/content/news src/content/pages public/uploads
mkdir -p src/content public
cp -a "$src/news" src/content/news
cp -a "$src/pages" src/content/pages
cp -a "$src/uploads" public/uploads
echo "content linked from $src"
+16
View File
@@ -0,0 +1,16 @@
---
// The one news card — used by the home page and the /news archive.
// `featured` (archive page 1, first item) switches the wide lead variant and
// eager-loads its cover. Cover alt stays empty by design: the image is
// decorative next to the always-visible title.
import { fmt, iso, readMin } from '../lib/news.js';
const { post, featured = false } = Astro.props;
---
<article class={featured ? 'card card--featured' : 'card'}>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={featured ? 'eager' : 'lazy'} />}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>

Some files were not shown because too many files have changed in this diff Show More