content split + copy audit + css polish: content (news/pages/uploads) moves to the ctao/content repo (build overlays it — two-repo build.sh, link-content.sh for local dev); portal copy trimmed to source-documents-only (dashboard empty states, minimal privacy/disclaimer, no mock badges); css: inverted selection on navy bands, featured-card text-track floor, 44px toc target, mobile search dates on own line, footer link row gap; DESIGN.md rewritten as a short handoff

This commit is contained in:
2026-09-03 18:54:01 +02:00
parent 2e6d374b4b
commit ec4ff5dfa2
93 changed files with 259 additions and 464 deletions
+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, article headlines (news titles run long — full `--fs-h1` would fill the viewport) |
| `--fs-h1` | clamp(2.25–3.5rem) | Short page heads only ("News & Announcements") |
| `--fs-display` | clamp(2.5–4.75rem) | Home hero only |
Font tokens end with a generic (`sans-serif`); use them bare —
`var(--font-body), sans-serif` doubles the generic.
Font loading (prevents any navigation blink): BOTH families are inlined as
base64 `data:` URIs inside the render-blocking `global.css` — no separate
font fetch exists, so there is no swap window and no font flash between
navigations. Metric-matched Arial fallbacks (`Inter Fallback`,
`Space Grotesk Fallback` — size-adjust/ascent/descent computed from the real
font metrics, capsize method) cover only the pre-CSS paint. The standalone
`public/fonts/*.woff2` files are referenced ONLY by
`public/admin/preview.css` (CMS entry preview) — they look unused but aren't.
## 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 build && npm run 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).
+23 -4
View File
@@ -6,7 +6,23 @@ a build job turns commits into static HTML. No application or database runs
on the public path. on the public path.
Live demo: <https://astro.isl-dev.grid.cyfronet.pl> · Live demo: <https://astro.isl-dev.grid.cyfronet.pl> ·
content repo (Gitea): <https://astro-git.isl-dev.grid.cyfronet.pl/ctao/portal> 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 ## Stack
@@ -30,8 +46,11 @@ content repo (Gitea): <https://astro-git.isl-dev.grid.cyfronet.pl/ctao/portal>
``` ```
npm ci 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 dev # http://localhost:4321
npm run build # static output in dist/ npm run build # static output in dist/
npm run check # deterministic gate: build + internal-link check
``` ```
## Layout ## Layout
@@ -40,11 +59,11 @@ npm run build # static output in dist/
|---|---| |---|---|
| `src/pages/` | Routes (`.astro` templates), incl. `news/`, `search`, RSS/sitemap | | `src/pages/` | Routes (`.astro` templates), incl. `news/`, `search`, RSS/sitemap |
| `src/layouts/Base.astro` | HTML shell: head, header/nav, footer | | `src/layouts/Base.astro` | HTML shell: head, header/nav, footer |
| `src/content/news/*.md` | Articles — Markdown + frontmatter (schema in `src/content.config.ts`) | | `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 | | `src/styles/global.css` | All CSS: tokens + components + prose |
| `DESIGN.md` | Design-system rules the CSS implements | | `DESIGN.md` | Design handoff: where the tokens live, brand basics, practical notes |
| `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) | | `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) |
| `public/uploads/` | Editor-uploaded media (committed as WebP by the CMS) | | `public/uploads/` | Editor-uploaded media (from the content repo — gitignored here) |
| `deploy/` | Runbook + container/systemd units for the demo machine — see `deploy/README.md` | | `deploy/` | Runbook + container/systemd units for the demo machine — see `deploy/README.md` |
## Editing content ## Editing content
+15 -8
View File
@@ -4,15 +4,20 @@ 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)
├── state/ # last-built release id
├── npm-cache/ # npm cache for the build container (created by build.sh) ├── npm-cache/ # npm cache for the build container (created by build.sh)
├── bin/build.sh # copied from deploy/ (source of truth stays in the repo) ├── bin/build.sh # copied from deploy/ (source of truth stays in the code repo)
└── config/nginx.conf └── config/nginx.conf
``` ```
@@ -47,10 +52,11 @@ missing). Everything else runs inside containers.
6. **[W]** Create the Gitea admin — run interactively in a terminal so the 6. **[W]** Create the Gitea admin — run interactively in a terminal so the
password never lands in a file or shell history: 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`
7. **[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
Then, from your workstation, push the portal repo over the vhost with a `content` (both public read). Then, from your workstation, push both over
repo-scoped token: the vhost with a repo-scoped token:
`git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main` `git push https://<user>:<token>@astro-git.isl-dev.grid.cyfronet.pl/ctao/portal.git main`
`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 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>`
@@ -88,7 +94,8 @@ loginctl disable-linger $USER # only if nothing else of yours should survive l
## Notes ## Notes
- **Only site content auto-deploys.** Changes to `deploy/*` need a manual - **Only pushes to the machine's Gitea auto-deploy** (code and content repos
alike). Changes to `deploy/*` need a manual
re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` + re-copy: `build.sh` → `bin/`, `nginx.conf` → `config/` +
`systemctl --user restart ctao-demo-web`, unit files → `systemctl --user restart ctao-demo-web`, unit files →
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is `~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
+56 -28
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,9 +24,10 @@ 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"
# Host prerequisites (everything else runs inside containers). Fail loud — # Host prerequisites (everything else runs inside containers). Fail loud —
# a missing tool is permanent, unlike a Gitea hiccup below. # a missing tool is permanent, unlike a Gitea hiccup below.
@@ -27,43 +35,63 @@ for tool in curl jq podman; do
command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; } command -v "$tool" >/dev/null || { echo "missing host tool: $tool"; exit 1; }
done done
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) --- # --- 1. Cheap poll: both branch heads via the local Gitea API ---
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \ head_of() {
| jq -r '.commit.id' || true) 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.
for sha in "$code_sha" "$content_sha"; do
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "poll failed (gitea unreachable?) — skipping this tick" echo "poll failed (gitea unreachable?) — skipping this tick"
exit 0 exit 0
fi fi
# Skip only if this sha is both recorded AND still present in releases/ 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). # (a deleted release dir must trigger a rebuild, not an eternal skip).
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \ [[ "$release" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
&& -d "$BASE/releases/$sha" ]] && exit 0 && -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 /work/repo 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"
# --force: the working copy is disposable; a stray tracked-file edit must git -C "$1" remote set-url origin "$2" # self-heal if the URL changes
# not wedge every future build. git -C "$1" fetch --quiet origin "$BRANCH"
git -C repo checkout --quiet --force "$SHA" # --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
@@ -76,17 +104,17 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
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. `current`'s target is excluded explicitly — # --- 4. Prune old releases. `current`'s target is excluded explicitly —
# mtime ordering makes it newest today, but nothing should depend on that. # mtime ordering makes it newest today, but nothing should depend on that.
@@ -98,4 +126,4 @@ ls -1t | grep -vx current | grep -vx -- "$cur" | tail -n +"$((KEEP + 1))" | whil
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"
+6 -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
@@ -66,7 +68,7 @@ collections:
- 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
+2 -2
View File
@@ -30,7 +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-h1: clamp(2.25rem, 5vw, 3.5rem); --fs-h2: clamp(1.75rem, 3.5vw, 2.5rem);
--radius: 16px; --radius: 16px;
} }
@@ -52,7 +52,7 @@ 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; }
a { overflow-wrap: anywhere; } a { overflow-wrap: anywhere; }
h1 { color: var(--galaxy); font-size: var(--fs-h1); } 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; }
+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"
+5 -11
View File
@@ -3,17 +3,11 @@ title: Disclaimer
description: Terms of use of the CTAO Science Portal. description: Terms of use of the CTAO Science Portal.
--- ---
*Demo page — final wording will be provided by the CTAO Central Organisation.* *Demo page — the final terms of use will be provided by the CTAO Central
Organisation.*
The information on this portal is provided by the Cherenkov Telescope Array Observatory (CTAO)
for general information and scientific use. While we strive to keep content accurate and up to
date, the CTAO makes no warranties of any kind about the completeness, accuracy or reliability
of the information, software or data products made available here.
External links are provided for convenience; the CTAO is not responsible for the content of
external sites.
## Content credit ## Content credit
Sample news content on this demonstration portal is reproduced from the official CTAO website Sample news content on this demonstration portal is reproduced from the
([ctao.org](https://www.ctao.org/)) for evaluation purposes only. official CTAO website ([ctao.org](https://www.ctao.org/)) for evaluation
purposes only.
+4 -10
View File
@@ -3,14 +3,8 @@ title: Privacy statement
description: How the CTAO Science Portal handles personal data. description: How the CTAO Science Portal handles personal data.
--- ---
*Demo page — final wording will be provided by the CTAO Central Organisation.* *Demo page — the final privacy statement will be provided by the CTAO Central
Organisation.*
The CTAO Science Portal processes personal data only to the extent required to provide its Sign-in and account information are handled by the central **CTAO AAI**
services: account information is managed by the central **CTAO AAI** system, and the portal system; this portal stores no passwords.
stores no passwords. Usage analytics, if any, are anonymised.
## Your rights
Under the GDPR you have the right to access, rectify, erase and port your personal data, and
to object to or restrict its processing. Requests are handled by the CTAO data protection
officer (contact details on the [Contact](/pages/contact) page).
+4 -2
View File
@@ -8,8 +8,10 @@ import Base from '../layouts/Base.astro';
<article class="article"> <article class="article">
<h1>Page not found</h1> <h1>Page not found</h1>
<div class="prose"> <div class="prose">
<p>The address may be mistyped or the page may have moved — go to the {/* Keep "the <a>" on one line: the compiler drops the whitespace at a
<a href="/">home page</a> or browse <a href="/news">all news</a>.</p> line break before a tag, which glued "the" to the link text. */}
<p>The address may be mistyped or the page may have moved —
go to the <a href="/">home page</a> or browse <a href="/news">all news</a>.</p>
</div> </div>
<form class="search-form" role="search" action="/search" method="get"> <form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="nf-q">Search news</label> <label class="sr-only" for="nf-q">Search news</label>
+6 -46
View File
@@ -1,22 +1,8 @@
--- ---
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
// Per SPEC §3.3.2 the dashboard aggregates proposal statuses, data products
// Sample data, clearly labelled as mock in the UI. Per SPEC §3.3.2 the dashboard // and help-desk tickets. No integrations exist yet, so panels show empty
// aggregates: proposal statuses, scheduling info, data products, help-desk tickets. // states instead of fabricated sample rows (source-documents-only rule).
const proposals = [
{ id: 'PROP-2026-0142', title: 'AGN flare monitoring with LST sub-array', status: 'In review', kind: 'review' },
{ id: 'PROP-2026-0089', title: 'Galactic PeVatron survey follow-up', status: 'Accepted', kind: 'ok' },
{ id: 'PROP-2025-0231', title: 'GRB afterglow ToO programme', status: 'Draft', kind: 'draft' },
];
const products = [
{ id: 'OBS-88213', desc: 'DL3 event lists — PKS 2155-304 (12 runs)', date: '2026-07-18' },
{ id: 'OBS-87990', desc: 'DL3 event lists — Crab Nebula calibration', date: '2026-07-02' },
];
const fmt = (iso) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'medium' }).format(new Date(iso));
const tickets = [
{ id: '#4821', subject: 'Question about co-investigator roles', status: 'Answered', kind: 'ok' },
{ id: '#4711', subject: 'Data download quota question', status: 'Open', kind: 'review' },
];
--- ---
<Base title="CTAO Science Portal — Dashboard" description="User dashboard (mock)"> <Base title="CTAO Science Portal — Dashboard" description="User dashboard (mock)">
<section class="band--moon"> <section class="band--moon">
@@ -25,44 +11,18 @@ const tickets = [
<p class="standfirst">Your proposals, data products and support tickets in one place.</p> <p class="standfirst">Your proposals, data products and support tickets in one place.</p>
</header> </header>
<div class="container page"> <div class="container page">
<span class="badge-mock">Mock — sample data, no live services connected</span>
<div class="dash-grid"> <div class="dash-grid">
<div class="panel"> <div class="panel">
<h2 class="panel-title">My proposals</h2> <h2 class="panel-title">My proposals</h2>
<ul class="rows"> <p class="notice">Proposal statuses will appear here once the Proposal Handling System (APC team) is connected.</p>
{proposals.map((p) => (
<li>
<span class="row-id">{p.id}</span>
<span class="row-main">{p.title}</span>
<span class={`pill pill--${p.kind}`}>{p.status}</span>
</li>
))}
</ul>
</div> </div>
<div class="panel"> <div class="panel">
<h2 class="panel-title">Recent data products</h2> <h2 class="panel-title">Recent data products</h2>
<ul class="rows"> <p class="notice">Your data products will appear here. Search and download in the <a href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener" class="external">Data Explorer</a> (external, LUX team).</p>
{products.map((d) => (
<li>
<span class="row-id">{d.id}</span>
<span class="row-main">{d.desc}</span>
<time class="row-date" datetime={d.date}>{fmt(d.date)}</time>
</li>
))}
</ul>
<p class="notice">Search and download in the <a href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener" class="external">Data Explorer</a> (external, LUX team).</p>
</div> </div>
<div class="panel"> <div class="panel">
<h2 class="panel-title">Support tickets</h2> <h2 class="panel-title">Support tickets</h2>
<ul class="rows"> <p class="notice">Help-desk tickets will appear here once User Support is connected.</p>
{tickets.map((t) => (
<li>
<span class="row-id">{t.id}</span>
<span class="row-main">{t.subject}</span>
<span class={`pill pill--${t.kind}`}>{t.status}</span>
</li>
))}
</ul>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -25,9 +25,9 @@ const services = [
icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' }, icon: '<circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4"/><path d="m5.7 5.7 3.5 3.5m5.6 0 3.5-3.5m0 12.6-3.5-3.5m-5.6 0-3.5 3.5"/>' },
// Two subdued planned tiles complete the 2×3 grid (an orphan 4th tile in a // Two subdued planned tiles complete the 2×3 grid (an orphan 4th tile in a
// 3-col grid reads broken); the remaining TBD services stay on the quiet line. // 3-col grid reads broken); the remaining TBD services stay on the quiet line.
{ title: 'Software', planned: true, desc: 'Science analysis tools and pipelines — planned.', { title: 'Software', planned: true, desc: 'Landing page for CTAO software — planned.',
icon: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15H17"/>' }, icon: '<rect x="3" y="4.5" width="18" height="15" rx="2"/><path d="m7 9.5 3 2.5-3 2.5M12.5 15H17"/>' },
{ title: 'Documentation', planned: true, desc: 'User guides and technical documentation — planned.', { title: 'Documentation', planned: true, desc: 'CTAO user documentation — planned.',
icon: '<path d="M12 6.7C10.6 5.2 8.6 4.5 5.7 4.5H4v13.6h1.7c2.9 0 4.9.7 6.3 2.2 1.4-1.5 3.4-2.2 6.3-2.2H20V4.5h-1.7c-2.9 0-4.9.7-6.3 2.2z"/><path d="M12 6.7v13.6"/>' }, icon: '<path d="M12 6.7C10.6 5.2 8.6 4.5 5.7 4.5H4v13.6h1.7c2.9 0 4.9.7 6.3 2.2 1.4-1.5 3.4-2.2 6.3-2.2H20V4.5h-1.7c-2.9 0-4.9.7-6.3 2.2z"/><path d="M12 6.7v13.6"/>' },
]; ];
const planned = ['Scheduling', 'Science alerts']; const planned = ['Scheduling', 'Science alerts'];
-1
View File
@@ -6,7 +6,6 @@ import Base from '../layouts/Base.astro';
<div class="band-bg" aria-hidden="true"></div> <div class="band-bg" aria-hidden="true"></div>
<div class="auth-card"> <div class="auth-card">
<img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" /> <img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" />
<span class="badge-mock">Mock — AAI integration pending</span>
<h1>Sign in</h1> <h1>Sign in</h1>
{/* Auth pages are scanned, not read — one line, no reassurance prose */} {/* Auth pages are scanned, not read — one line, no reassurance prose */}
<p class="muted">Use your CTAO account — single sign-on via CTAO AAI.</p> <p class="muted">Use your CTAO account — single sign-on via CTAO AAI.</p>
-3
View File
@@ -8,8 +8,6 @@ import Base from '../layouts/Base.astro';
<p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p> <p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p>
</header> </header>
<div class="container page"> <div class="container page">
<span class="badge-mock">Mock — PHS not yet connected</span>
<span class="badge-ext">Planned integration: Proposal Handling System (APC team)</span>
{/* Integration landing, not a form: the PHS is APC's product — the spec {/* Integration landing, not a form: the PHS is APC's product — the spec
(§3.3.3) scopes this portal to integrating it, so we describe the (§3.3.3) scopes this portal to integrating it, so we describe the
hand-off instead of imitating their submission UI. */} hand-off instead of imitating their submission UI. */}
@@ -22,7 +20,6 @@ import Base from '../layouts/Base.astro';
<ul class="panel-list"> <ul class="panel-list">
<li>This menu entry, with a single sign-on hand-off (CTAO AAI).</li> <li>This menu entry, with a single sign-on hand-off (CTAO AAI).</li>
<li>Proposal status feedback in your <a href="/dashboard">Dashboard</a>.</li> <li>Proposal status feedback in your <a href="/dashboard">Dashboard</a>.</li>
<li>Notifications via news and announcements.</li>
</ul> </ul>
<button class="btn" type="button" disabled>Open the Proposal Handling System — integration planned</button> <button class="btn" type="button" disabled>Open the Proposal Handling System — integration planned</button>
</div> </div>
-3
View File
@@ -15,9 +15,6 @@ const channels = [
<p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p> <p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p>
</header> </header>
<div class="container page"> <div class="container page">
{/* One page-level whisper badge — the tiles below stay clean (they are
not interactive; honesty lives here, at the point users arrive). */}
<span class="badge-mock">Mock — target: SUSS User Support system</span>
<h2 class="sr-only">Support channels</h2> <h2 class="sr-only">Support channels</h2>
<div class="grid"> <div class="grid">
{channels.map((c) => ( {channels.map((c) => (
+24 -4
View File
@@ -155,6 +155,9 @@ summary::-webkit-details-marker { display: none; }
a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; } a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }
::selection { background: var(--galaxy); color: #fff; } ::selection { background: var(--galaxy); color: #fff; }
/* On the navy bands the Galaxy fill would be invisible (band ≈ selection):
the pair inverts, no new colors. */
:is(.site-header, .hero-band, .auth-band, .footer) ::selection { background: #fff; color: var(--galaxy); }
a:hover { color: var(--indigo); } a:hover { color: var(--indigo); }
/* Placeholder links (<a> without href) don't pretend to be clickable */ /* Placeholder links (<a> without href) don't pretend to be clickable */
a:not([href]) { text-decoration: none; cursor: default; } a:not([href]) { text-decoration: none; cursor: default; }
@@ -472,11 +475,18 @@ li.suggest-label {
.card .meta { margin-top: auto; color: var(--muted); font-size: var(--fs-xs); display: flex; align-items: center; gap: 12px; } .card .meta { margin-top: auto; color: var(--muted); font-size: var(--fs-xs); display: flex; align-items: center; gap: 12px; }
.card .meta > span { display: inline-flex; align-items: center; gap: 4px; } .card .meta > span { display: inline-flex; align-items: center; gap: 4px; }
.card .meta .ico { width: 13px; height: 13px; stroke-width: 1.8; } .card .meta .ico { width: 13px; height: 13px; stroke-width: 1.8; }
/* Lead story — larger, splits when the grid itself is wide enough (container query) */ /* Lead story — spans the grid at every width (it sits outside the card subgrid
above, so it has to own its row), splitting into cover | text once the grid
is wide enough. The text track has a floor: at a plain 2fr it fell to ~220px
on a ~700px grid and truncated the one headline that must read in full. */
.card--featured { grid-column: 1 / -1; }
@container (min-width: 640px) { @container (min-width: 640px) {
.card--featured { grid-column: 1 / -1; display: grid; grid-template-columns: 3fr 2fr; } .card--featured { display: grid; grid-template-columns: 3fr minmax(20rem, 2fr); }
.card--featured .cover { height: 100%; aspect-ratio: auto; } .card--featured .cover { height: 100%; aspect-ratio: auto; }
.card--featured .body { padding: var(--space-l); justify-content: center; } .card--featured .body { padding: var(--space-l); justify-content: center; }
/* The auto margin that bottoms the meta in a vertical card would absorb all
the slack here and defeat the centering */
.card--featured .meta { margin-top: 0; }
.card--featured h3 { font-size: var(--fs-xl); font-family: var(--font-display); font-weight: 500; -webkit-line-clamp: 3; line-clamp: 3; } .card--featured h3 { font-size: var(--fs-xl); font-family: var(--font-display); font-weight: 500; -webkit-line-clamp: 3; line-clamp: 3; }
.card--featured .desc { -webkit-line-clamp: 3; line-clamp: 3; } .card--featured .desc { -webkit-line-clamp: 3; line-clamp: 3; }
} }
@@ -564,7 +574,7 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.toc a[aria-current="true"] { color: var(--galaxy); font-weight: 600; } .toc a[aria-current="true"] { color: var(--galaxy); font-weight: 600; }
/* Inline rendering (< 1200px): light disclosure row with a chevron */ /* Inline rendering (< 1200px): light disclosure row with a chevron */
.toc--inline { border: 1px solid var(--border); border-radius: var(--radius); padding: 4px var(--space-m); margin-bottom: var(--space-l); } .toc--inline { border: 1px solid var(--border); border-radius: var(--radius); padding: 4px var(--space-m); margin-bottom: var(--space-l); }
.toc--inline summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-s); min-height: 40px; } .toc--inline summary { display: flex; align-items: center; justify-content: space-between; gap: var(--space-s); min-height: 44px; } /* ≥44px tap target */
.toc--inline summary .ico { width: 14px; height: 14px; stroke-width: 2.6; } /* optical weight match at 14px */ .toc--inline summary .ico { width: 14px; height: 14px; stroke-width: 2.6; } /* optical weight match at 14px */
.toc--inline[open] summary .ico { transform: rotate(180deg); } .toc--inline[open] summary .ico { transform: rotate(180deg); }
.toc--inline ol { padding-bottom: var(--space-s); } .toc--inline ol { padding-bottom: var(--space-s); }
@@ -670,6 +680,14 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
/* Dates on results and suggestions — plain muted text, no chip chrome */ /* Dates on results and suggestions — plain muted text, no chip chrome */
.search-results small, .suggest small { color: var(--muted); white-space: nowrap; font-size: var(--fs-xs); } .search-results small, .suggest small { color: var(--muted); white-space: nowrap; font-size: var(--fs-xs); }
.search-results small { margin-top: 5px; } /* optical align with the first title line */ .search-results small { margin-top: 5px; } /* optical align with the first title line */
/* Phone: the date takes its own line. Beside a nowrap date the title measure
drops to ~210px at 390px — six-line result titles. */
@media (max-width: 640px) {
/* stretch, not the inherited flex-start: in column direction flex-start
sizes children to max-content width and long titles overflow the card */
.search-results li { flex-direction: column; align-items: stretch; }
.search-results small { margin-top: 0; }
}
/* Matched-term highlight in results/suggestions (Starlight search pattern) — /* Matched-term highlight in results/suggestions (Starlight search pattern) —
functional state, brand tint, no new hues */ functional state, brand tint, no new hues */
mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; } mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(var(--radius) - 12px); padding: 0 2px; }
@@ -739,7 +757,9 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
.foot-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-s) var(--space-m); } .foot-top { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-s) var(--space-m); }
.foot-top img { height: 30px; width: auto; } .foot-top img { height: 30px; width: auto; }
.foot-top p { margin: 0; max-width: 60ch; } .foot-top p { margin: 0; max-width: 60ch; }
.foot-links { display: flex; flex-wrap: wrap; gap: 4px var(--space-m); } /* Row gap on the scale: wrapped link rows sat 4px apart on phones (28px pitch
for 24px targets) */
.foot-links { display: flex; flex-wrap: wrap; gap: var(--space-s) var(--space-m); }
.foot-links a { color: #fff; font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; } .foot-links a { color: #fff; font-weight: 500; text-decoration: none; display: inline-flex; align-items: center; min-height: 24px; }
.foot-links a[href]:hover { text-decoration: underline; } .foot-links a[href]:hover { text-decoration: underline; }
.foot-links a:not([href]) { color: var(--on-galaxy-muted); } .foot-links a:not([href]) { color: var(--on-galaxy-muted); }