feat: content-UX — header search, metadata de-noise, icon/microcopy coherence, article composition

This commit is contained in:
ctao
2026-07-25 07:32:11 +02:00
parent a8833ce736
commit 419e9a2dd1
12 changed files with 361 additions and 119 deletions
+73
View File
@@ -123,6 +123,79 @@ is untouched; the TOC works fully without JS. Same precedent covers the search
`<mark>` term highlight (Cherenkov tint) and the `.pagination` pill nav `<mark>` term highlight (Cherenkov tint) and the `.pagination` pill nav
(Galaxy pill = current page, 44px targets). (Galaxy pill = current page, 44px targets).
## Header search & one-row width math
Search lives in the header. Desktop (≥1025px): compact GET-form pill (flex
130–180px) in the utility cluster; suggestions reuse `.suggest`. ≤1024px: a
44px magnifier `<details>` reveals a full-width search row under the header
(visible search beats search buried in a menu — NN/g mobile search). Both
forms are plain `GET /search` (full no-JS path); `<details name="header-panel">`
makes search + menu a native exclusive pair (no JS). The `/search` page stays
the full-results and deep-link target.
Nav content rule: the header nav reflects end-user tasks only (NN/g —
navigation mirrors user goals, not org tooling). The content editor is
internal-team tooling (SPEC §3.2), so it lives as a discreet "Content editor"
utility link in the footer, pointed to by the demo note; /admin keeps working
as the direct URL.
One-row invariant: `.nav > *` is `flex: none`; only the spacer and the search
field flex, so the row can never wrap or overflow. Tier math (Inter widths
estimated ±8%; logo 153:32 → 124px at 26px height; inner width = viewport −
2×gutter, container caps at 1120):
| Tier | Budget | Available |
|---|---|---|
| ≥1100 | brand+sub 247 + nav 415 + search 186 + Sign in 91 + gaps 16 ≈ 955 | 1044 @1100 |
| 1025–1099 (sub-brand hidden) | ≈ 842 | 969 @1025 |
| 481–1024 (menu + magnifier) | brand 134 + magnifier 44 + Sign in 91 + Menu 92 + gaps 16 ≈ 377 | 445 @481 |
| ≤480 (logo 22px, icon-only menu) | ≈ 312 | 339 @375 |
| ≤360 (compact paddings, gap 2) | ≈ 277 | 292 @320 |
The search field's 50px shrink range (180 → 130) absorbs estimate error in
every tier. No focus-grow on the field: a width transition would be a layout
animation (motion policy allows transform/opacity only) and an unanimated
snap is worse than a calm fixed width.
## Content & microcopy
- Card metadata must differentiate (NN/g: omit non-differentiating metadata):
category and author are identical on all articles, so cards show
date · reading time (body words ÷ 220 wpm, computed at build). Category and
author remain on the article page; meta order there is
category · author · date · read time.
- Badges: "Mock — <what's missing>" (bare "Mock" only on tiles); "External —
<team>"; "Planned — not yet available". Mock badges are never removed.
- Terminology is 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>` itself: `<mark>` term-highlight splits the
title into fragments, and flex would turn each into its own item.
- 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 —
never rely on it for new components.
- Articles end with `.post-nav` (Newer ← → Older from the date-sorted
collection). Image captions: italic line under the image
(`.prose img + em` / `p > em:only-child`), muted and smaller.
### Element rules (condensed)
| Element | Rule | Source |
|---|---|---|
| Phase banner | THE one loud demo disclosure: Moon bar above header, "Demo" tag + one sentence | GOV.UK phase banner |
| Header nav | End-user tasks only; order News · Data · Proposals · Dashboard · Support (entry first, help last, personal area next to Sign in) | NN/g serial-position |
| Header search | Compact field ≥1025px, magnifier reveal ≤1024px; both plain GET /search | NN/g site search |
| Hero (home) | Headline + brand subtitle + two task CTAs (Cherenkov primary, ghost secondary) + one-line Latest teaser. Never a second search box; no fake live widgets | hero-CTA convention, one-search-location |
| Badges | Quiet hairline pill, muted sentence-case text, Galaxy dot on mocks; "Mock — reason"; never removed | REQUIREMENTS §5, NN/g hierarchy |
| Cards | Cover · title (clamp 3) · desc (clamp 2) · date · read time; no non-differentiating metadata | NN/g metadata |
| Pagination | Newer/Older + windowed numbers, 44px targets, aria-current | NN/g pagination |
| Article | Back "← All news"; meta category · author · date · read time; italic muted captions; TOC at ≥3 headings; post-nav | editorial convention |
| Forms | Visible labels above fields; placeholders are examples only; buttons start with a verb | GOV.UK forms, NN/g |
| Login | One line + button + one small-print line; no reassurance prose | auth brevity |
| Footer | Identity line → Contact · Search · Disclaimer · Privacy · Site settings · Content editor → © line; no implementation talk in UI copy | NN/g footers |
| Search results | Links lay out as flowing text (mark-safe); date chip; "Press Enter for all results" hint | — |
## Do not ## Do not
- No new hues, tints, or grays — derive via `color-mix` from brand tokens only. - No new hues, tints, or grays — derive via `color-mix` from brand tokens only.
+10 -10
View File
@@ -18,8 +18,8 @@ public_folder: "/uploads"
collections: collections:
- name: news - name: news
label: "Newsy" label: "News"
label_singular: "Artykuł" label_singular: "Article"
folder: "src/content/news" folder: "src/content/news"
create: true create: true
slug: "{{slug}}" slug: "{{slug}}"
@@ -27,14 +27,14 @@ collections:
format: frontmatter format: frontmatter
summary: "{{title}} · {{date}}" summary: "{{title}} · {{date}}"
fields: fields:
- { name: title, label: "Tytuł", widget: string } - { name: title, label: "Title", widget: string }
- { name: description, label: "Krótki opis", widget: text } - { name: description, label: "Short description", widget: text }
- { name: date, label: "Data", widget: datetime, date_format: "YYYY-MM-DD", time_format: false } - { name: date, label: "Date", widget: datetime, date_format: "YYYY-MM-DD", time_format: false }
- { name: category, label: "Kategoria", widget: string, default: "news" } - { name: category, label: "Category", widget: string, default: "news" }
- { name: author, label: "Autor", widget: string, default: "CTAO" } - { name: author, label: "Author", widget: string, default: "CTAO" }
- { name: cover, label: "Okładka", widget: image, required: false } - { name: cover, label: "Cover image", widget: image, required: false }
- { name: draft, label: "Szkic (nieopublikowany)", widget: boolean, default: false } - { name: draft, label: "Draft (unpublished)", widget: boolean, default: false }
- { name: body, label: "Treść", widget: markdown } - { name: body, label: "Body", widget: markdown }
- name: pages - name: pages
label: "Pages" label: "Pages"
+62 -19
View File
@@ -11,12 +11,15 @@ const ogImage = new URL(image || '/brand/og-card.jpg', site);
const path = Astro.url.pathname; const path = Astro.url.pathname;
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href)); const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5). // Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// External systems built by other teams are links out, clearly marked. // Order: serial-position effect (NN/g) — News (universal entry) first, Support
// (help convention) last; Data/Proposals by task frequency; Dashboard is the
// personal area, placed late next to the Sign-in cluster. External systems
// built by other teams are links out, clearly marked.
const links = [ const links = [
{ href: '/news', label: 'News' }, { href: '/news', label: 'News' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/proposals', label: 'Proposals' }, { href: '/proposals', label: 'Proposals' },
{ href: '/dashboard', label: 'Dashboard' }, { href: '/dashboard', label: 'Dashboard' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/support', label: 'Support' }, { href: '/support', label: 'Support' },
]; ];
--- ---
@@ -45,6 +48,15 @@ const links = [
</head> </head>
<body class={ambient ? 'has-ambient' : undefined}> <body class={ambient ? 'has-ambient' : undefined}>
<a class="skip" href="#main">Skip to content</a> <a class="skip" href="#main">Skip to content</a>
{/* Demo disclosure — GOV.UK phase-banner pattern: one slim, honest banner
instead of implementation talk scattered through the UI. Not sticky;
it scrolls away and the header sticks below it. */}
<div class="phase-banner">
<div class="container">
<strong class="phase-tag">Demo</strong>
<span>This is a demonstration of the CTAO Science Portal — content is sample data.</span>
</div>
</div>
<header class="site-header"> <header class="site-header">
<div class="container nav"> <div class="container nav">
<a class="brand" href="/" aria-label="CTAO Science Portal — home"> <a class="brand" href="/" aria-label="CTAO Science Portal — home">
@@ -59,17 +71,37 @@ const links = [
))} ))}
</nav> </nav>
<span class="spacer"></span> <span class="spacer"></span>
<a class="navlink" href="/search">Search</a> {/* Site search lives in the header (desktop: compact field; ≤1024px: the
<a class="navlink navlink--edit" href="/admin" title="Content editor (demo CMS)"> magnifier <details> below). Plain GET form — works without JS; with JS
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M17 3l4 4L8 20l-5 1 1-5L17 3z" /></svg> the shared suggest dropdown appears (search-client.js). */}
Editor <form class="nav-search" role="search" aria-label="Site search" action="/search" method="get">
</a> <label class="sr-only" for="hdr-q">Search news</label>
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
<input id="hdr-q" name="q" type="search" placeholder="Search…" autocomplete="off" />
<ul class="suggest" id="hdr-suggest"></ul>
<p class="sr-only" id="hdr-suggest-status" role="status" aria-live="polite"></p>
</form>
<a class="login-mock" href="/login">Sign in</a> <a class="login-mock" href="/login">Sign in</a>
{/* ≤1024px: magnifier button reveals a full-width search row. name="header-panel"
pairs it with the menu as a native exclusive group — opening one closes
the other, no JS (exclusive-accordion behavior of <details name>). */}
<details class="search-pop" name="header-panel">
<summary aria-label="Search">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg>
</summary>
<form role="search" aria-label="Site search" action="/search" method="get">
<label class="sr-only" for="pop-q">Search news</label>
<input id="pop-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
<ul class="suggest" id="pop-suggest"></ul>
<p class="sr-only" id="pop-suggest-status" role="status" aria-live="polite"></p>
</form>
</details>
{/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */} {/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */}
<details class="menu"> <details class="menu" name="header-panel">
<summary> <summary aria-label="Menu">
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path class="i-menu" d="M3 6h18M3 12h18M3 18h18" /><path class="i-close" d="M5 5l14 14M19 5 5 19" /></svg> <svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path class="i-menu" d="M3 6h18M3 12h18M3 18h18" /><path class="i-close" d="M5 5l14 14M19 5 5 19" /></svg>
Menu <span class="menu-label">Menu</span>
</summary> </summary>
<nav aria-label="Main navigation"> <nav aria-label="Main navigation">
{links.map((l) => l.ext ? ( {links.map((l) => l.ext ? (
@@ -77,12 +109,21 @@ const links = [
) : ( ) : (
<a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a> <a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))} ))}
<a href="/search">Search</a>
<a href="/admin">Editor</a>
</nav> </nav>
</details> </details>
</div> </div>
</header> </header>
<script is:inline src="/search-client.js"></script>
<script is:inline>
// Header search suggestions (desktop field + mobile reveal); the forms are
// plain GET /search, so everything below is progressive enhancement.
newsSearch({ input: 'hdr-q', list: 'hdr-suggest', status: 'hdr-suggest-status', limit: 6, unit: 'suggestion' });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion' });
var pop = document.querySelector('.search-pop');
pop.addEventListener('toggle', function () {
if (pop.open) document.getElementById('pop-q').focus();
});
</script>
<main id="main"> <main id="main">
<slot /> <slot />
</main> </main>
@@ -92,23 +133,25 @@ const links = [
<div class="container"> <div class="container">
<div class="foot-top"> <div class="foot-top">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" /> <img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<p>Science Portal — news, observation proposals, data and user services of the <p>CTAO Science Portal — demo by Cyfronet (SUSS-PORT team).</p>
Cherenkov Telescope Array Observatory.</p>
</div> </div>
{/* Order: action/utility first, legal middle, meta last (GOV.UK/NN-g
footer anatomy). Spec §3.3.1 requires the five items, not an order. */}
<nav class="foot-links" aria-label="Footer"> <nav class="foot-links" aria-label="Footer">
<a href="/pages/contact">Contact</a>
<a href="/search">Search</a>
<a href="/pages/disclaimer">Disclaimer</a> <a href="/pages/disclaimer">Disclaimer</a>
<a href="/pages/privacy">Privacy</a> <a href="/pages/privacy">Privacy</a>
<a href="/pages/contact">Contact</a>
<a>Site settings</a> <a>Site settings</a>
<a href="/search">Search</a> {/* Internal tooling stays out of the main nav (NN/g: navigation reflects
user tasks); the editor is a discreet utility link for the demo. */}
<a href="/admin">Content editor</a>
</nav> </nav>
<div class="foot-note"> <div class="foot-note">
{/* The real brand flash — path taken from the official logo SVG (BRAND D.4). {/* The real brand flash — path taken from the official logo SVG (BRAND D.4).
Sole decorative accent in this view; static by design. */} Sole decorative accent in this view; static by design. */}
<svg class="flash" viewBox="85.3 15 15 15" aria-hidden="true"><path fill="currentColor" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/></svg> <svg class="flash" viewBox="85.3 15 15 15" aria-hidden="true"><path fill="currentColor" d="M94.7,23.8c1-1.4,3.3-2.1,5.2-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.2c0.1,2,0.3,4.4-0.6,5.8c-1,1.4-3.3,2.1-5.2,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.2C93.9,27.6,93.7,25.2,94.7,23.8z"/></svg>
<span>Science Portal Core demo (Cyfronet / SUSS-PORT team). Sample content <span>© 2026 CTAO — demonstration, not an official service.</span>
imported from ctao.org; the Site settings link is a placeholder. Statically
generated from Markdown (git-based CMS).</span>
</div> </div>
</div> </div>
</footer> </footer>
+1 -1
View File
@@ -58,7 +58,7 @@ const tickets = [
<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">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">Help-desk tickets</h2> <h2 class="panel-title">Support tickets</h2>
<ul class="rows"> <ul class="rows">
{tickets.map((t) => ( {tickets.map((t) => (
<li> <li>
+14 -17
View File
@@ -7,6 +7,9 @@ const posts = (await getCollection('news', ({ data }) => !data.draft))
.slice(0, 3); .slice(0, 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d); const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10); const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Services per the Science Portal spec; unbuilt ones are labelled, never faked. // Services per the Science Portal spec; unbuilt ones are labelled, never faked.
const services = [ const services = [
{ title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.', badge: 'mock' }, { title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.', badge: 'mock' },
@@ -24,23 +27,18 @@ const services = [
<div class="eyebrow">Cherenkov Telescope Array Observatory</div> <div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1> <h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p> <p>Exploring the Universe at the Highest Energies</p>
<form class="hero-search" role="search" action="/search" method="get"> {/* Search lives in the header (one search location); the hero carries the
<label class="sr-only" for="home-q">Search news</label> two core scientist tasks per SPEC §3.1 instead — classic hero-CTA pair. */}
<svg class="ico search-ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="11" cy="11" r="7" /><path d="m20 20-4.3-4.3" /></svg> <div class="hero-cta">
<input id="home-q" name="q" type="search" placeholder="Search news and announcements…" autocomplete="off" /> <a class="btn external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
<button class="btn" type="submit">Search</button> <a class="btn btn--ghost" href="/proposals">Submit a proposal</a>
{/* Live suggestions (plain links, Tab-accessible); Enter still submits to /search */} </div>
<ul class="suggest" id="home-suggest"></ul> {posts[0] && (
<p class="sr-only" id="home-suggest-status" role="status" aria-live="polite"></p> <p class="hero-teaser">Latest: <a href={`/news/${posts[0].id}`}>{posts[0].data.title}</a></p>
</form> )}
</div> </div>
</section> </section>
<script is:inline src="/search-client.js"></script>
<script is:inline>
newsSearch({ input: 'home-q', list: 'home-suggest', status: 'home-suggest-status', limit: 6, unit: 'suggestion' });
</script>
{/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */} {/* Surface rhythm (DESIGN.md): services on Moon Gray, editorial news on white */}
<section class="band--moon"> <section class="band--moon">
<div class="container page"> <div class="container page">
@@ -56,7 +54,7 @@ const services = [
<p class="desc">{s.desc}</p> <p class="desc">{s.desc}</p>
{s.badge === 'mock' && <span class="badge-mock">Mock</span>} {s.badge === 'mock' && <span class="badge-mock">Mock</span>}
{s.badge === 'ext' && <span class="badge-ext">External — LUX team</span>} {s.badge === 'ext' && <span class="badge-ext">External — LUX team</span>}
{s.badge === 'tbd' && <span class="badge-ext">Planned (TBD)</span>} {s.badge === 'tbd' && <span class="badge-ext">Planned — not yet available</span>}
</article> </article>
))} ))}
</div> </div>
@@ -73,10 +71,9 @@ const services = [
<article class="card"> <article class="card">
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading="lazy" />} {post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading="lazy" />}
<div class="body"> <div class="body">
<span class="cat">{post.data.category}</span>
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3> <h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p> <p class="desc">{post.data.description}</p>
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div> <div class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
</div> </div>
</article> </article>
))} ))}
+3 -4
View File
@@ -8,11 +8,10 @@ import Base from '../layouts/Base.astro';
<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> <span class="badge-mock">Mock — AAI integration pending</span>
<h1>Sign in</h1> <h1>Sign in</h1>
<p class="muted">One account for all CTAO services. Authentication is handled by the {/* Auth pages are scanned, not read — one line, no reassurance prose */}
central <strong>CTAO AAI</strong> (single sign-on) — the portal never sees your password.</p> <p class="muted">Use your CTAO account — single sign-on via CTAO AAI.</p>
<button class="btn btn-wide" type="button">Continue with your CTAO account</button> <button class="btn btn-wide" type="button">Continue with your CTAO account</button>
<p class="notice">No account yet? <a>Register via CTAO AAI</a> · Access level follows <p class="notice">No account? <a>Register via CTAO AAI</a></p>
your CTAO role and permissions.</p>
</div> </div>
</section> </section>
</Base> </Base>
+5 -3
View File
@@ -15,6 +15,9 @@ const { page } = Astro.props;
const first = page.currentPage === 1; const first = page.currentPage === 1;
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d); const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const iso = (d) => d.toISOString().slice(0, 10); const iso = (d) => d.toISOString().slice(0, 10);
// Reading time from the Markdown body (~220 wpm) — differentiating card metadata;
// category/author are identical across all articles, so cards omit them (NN/g).
const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis) // Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = []; const nums = [];
for (let n = 1; n <= page.lastPage; n++) { for (let n = 1; n <= page.lastPage; n++) {
@@ -32,7 +35,7 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
<div class="container hero"> <div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div> <div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>News &amp; Announcements</h1> <h1>News &amp; Announcements</h1>
<p>Exploring the Universe at the Highest Energies</p> <p>Science highlights and updates from the observatory and its partners</p>
</div> </div>
</section> </section>
@@ -45,10 +48,9 @@ const hrefFor = (n) => (n === 1 ? '/news' : `/news/${n}`);
<img class="cover" src={encodeURI(post.data.cover)} alt="" loading={first && i === 0 ? 'eager' : 'lazy'} /> <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={first && i === 0 ? 'eager' : 'lazy'} />
)} )}
<div class="body"> <div class="body">
<span class="cat">{post.data.category}</span>
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3> <h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p> <p class="desc">{post.data.description}</p>
<div class="meta">{post.data.author} · <time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></div> <div class="meta"><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time> · {readMin(post)} min read</div>
</div> </div>
</article> </article>
))} ))}
+29 -4
View File
@@ -3,14 +3,22 @@ import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro'; import Base from '../../layouts/Base.astro';
export async function getStaticPaths() { export async function getStaticPaths() {
const posts = await getCollection('news', ({ data }) => !data.draft); // Date-sorted so each article knows its sequential neighbours (prev/next
return posts.map((post) => ({ params: { slug: post.id }, props: { post } })); // pattern of every editorial site — keeps readers in the content flow).
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
return posts.map((post, i) => ({
params: { slug: post.id },
props: { post, newer: posts[i - 1] ?? null, older: posts[i + 1] ?? null },
}));
} }
const { post } = Astro.props; const { post, newer, older } = Astro.props;
const { Content, headings } = await render(post); const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3); const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d); const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
const readMin = Math.max(1, Math.round((post.body ?? '').split(/\s+/).length / 220));
--- ---
<Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}> <Base title={`${post.data.title} — CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details> {/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
@@ -19,7 +27,8 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
<article class="article"> <article class="article">
<a class="back" href="/news">← All news</a> <a class="back" href="/news">← All news</a>
<h1>{post.data.title}</h1> <h1>{post.data.title}</h1>
<div class="meta"><span class="cat">{post.data.category}</span><span>{post.data.author}</span>·<time datetime={post.data.date.toISOString().slice(0, 10)}>{fmt(post.data.date)}</time></div> {/* Meta order: category · author · date · reading time */}
<div class="meta"><span class="cat">{post.data.category}</span><span>{post.data.author}</span>·<time datetime={post.data.date.toISOString().slice(0, 10)}>{fmt(post.data.date)}</time>·<span>{readMin} min read</span></div>
{toc.length >= 3 && ( {toc.length >= 3 && (
<details class="toc toc--inline"> <details class="toc toc--inline">
<summary>On this page</summary> <summary>On this page</summary>
@@ -34,6 +43,22 @@ const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).forma
<div class="prose"> <div class="prose">
<Content /> <Content />
</div> </div>
{(newer || older) && (
<nav class="post-nav" aria-label="More news">
{newer && (
<a class="post-nav-prev" rel="prev" href={`/news/${newer.id}`}>
<small>← Newer</small>
<span class="post-nav-title">{newer.data.title}</span>
</a>
)}
{older && (
<a class="post-nav-next" rel="next" href={`/news/${older.id}`}>
<small>Older →</small>
<span class="post-nav-title">{older.data.title}</span>
</a>
)}
</nav>
)}
</article> </article>
{toc.length >= 3 && ( {toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page"> <nav class="toc toc--rail" aria-label="On this page">
+3 -3
View File
@@ -7,14 +7,14 @@ import Base from '../layouts/Base.astro';
<div class="container hero"> <div class="container hero">
<div class="eyebrow">Proposal Submission</div> <div class="eyebrow">Proposal Submission</div>
<h1>Observation proposals</h1> <h1>Observation proposals</h1>
<p>Submitting observation proposals, including ToO / MWL requests</p> <p>Submit observation proposals, including ToO and MWL requests</p>
</div> </div>
</section> </section>
<section class="band--moon"> <section class="band--moon">
<div class="container page"> <div class="container page">
<span class="badge-mock">Mock — no submission logic</span> <span class="badge-mock">Mock — no submission logic</span>
<span class="badge-ext">Target: Proposal Handling System (APC team) integrated into the portal</span> <span class="badge-ext">Planned integration: Proposal Handling System (APC team)</span>
<div class="panel panel--narrow"> <div class="panel panel--narrow">
<div class="form-row"> <div class="form-row">
<label for="f-title">Proposal title</label> <label for="f-title">Proposal title</label>
@@ -42,7 +42,7 @@ import Base from '../layouts/Base.astro';
</div> </div>
<div class="form-row"> <div class="form-row">
<label for="f-just">Scientific justification</label> <label for="f-just">Scientific justification</label>
<textarea id="f-just" rows="4" placeholder="Brief description of the observation goal..."></textarea> <textarea id="f-just" rows="4" placeholder="Brief description of the observation goal…"></textarea>
</div> </div>
<button class="btn" type="button">Submit proposal</button> <button class="btn" type="button">Submit proposal</button>
<p class="notice">Demonstration mock — the form does not store any data.</p> <p class="notice">Demonstration mock — the form does not store any data.</p>
+1 -1
View File
@@ -26,7 +26,7 @@ import Base from '../layouts/Base.astro';
</section> </section>
</Base> </Base>
<script is:inline src="/search-client.js"></script> {/* search-client.js is loaded once by the Base layout (header search) */}
<script is:inline> <script is:inline>
const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' }); const run = newsSearch({ input: 'q', list: 'search-results', status: 'search-status', limit: 20, hint: 'Type at least 2 characters.' });
// Support /search?q=… deep links (hero search form, footer, bookmarks) // Support /search?q=… deep links (hero search form, footer, bookmarks)
+1 -1
View File
@@ -14,7 +14,7 @@ const channels = [
<div class="container hero"> <div class="container hero">
<div class="eyebrow">User services</div> <div class="eyebrow">User services</div>
<h1>User Support</h1> <h1>User Support</h1>
<p>Help desk, FAQ / troubleshooting guide, user forum and mailing lists</p> <p>Help desk, FAQ, user forum and mailing lists</p>
</div> </div>
</section> </section>
+156 -53
View File
@@ -74,6 +74,11 @@
* { box-sizing: border-box; } * { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-padding-top: 84px; /* sticky header + anchor jumps */ } html { -webkit-text-size-adjust: 100%; scroll-padding-top: 84px; /* sticky header + anchor jumps */ }
/* Guard, not a solution (DESIGN.md): every wide element is contained at its own
level (tables scroll in their box, long words wrap); clip only catches
regressions so one offender can never remove the page gutters. `clip`, unlike
`hidden`, creates no scroll container — sticky header keeps working. */
html, body { overflow-x: clip; }
body { body {
margin: 0; margin: 0;
font-family: var(--font-body); font-family: var(--font-body);
@@ -85,6 +90,8 @@ body {
h1, h2 { font-family: var(--font-display); letter-spacing: -0.01em; } h1, h2 { font-family: var(--font-display); letter-spacing: -0.01em; }
h1, h2, h3, .hero p { text-wrap: balance; } h1, h2, h3, .hero p { text-wrap: balance; }
/* Overlong unbroken words (slugs, codes) may break rather than overflow the page */
h1, h2, h3 { overflow-wrap: break-word; }
.prose p, .desc { text-wrap: pretty; } .prose p, .desc { text-wrap: pretty; }
/* Inline SVG icons — stroke inherits currentColor */ /* Inline SVG icons — stroke inherits currentColor */
@@ -120,24 +127,72 @@ img { max-width: 100%; height: auto; display: block; }
official logo, never a CSS approximation. Budget: one accent per view; static. */ official logo, never a CSS approximation. Budget: one accent per view; static. */
.flash { width: 12px; height: 12px; color: var(--cherenkov); flex: none; } .flash { width: 12px; height: 12px; color: var(--cherenkov); flex: none; }
/* Demo disclosure — GOV.UK phase-banner pattern: slim Moon bar above the
header (scrolls away; the header sticks below it). Galaxy-on-tint pairs
computed ≥16:1 (same pairing as .cat chips). */
.phase-banner { background: var(--moon); border-bottom: 1px solid var(--border); font-size: 0.8rem; }
.phase-banner .container { display: flex; align-items: center; gap: 10px; padding-top: 6px; padding-bottom: 6px; }
.phase-tag {
flex: none; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--galaxy); background: var(--tint-cherenkov); border-radius: var(--radius-pill); padding: 2px 10px;
}
/* Header — Galaxy Blue band, reverse (white) logo per BRAND B.1.2 */ /* Header — Galaxy Blue band, reverse (white) logo per BRAND B.1.2 */
.site-header { background: var(--galaxy); position: sticky; top: 0; z-index: 10; } .site-header { background: var(--galaxy); position: sticky; top: 0; z-index: 10; }
.nav { display: flex; align-items: center; gap: 4px; min-height: 64px; flex-wrap: nowrap; } .nav { display: flex; align-items: center; gap: 4px; min-height: 64px; flex-wrap: nowrap; }
.nav > nav { display: flex; align-items: center; gap: 4px; } .nav > nav { display: flex; align-items: center; gap: 4px; }
/* Header tiers (one row, always): full nav >1024px; sub-brand only >1100px; /* One-row guarantee: only the search field and the spacer flex — everything
disclosure menu (native <details>/<summary>, CSS-only) at ≤1024px */ else keeps its natural size (links are single words, they can never wrap) */
@media (max-width: 1100px) { .brand .brand-sub { display: none; } } .nav > * { flex: none; }
@media (max-width: 360px) { /* Header tiers (one row, always — width math in DESIGN.md):
.nav { padding: 0 14px; } ≥1100px full nav + inline search + sub-brand · 1025–1099px sub-brand hidden ·
.brand img { height: 20px; } ≤1024px disclosure menu + magnifier reveal (both native <details>, CSS-only) ·
.nav .login-mock { padding: 8px 14px; } ≤480px icon-only menu button, smaller logo · ≤360px compact paddings */
} @media (max-width: 1099px) { .brand .brand-sub { display: none; } }
.menu { display: none; } .menu { display: none; }
.menu .i-close { display: none; } .menu .i-close { display: none; }
.menu[open] .i-menu { display: none; } .menu[open] .i-menu { display: none; }
.menu[open] .i-close { display: block; } .menu[open] .i-close { display: block; }
.brand { display: flex; align-items: center; gap: 10px; margin-right: 10px; }
.brand img { height: 26px; width: auto; }
.brand .brand-sub {
color: var(--cherenkov); font-size: 0.78rem; font-weight: 500;
border-left: 1px solid rgba(255,255,255,0.25); padding-left: 10px;
}
.nav .spacer { flex: 1; }
.nav a.navlink {
color: var(--on-galaxy-muted); font-weight: 500; font-size: 0.95rem;
padding: 10px 10px; text-decoration: none; border-bottom: 2px solid transparent;
}
.nav a.navlink:hover { color: #fff; }
.nav a.navlink[aria-current="page"] { color: #fff; border-bottom-color: var(--cherenkov); }
/* AAI sign-in entry — leads to the /login mock page */
.nav .login-mock {
color: var(--galaxy); background: var(--cherenkov); font: inherit; font-weight: 600;
border: 0; border-radius: var(--radius-pill); padding: 8px 18px; margin-left: 6px;
text-decoration: none; white-space: nowrap;
}
.nav .login-mock:hover { background: var(--cherenkov-hover); color: var(--galaxy); }
/* Tap targets ≥44px (WCAG 2.5.5); inline-flex keeps the active bottom border */
.nav a.navlink, .nav .login-mock {
display: inline-flex; align-items: center; gap: 6px; min-height: 44px;
}
/* Header search (desktop) — compact pill in the utility cluster; a plain GET
form to /search (works without JS), suggestions reuse the shared .suggest
dropdown. Flexes 130–180px so the row can never overflow (width math: DESIGN.md). */
.nav-search { position: relative; display: flex; align-items: center; flex: 0 1 180px; min-width: 130px; margin-left: 6px; }
.nav-search .search-ico { left: 12px; width: 15px; height: 15px; }
.nav-search input {
width: 100%; min-width: 0; font: inherit; font-size: 0.9rem; color: var(--text);
background: #fff; border: 1px solid transparent; border-radius: var(--radius-pill);
padding: 8px 14px 8px 34px;
}
.nav-search input:focus { border-color: var(--azure); }
.nav-search .suggest { left: auto; right: 0; width: min(380px, 92vw); top: calc(100% + 10px); }
/* ≤1024px replacement: magnifier <details> reveal — see the menu media block */
.search-pop { display: none; }
@media (max-width: 1024px) { @media (max-width: 1024px) {
.nav > nav, .nav > a.navlink { display: none; } .nav > nav { display: none; }
.menu { display: block; } .menu { display: block; }
.menu summary { .menu summary {
display: inline-flex; align-items: center; gap: 8px; min-height: 44px; display: inline-flex; align-items: center; gap: 8px; min-height: 44px;
@@ -158,37 +213,42 @@ img { max-width: 100%; height: auto; display: block; }
} }
.menu nav a:last-child { border-bottom: 0; } .menu nav a:last-child { border-bottom: 0; }
.menu nav a[aria-current="page"] { color: var(--cherenkov); } .menu nav a[aria-current="page"] { color: var(--cherenkov); }
/* Magnifier icon-button reveals a full-width search row under the header
(visible search beats search buried in the menu — NN/g mobile-search).
Same native-<details> panel pattern as the menu. */
.nav-search { display: none; }
.search-pop { display: block; }
.search-pop summary {
display: inline-flex; align-items: center; justify-content: center;
min-width: 44px; min-height: 44px; color: #fff; cursor: pointer; list-style: none;
} }
.brand { display: flex; align-items: center; gap: 10px; margin-right: 10px; } .search-pop summary::-webkit-details-marker { display: none; }
.brand img { height: 26px; width: auto; } .search-pop form {
.brand .brand-sub { position: absolute; left: 0; right: 0; top: 100%;
color: var(--cherenkov); font-size: 0.78rem; font-weight: 500; display: flex; gap: 8px; padding: 10px var(--gutter) 14px;
border-left: 1px solid rgba(255,255,255,0.25); padding-left: 10px; background: var(--galaxy); border-top: 1px solid rgba(255, 255, 255, 0.15);
box-shadow: var(--shadow-lifted);
} }
.nav .spacer { flex: 1; } .search-pop input {
.nav a.navlink { flex: 1; min-width: 0; font: inherit; color: var(--text);
color: var(--on-galaxy-muted); font-weight: 500; font-size: 0.95rem; background: #fff; border: 1px solid var(--border); border-radius: var(--radius-pill);
padding: 10px 10px; text-decoration: none; border-bottom: 2px solid transparent; padding: 10px 16px;
} }
.nav a.navlink:hover { color: #fff; } .search-pop input:focus { border-color: var(--azure); }
.nav a.navlink[aria-current="page"] { color: #fff; border-bottom-color: var(--cherenkov); } .search-pop .btn { padding: 10px 18px; }
.nav a.navlink--edit { .search-pop .suggest { left: 10px; right: 10px; top: calc(100% + 4px); }
color: #fff; border: 1px solid rgba(255,255,255,0.35); border-radius: var(--radius-pill);
padding: 7px 16px; margin-left: 6px;
} }
.nav a.navlink--edit:hover { border-color: var(--cherenkov); color: var(--cherenkov); } @media (max-width: 480px) {
/* AAI sign-in entry — leads to the /login mock page */ .brand img { height: 22px; }
.nav .login-mock { .menu summary .menu-label { display: none; }
color: var(--galaxy); background: var(--cherenkov); font: inherit; font-weight: 600; .menu summary { min-width: 44px; justify-content: center; padding: 0; margin-left: 2px; }
border: 0; border-radius: var(--radius-pill); padding: 8px 18px; margin-left: 6px;
text-decoration: none; white-space: nowrap;
} }
.nav .login-mock:hover { background: var(--cherenkov-hover); color: var(--galaxy); } @media (max-width: 360px) {
/* Tap targets ≥44px (WCAG 2.5.5); inline-flex keeps the active bottom border */ .nav { padding: 0 14px; gap: 2px; }
.nav a.navlink, .nav .login-mock, .nav a.navlink--edit { .brand { margin-right: 4px; }
display: inline-flex; align-items: center; gap: 6px; min-height: 44px; .brand img { height: 20px; }
.nav .login-mock { padding: 8px 12px; }
} }
.navlink--edit .ico { width: 15px; height: 15px; }
/* External-link marker — SVG arrow via mask, inherits currentColor */ /* External-link marker — SVG arrow via mask, inherits currentColor */
.external::after { .external::after {
content: ""; display: inline-block; width: 0.72em; height: 0.72em; margin-left: 3px; content: ""; display: inline-block; width: 0.72em; height: 0.72em; margin-left: 3px;
@@ -215,16 +275,26 @@ img { max-width: 100%; height: auto; display: block; }
letter-spacing: 0.14em; font-size: 0.72rem; margin-bottom: 8px; letter-spacing: 0.14em; font-size: 0.72rem; margin-bottom: 8px;
} }
/* Search fields — large pill input with inline magnifier; real GET form to /search */ /* Hero CTAs — the two core scientist tasks (SPEC §3.1): one Cherenkov primary,
.hero-search, .search-form { position: relative; display: flex; gap: 8px; max-width: 560px; } one quiet ghost. Links styled as buttons because they navigate. */
.hero-search { margin-top: var(--space-m); } .hero-cta { display: flex; flex-wrap: wrap; gap: var(--space-s); margin-top: var(--space-m); }
a.btn { display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
.btn--ghost { background: transparent; color: #fff; border: 1px solid rgba(255, 255, 255, 0.55); }
.btn--ghost:hover { background: rgba(255, 255, 255, 0.1); color: #fff; }
/* One-line latest-news teaser under the CTAs (editorial touch, no widgets) */
.hero .hero-teaser { margin-top: var(--space-m); font-size: 0.9rem; color: var(--on-galaxy-muted); max-width: none; }
.hero .hero-teaser a { color: #fff; text-underline-offset: 3px; }
.hero .hero-teaser a:hover { color: var(--cherenkov); }
/* Search field (/search page) — large pill input with inline magnifier; real GET form */
.search-form { position: relative; display: flex; gap: 8px; max-width: 560px; }
.search-ico { position: absolute; left: 16px; top: 50%; translate: 0 -50%; color: var(--muted); pointer-events: none; } .search-ico { position: absolute; left: 16px; top: 50%; translate: 0 -50%; color: var(--muted); pointer-events: none; }
.hero-search input, .search-form input { .search-form input {
flex: 1; min-width: 0; font: inherit; color: var(--text); flex: 1; min-width: 0; font: inherit; color: var(--text);
background: #fff; border: 1px solid var(--border); background: #fff; border: 1px solid var(--border);
border-radius: var(--radius-pill); padding: 13px 18px 13px 44px; border-radius: var(--radius-pill); padding: 13px 18px 13px 44px;
} }
.hero-search input:focus, .search-form input:focus { border-color: var(--azure); } .search-form input:focus { border-color: var(--azure); }
/* Live suggestions under the hero search — plain links, Tab-accessible */ /* Live suggestions under the hero search — plain links, Tab-accessible */
.suggest { .suggest {
position: absolute; top: calc(100% + 8px); left: 0; right: 0; position: absolute; top: calc(100% + 8px); left: 0; right: 0;
@@ -235,14 +305,19 @@ img { max-width: 100%; height: auto; display: block; }
max-height: min(60vh, 480px); overflow: auto; /* long lists scroll on mobile */ max-height: min(60vh, 480px); overflow: auto; /* long lists scroll on mobile */
} }
.suggest:empty { display: none; } .suggest:empty { display: none; }
.suggest li { display: flex; align-items: center; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); } .suggest li { display: flex; align-items: flex-start; gap: var(--space-s); padding: 0 12px; border-radius: calc(var(--radius) - 8px); }
.suggest li:hover, .suggest li:focus-within { background: var(--moon); } .suggest li:hover, .suggest li:focus-within { background: var(--moon); }
/* The link holds <mark>-split text nodes — it must lay out as flowing text,
never as a flex row (flex would turn every fragment into its own item and
stop titles from wrapping). ≥44px target comes from the block padding. */
.suggest a { .suggest a {
flex: 1; display: flex; align-items: center; min-height: 44px; flex: 1; min-width: 0; display: block; padding-block: 10px;
color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none; color: var(--text); font-weight: 500; font-size: 0.94rem; text-decoration: none;
overflow-wrap: break-word;
} }
.suggest small { margin-top: 12px; } /* date chip tracks the first title line */
.suggest::after { .suggest::after {
content: "↵ Enter — all results"; display: block; margin-top: 4px; content: "Press Enter for all results"; display: block; margin-top: 4px;
padding: 8px 12px 2px; border-top: 1px solid var(--border); padding: 8px 12px 2px; border-top: 1px solid var(--border);
color: var(--muted); font-size: 0.78rem; color: var(--muted); font-size: 0.78rem;
} }
@@ -261,10 +336,10 @@ img { max-width: 100%; height: auto; display: block; }
.grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; container-type: inline-size; } .grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; container-type: inline-size; }
@media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } } @media (min-width: 640px) { .grid { grid-template-columns: repeat(2, 1fr); } }
@media (min-width: 940px) { .grid { grid-template-columns: repeat(3, 1fr); } } @media (min-width: 940px) { .grid { grid-template-columns: repeat(3, 1fr); } }
/* Subgrid: cover/cat/title/desc/meta rows align across each card row */ /* Subgrid: cover/title/desc/meta rows align across each card row */
@supports (grid-template-rows: subgrid) { @supports (grid-template-rows: subgrid) {
.grid > .card:not(.card--featured, .tile) { display: grid; grid-template-rows: subgrid; grid-row: span 5; gap: 0; } .grid > .card:not(.card--featured, .tile) { display: grid; grid-template-rows: subgrid; grid-row: span 4; gap: 0; }
.grid > .card:not(.card--featured, .tile) .body { display: grid; grid-template-rows: subgrid; grid-row: span 4; gap: 10px; } .grid > .card:not(.card--featured, .tile) .body { display: grid; grid-template-rows: subgrid; grid-row: span 3; gap: 10px; }
} }
/* News cards — image + category + clamped title/description + <time> meta; /* News cards — image + category + clamped title/description + <time> meta;
@@ -380,6 +455,9 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
} }
} }
/* Long unbroken strings (URLs, identifiers) wrap instead of forcing page overflow */
.prose { overflow-wrap: break-word; }
.prose a { overflow-wrap: anywhere; }
.prose h2 { color: var(--galaxy); margin: 34px 0 10px; font-size: clamp(1.25rem, 2.6vw, 1.45rem); } .prose h2 { color: var(--galaxy); margin: 34px 0 10px; font-size: clamp(1.25rem, 2.6vw, 1.45rem); }
.prose h3 { color: var(--galaxy); margin: 26px 0 8px; font-size: 1.15rem; } .prose h3 { color: var(--galaxy); margin: 26px 0 8px; font-size: 1.15rem; }
.prose p { margin: 0 0 16px; } .prose p { margin: 0 0 16px; }
@@ -392,11 +470,30 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
.prose img { border: 1px solid var(--border); border-radius: var(--radius); } .prose img { border: 1px solid var(--border); border-radius: var(--radius); }
.prose img + em, .prose p > em:only-child { color: var(--muted); font-size: 0.85rem; } .prose img + em, .prose p > em:only-child { color: var(--muted); font-size: 0.85rem; }
.prose hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; } .prose hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
.prose table { width: 100%; border-collapse: collapse; margin: 0 0 16px; font-size: 0.94rem; } /* Wide tables scroll inside their own box, never the page (mobile guard) */
.prose table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: 0.94rem; }
.prose th { text-align: left; font-weight: 600; color: var(--galaxy); } .prose th { text-align: left; font-weight: 600; color: var(--galaxy); }
.prose th, .prose td { padding: 10px 12px; border-bottom: 1px solid var(--border); } .prose th, .prose td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
.prose thead th { border-bottom: 2px solid var(--cherenkov); } .prose thead th { border-bottom: 2px solid var(--cherenkov); }
/* Prev/next article links — sequential nav from the date-sorted collection */
.post-nav {
display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-m);
border-top: 1px solid var(--border); margin-top: var(--space-xl); padding-top: var(--space-m);
}
.post-nav a { display: block; min-width: 0; text-decoration: none; }
.post-nav .post-nav-prev { grid-column: 1; }
.post-nav .post-nav-next { grid-column: 2; text-align: right; }
.post-nav small {
display: block; color: var(--muted); font-size: 0.72rem; font-weight: 600;
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 4px;
}
.post-nav .post-nav-title {
color: var(--galaxy); font-weight: 500;
display: -webkit-box; -webkit-line-clamp: 2; line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.post-nav a:hover .post-nav-title { color: var(--link); }
/* Generic page section — one rhythm token for all pages */ /* Generic page section — one rhythm token for all pages */
.page { padding: var(--space-xl) 0; } .page { padding: var(--space-xl) 0; }
.panel { .panel {
@@ -422,29 +519,34 @@ span.page-step { color: var(--muted); } /* disabled end stop — non-interactive
/* Search results (list built by the inline script on /search) — card-like rows */ /* Search results (list built by the inline script on /search) — card-like rows */
.search-results { list-style: none; margin: var(--space-m) 0 0; padding: 0; max-width: 720px; display: grid; gap: var(--space-s); } .search-results { list-style: none; margin: var(--space-m) 0 0; padding: 0; max-width: 720px; display: grid; gap: var(--space-s); }
.search-results li { .search-results li {
display: flex; justify-content: space-between; align-items: center; gap: var(--space-s); display: flex; align-items: flex-start; gap: var(--space-s);
background: #fff; border: 1px solid var(--border); border-radius: var(--radius); background: #fff; border: 1px solid var(--border); border-radius: var(--radius);
padding: 12px 18px; box-shadow: var(--shadow-ambient); padding: 12px 18px; box-shadow: var(--shadow-ambient);
} }
.search-results li:hover { border-color: var(--azure); box-shadow: var(--shadow-lifted); } .search-results li:hover { border-color: var(--azure); box-shadow: var(--shadow-lifted); }
.search-results a { text-decoration: none; font-weight: 500; } /* Title flows as inline text (never display:flex — see .suggest a note) */
.search-results a { flex: 1; min-width: 0; overflow-wrap: break-word; text-decoration: none; font-weight: 500; }
.search-results a:hover { text-decoration: underline; } .search-results a:hover { text-decoration: underline; }
/* Date chips on results and suggestions */ /* Date chips on results and suggestions */
.search-results small, .suggest small { .search-results small, .suggest small {
color: var(--muted); white-space: nowrap; font-size: 0.78rem; color: var(--muted); white-space: nowrap; font-size: 0.78rem;
background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px; background: var(--moon); border-radius: var(--radius-pill); padding: 2px 10px;
} }
.search-results small { margin-top: 3px; } /* optical align with the first title line */
/* Matched-term highlight in results/suggestions (Starlight search pattern) — /* Matched-term highlight in results/suggestions (Starlight search pattern) —
brand tint only, no new hues */ brand tint only, 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; }
/* Mock/status badges — every mock must be labelled (REQUIREMENTS.md §5) */ /* Mock/status badges — every mock stays labelled (REQUIREMENTS.md §5), but the
Demo phase banner is loud once, so per-element badges only whisper: quiet
hairline pill, muted text (7.1:1 on Moon), sentence case, Galaxy dot on mocks. */
.badge-mock, .badge-ext { .badge-mock, .badge-ext {
display: inline-block; font-size: 0.72rem; font-weight: 600; letter-spacing: 0.06em; display: inline-flex; align-items: center; gap: 6px; max-width: 100%;
text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-pill); margin-bottom: 14px; font-size: 0.75rem; font-weight: 500; color: var(--muted);
background: var(--moon); border: 1px solid var(--border);
border-radius: var(--radius-pill); padding: 2px 10px; margin-bottom: 14px;
} }
.badge-mock { color: #7a3d00; background: #fff3e0; border: 1px solid #f0cf9b; } .badge-mock::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--galaxy); flex: none; }
.badge-ext { color: var(--galaxy); background: var(--moon); border: 1px solid var(--border); text-transform: none; letter-spacing: 0; }
/* Dashboard mock — aggregation panels with status pills */ /* Dashboard mock — aggregation panels with status pills */
.dash-grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; margin-top: var(--space-s); } .dash-grid { display: grid; gap: var(--space-m); grid-template-columns: 1fr; margin-top: var(--space-s); }
@@ -534,6 +636,7 @@ mark { background: var(--tint-cherenkov); color: inherit; border-radius: calc(va
.hero > :nth-child(2) { animation-delay: 0.08s; } .hero > :nth-child(2) { animation-delay: 0.08s; }
.hero > :nth-child(3) { animation-delay: 0.16s; } .hero > :nth-child(3) { animation-delay: 0.16s; }
.hero > :nth-child(4) { animation-delay: 0.24s; } .hero > :nth-child(4) { animation-delay: 0.24s; }
.hero > :nth-child(5) { animation-delay: 0.32s; }
/* Hover feedback */ /* Hover feedback */
.card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; } .card, .search-results li { transition: border-color 0.15s ease, box-shadow 0.2s ease, transform 0.2s ease; }
.card:hover { transform: translateY(-2px); } .card:hover { transform: translateY(-2px); }