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
@@ -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>
|
||||
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 400 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 594 KiB |
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 317 KiB |
|
After Width: | Height: | Size: 453 KiB |
|
After Width: | Height: | Size: 495 KiB |
|
After Width: | Height: | Size: 595 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 420 KiB |
|
After Width: | Height: | Size: 669 KiB |
|
After Width: | Height: | Size: 600 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 57 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 675 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 609 KiB |
|
After Width: | Height: | Size: 346 KiB |
|
After Width: | Height: | Size: 750 KiB |
|
After Width: | Height: | Size: 119 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 424 KiB |
|
After Width: | Height: | Size: 535 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 61 KiB |
|
After Width: | Height: | Size: 75 KiB |
|
After Width: | Height: | Size: 361 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 110 KiB |
|
After Width: | Height: | Size: 133 KiB |
|
After Width: | Height: | Size: 141 KiB |
|
After Width: | Height: | Size: 221 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 49 KiB |
@@ -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"
|
||||
@@ -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
|
||||
`src/styles/global.css`; this file states the rules. Brand basis: CTAO Brand
|
||||
Guidelines (Apr 2024) + techniques observed on ctao.org (photography-led hero,
|
||||
nebula motion, generous spacing, 15/30/60 rounding, quiet-fade hovers).
|
||||
No frameworks, no JS for presentation.
|
||||
All styling lives in **`src/styles/global.css`** — design tokens in `:root`
|
||||
(colors, type scale, spacing, radius, shadows, `--header-h`) + plain-CSS
|
||||
components below them. No frameworks, no preprocessors, no runtime JS for
|
||||
presentation. Pages are `.astro` templates (plain HTML).
|
||||
|
||||
## Color tokens (brand-fixed — never add hues)
|
||||
## Brand (official: CTAO Brand Guidelines, April 2024 — PDF in the project docs)
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--galaxy` | `#00004A` | Headings, header/footer bands, hero scrim |
|
||||
| `--cherenkov` | `#00E4D8` | ONLY: primary CTA, flash, hero subtitle, logo cluster (+ functional: `<mark>`, TOC active rule). Never small text on light bg |
|
||||
| `--moon` | `#F5F5F5` | Utility surfaces (bands, neutral hovers) |
|
||||
| `--azure` | `#007AFF` | Focus rings and field-focus borders ONLY (no hover borders) |
|
||||
| `--indigo` | `#00009C` | Gradient depth, link hover |
|
||||
| `--text` / `--muted` / `--border` / `--link` | derived | Body ink; `--link #0057C2` is azure darkened to pass AA |
|
||||
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
|
||||
`#F5F5F5`, Cosmic Azure `#007AFF` (links/focus), Interstellar Indigo
|
||||
`#00009C`. Cherenkov is an ACCENT (brand book rations it — currently:
|
||||
primary CTA, hero subtitle, logo flash); it fails AA as text on white.
|
||||
- Type: **Inter** for everything, **Space Grotesk** (weight 500) only for
|
||||
communicative headlines (h1/h2). Sizes come from the `--fs-*` scale.
|
||||
- Logo files in `public/brand/`; always "CTAO", min 100px wide, no effects.
|
||||
|
||||
Accent discipline: Cherenkov is rationed to the four brand moments above.
|
||||
Status pills, category/date chips and metadata are plain muted text — the
|
||||
label carries the meaning, not the chrome.
|
||||
## Practical notes (the non-obvious ones)
|
||||
|
||||
## Typography
|
||||
|
||||
| Role | Font | Rule |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
| Everything else | Inter (`--font-body`) | Includes h3+ (weight 600), cards, UI |
|
||||
| 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` |
|
||||
| Exception: `.panel-title` (h2) | Inter | Panel titles are UI labels, not headlines — weight 600, `--fs-l`. These are the ONLY two exceptions |
|
||||
|
||||
Type scale — the ONLY font sizes in the codebase (no ad-hoc rem values):
|
||||
|
||||
| Token | Value | Use |
|
||||
|---|---|---|
|
||||
| `--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).
|
||||
- **`public/admin/preview.css` mirrors the `.prose` styles** so the CMS
|
||||
editor preview matches articles 1:1 — when you touch `.prose` or its
|
||||
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
|
||||
swap flash). The woff2 files in `public/fonts/` look unused but feed the
|
||||
CMS preview — keep them.
|
||||
- All `animation`/`transition` rules sit in the single
|
||||
`prefers-reduced-motion: no-preference` block at the end of global.css.
|
||||
- Scroll-driven effects need `vite.build.cssMinify: 'esbuild'`
|
||||
(astro.config.mjs) — the default minifier breaks `animation-timeline`.
|
||||
- `--header-h` drives the sticky-header offsets (anchors, TOC rail, reading
|
||||
progress); change the header height only through the token.
|
||||
- Deterministic gate before pushing: `npm run check` (build + link check).
|
||||
|
||||
@@ -6,7 +6,23 @@ 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> ·
|
||||
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
|
||||
|
||||
@@ -30,8 +46,11 @@ content repo (Gitea): <https://astro-git.isl-dev.grid.cyfronet.pl/ctao/portal>
|
||||
|
||||
```
|
||||
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
|
||||
@@ -40,11 +59,11 @@ npm run build # static output in dist/
|
||||
|---|---|
|
||||
| `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 — 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 |
|
||||
| `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/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` |
|
||||
|
||||
## Editing content
|
||||
|
||||
@@ -4,15 +4,20 @@ Everything runs rootless as user `strapi`. Footprint on the machine:
|
||||
ONE directory (`~/ctao-portal-demo/`) + 4 unit files in `~/.config/`.
|
||||
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/
|
||||
├── gitea-data/ # Gitea state (repos, SQLite, accounts) ← the ONLY thing worth backing up
|
||||
├── gitea-config/ # Gitea app.ini (generated from env)
|
||||
├── repo/ # clone of the portal repo (created by first build)
|
||||
├── releases/ # <sha>/ dirs + `current` symlink (what nginx serves)
|
||||
├── state/ # last-built SHA
|
||||
├── repo/ # clone of the code repo (created by first build)
|
||||
├── content/ # clone of the content repo (created by first build)
|
||||
├── 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)
|
||||
├── 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
|
||||
```
|
||||
|
||||
@@ -47,10 +52,11 @@ missing). Everything else runs inside containers.
|
||||
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`
|
||||
7. **[W]** In the Gitea UI: create org `ctao`, repo `portal` (public read).
|
||||
Then, from your workstation, push the portal repo over the vhost with a
|
||||
repo-scoped token:
|
||||
7. **[W]** In the Gitea UI: create org `ctao` with repos `portal` and
|
||||
`content` (both public read). Then, from your workstation, push both over
|
||||
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/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
|
||||
`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
|
||||
|
||||
- **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/` +
|
||||
`systemctl --user restart ctao-demo-web`, unit files →
|
||||
`~/.config/…` + `systemctl --user daemon-reload` (+ restart). This is
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
#!/usr/bin/env bash
|
||||
# CTAO portal demo — poll the content repo, rebuild, publish atomically.
|
||||
# Triggered every 10 s by ctao-portal-build.timer; systemd oneshot semantics
|
||||
# guarantee runs never overlap. The 99.9% case is one local curl and exit 0.
|
||||
# No secrets anywhere: the portal repo is public-read on the local Gitea.
|
||||
# CTAO portal — poll the code AND content repos, rebuild on any change,
|
||||
# publish atomically. Triggered every 10 s by ctao-portal-build.timer; systemd
|
||||
# oneshot semantics guarantee runs never overlap. The 99.9% case is two local
|
||||
# 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
|
||||
|
||||
# Defaults match the machine; every var is env-overridable so the whole
|
||||
# pipeline can be tested locally against a sandbox dir + local Gitea.
|
||||
BASE="${BASE:-$HOME/ctao-portal-demo}"
|
||||
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}"
|
||||
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
|
||||
# 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
|
||||
# ip_tables kernel module, absent on the machine — pasta needs nothing.)
|
||||
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
|
||||
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 —
|
||||
# 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; }
|
||||
done
|
||||
|
||||
# --- 1. Cheap poll: branch head via the local Gitea API (host curl + jq) ---
|
||||
sha=$(curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$REPO/branches/$BRANCH" \
|
||||
| jq -r '.commit.id' || true)
|
||||
# --- 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
|
||||
# instead of painting the journal red every 10 s.
|
||||
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "poll failed (gitea unreachable?) — skipping this tick"
|
||||
exit 0
|
||||
fi
|
||||
# Skip only if this sha is both recorded AND still present in releases/
|
||||
for sha in "$code_sha" "$content_sha"; do
|
||||
if [[ ! "$sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "poll failed (gitea unreachable?) — skipping this tick"
|
||||
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).
|
||||
[[ "$sha" == "$(cat "$BASE/state/last-built" 2>/dev/null)" \
|
||||
&& -d "$BASE/releases/$sha" ]] && exit 0
|
||||
[[ "$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)
|
||||
|
||||
# --- 2. Build in the ephemeral container (git + pinned node live there).
|
||||
# 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
|
||||
# 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.
|
||||
podman run --rm --network="$BUILD_NETNS" --memory=1g \
|
||||
-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/content:/work/content:z" \
|
||||
-v "$BASE/releases:/work/releases:z" \
|
||||
-v "$BASE/npm-cache:/root/.npm:z" \
|
||||
-w /work "$BUILD_IMAGE" sh -ec '
|
||||
git config --global safe.directory /work/repo
|
||||
[ -d repo/.git ] || git clone --branch "$BRANCH" "$REPO_URL" repo
|
||||
git -C repo remote set-url origin "$REPO_URL" # self-heal if the URL changes
|
||||
git -C repo fetch --quiet origin "$BRANCH"
|
||||
# --force: the working copy is disposable; a stray tracked-file edit must
|
||||
# not wedge every future build.
|
||||
git -C repo checkout --quiet --force "$SHA"
|
||||
git config --global safe.directory "/work/repo"
|
||||
git config --global --add safe.directory "/work/content"
|
||||
sync_clone() { # $1 dir $2 url $3 sha
|
||||
[ -d "$1/.git" ] || git clone --branch "$BRANCH" "$2" "$1"
|
||||
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
|
||||
lock=$(sha256sum package-lock.json | cut -d" " -f1)
|
||||
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
|
||||
fi
|
||||
npm run build
|
||||
rm -rf "../releases/$SHA"
|
||||
cp -a dist "../releases/$SHA"
|
||||
rm -rf "../releases/$RELEASE"
|
||||
cp -a dist "../releases/$RELEASE"
|
||||
'
|
||||
|
||||
# --- 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
|
||||
# coreutils `mv` (gmv) first in PATH.
|
||||
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"
|
||||
echo "$sha" > "$BASE/state/last-built"
|
||||
echo "$release" > "$BASE/state/last-built"
|
||||
|
||||
# --- 4. Prune old releases. `current`'s target is excluded explicitly —
|
||||
# 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"
|
||||
done || true
|
||||
|
||||
echo "published $sha in $(( $(date +%s) - t0 ))s"
|
||||
echo "published $release in $(( $(date +%s) - t0 ))s"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
# these origins in deploy/ctao-demo-gitea.container.
|
||||
backend:
|
||||
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
|
||||
base_url: https://astro-git.isl-dev.grid.cyfronet.pl
|
||||
api_root: https://astro-git.isl-dev.grid.cyfronet.pl/api/v1
|
||||
@@ -22,7 +24,7 @@ app_title: CTAO Content Editor
|
||||
logo:
|
||||
src: /brand/CTAO_Logo_login.svg
|
||||
|
||||
media_folder: "public/uploads"
|
||||
media_folder: "uploads"
|
||||
public_folder: "/uploads"
|
||||
|
||||
# Keep the git repo lean: images are stored as plain git blobs (no LFS), so
|
||||
@@ -46,7 +48,7 @@ collections:
|
||||
- name: news
|
||||
label: "News"
|
||||
label_singular: "Article"
|
||||
folder: "src/content/news"
|
||||
folder: "news"
|
||||
create: true
|
||||
slug: "{{slug}}"
|
||||
extension: md
|
||||
@@ -66,7 +68,7 @@ collections:
|
||||
- name: pages
|
||||
label: "Pages"
|
||||
label_singular: "Page"
|
||||
folder: "src/content/pages"
|
||||
folder: "pages"
|
||||
create: true
|
||||
slug: "{{slug}}"
|
||||
extension: md
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
--fs-s: 0.9rem;
|
||||
--fs-l: 1.125rem;
|
||||
--fs-xl: 1.5rem;
|
||||
--fs-h1: clamp(2.25rem, 5vw, 3.5rem);
|
||||
--fs-h2: clamp(1.75rem, 3.5vw, 2.5rem);
|
||||
--radius: 16px;
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ a { color: var(--link); text-decoration: underline; text-underline-offset: 3px;
|
||||
/* = global.css `.prose` rules, selectors unwrapped = */
|
||||
body { line-height: 1.65; overflow-wrap: break-word; }
|
||||
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); }
|
||||
h3 { color: var(--galaxy); margin: 30px 0 8px; font-size: var(--fs-l); }
|
||||
p { margin: 0 0 16px; text-wrap: pretty; }
|
||||
|
||||
@@ -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"
|
||||
@@ -3,17 +3,11 @@ title: Disclaimer
|
||||
description: Terms of use of the CTAO Science Portal.
|
||||
---
|
||||
|
||||
*Demo page — final wording 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.
|
||||
*Demo page — the final terms of use will be provided by the CTAO Central
|
||||
Organisation.*
|
||||
|
||||
## Content credit
|
||||
|
||||
Sample news content on this demonstration portal is reproduced from the official CTAO website
|
||||
([ctao.org](https://www.ctao.org/)) for evaluation purposes only.
|
||||
Sample news content on this demonstration portal is reproduced from the
|
||||
official CTAO website ([ctao.org](https://www.ctao.org/)) for evaluation
|
||||
purposes only.
|
||||
|
||||
@@ -3,14 +3,8 @@ title: Privacy statement
|
||||
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
|
||||
services: account information is managed by the central **CTAO AAI** system, and the portal
|
||||
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).
|
||||
Sign-in and account information are handled by the central **CTAO AAI**
|
||||
system; this portal stores no passwords.
|
||||
|
||||
@@ -8,8 +8,10 @@ import Base from '../layouts/Base.astro';
|
||||
<article class="article">
|
||||
<h1>Page not found</h1>
|
||||
<div class="prose">
|
||||
<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>
|
||||
{/* Keep "the <a>" on one line: the compiler drops the whitespace at a
|
||||
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>
|
||||
<form class="search-form" role="search" action="/search" method="get">
|
||||
<label class="sr-only" for="nf-q">Search news</label>
|
||||
|
||||
@@ -1,22 +1,8 @@
|
||||
---
|
||||
import Base from '../layouts/Base.astro';
|
||||
|
||||
// Sample data, clearly labelled as mock in the UI. Per SPEC §3.3.2 the dashboard
|
||||
// aggregates: proposal statuses, scheduling info, data products, help-desk tickets.
|
||||
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' },
|
||||
];
|
||||
// Per SPEC §3.3.2 the dashboard aggregates proposal statuses, data products
|
||||
// and help-desk tickets. No integrations exist yet, so panels show empty
|
||||
// states instead of fabricated sample rows (source-documents-only rule).
|
||||
---
|
||||
<Base title="CTAO Science Portal — Dashboard" description="User dashboard (mock)">
|
||||
<section class="band--moon">
|
||||
@@ -25,44 +11,18 @@ const tickets = [
|
||||
<p class="standfirst">Your proposals, data products and support tickets in one place.</p>
|
||||
</header>
|
||||
<div class="container page">
|
||||
<span class="badge-mock">Mock — sample data, no live services connected</span>
|
||||
<div class="dash-grid">
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">My proposals</h2>
|
||||
<ul class="rows">
|
||||
{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>
|
||||
<p class="notice">Proposal statuses will appear here once the Proposal Handling System (APC team) is connected.</p>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">Recent data products</h2>
|
||||
<ul class="rows">
|
||||
{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>
|
||||
<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>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2 class="panel-title">Support tickets</h2>
|
||||
<ul class="rows">
|
||||
{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>
|
||||
<p class="notice">Help-desk tickets will appear here once User Support is connected.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"/>' },
|
||||
// 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.
|
||||
{ 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"/>' },
|
||||
{ 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"/>' },
|
||||
];
|
||||
const planned = ['Scheduling', 'Science alerts'];
|
||||
|
||||
@@ -6,7 +6,6 @@ import Base from '../layouts/Base.astro';
|
||||
<div class="band-bg" aria-hidden="true"></div>
|
||||
<div class="auth-card">
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
@@ -8,8 +8,6 @@ import Base from '../layouts/Base.astro';
|
||||
<p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p>
|
||||
</header>
|
||||
<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
|
||||
(§3.3.3) scopes this portal to integrating it, so we describe the
|
||||
hand-off instead of imitating their submission UI. */}
|
||||
@@ -22,7 +20,6 @@ import Base from '../layouts/Base.astro';
|
||||
<ul class="panel-list">
|
||||
<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>Notifications via news and announcements.</li>
|
||||
</ul>
|
||||
<button class="btn" type="button" disabled>Open the Proposal Handling System — integration planned</button>
|
||||
</div>
|
||||
|
||||
@@ -15,9 +15,6 @@ const channels = [
|
||||
<p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p>
|
||||
</header>
|
||||
<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>
|
||||
<div class="grid">
|
||||
{channels.map((c) => (
|
||||
|
||||
@@ -155,6 +155,9 @@ summary::-webkit-details-marker { display: none; }
|
||||
|
||||
a { color: var(--link); text-decoration: underline; text-underline-offset: 3px; }
|
||||
::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); }
|
||||
/* Placeholder links (<a> without href) don't pretend to be clickable */
|
||||
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 > span { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.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) {
|
||||
.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 .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 .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; }
|
||||
/* 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 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[open] summary .ico { transform: rotate(180deg); }
|
||||
.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 */
|
||||
.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 */
|
||||
/* 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) —
|
||||
functional state, brand tint, no new hues */
|
||||
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 img { height: 30px; width: auto; }
|
||||
.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[href]:hover { text-decoration: underline; }
|
||||
.foot-links a:not([href]) { color: var(--on-galaxy-muted); }
|
||||
|
||||