Compare commits

..
4 Commits
Author SHA1 Message Date
kuba c3a2ebc594 Merge simplified mockup portal into main 2026-09-21 21:26:27 +02:00
kuba d24fabe478 Use main branch to simpyfy config 2026-09-21 21:26:00 +02:00
kuba b7d6124e6b No nebula 2026-09-21 18:09:58 +02:00
kuba 467bf77d34 Simple conetent + restyled 2026-09-21 17:59:29 +02:00
54 changed files with 2073 additions and 2067 deletions
+4
View File
@@ -0,0 +1,4 @@
*.sh text eol=lf
*.service text eol=lf
*.timer text eol=lf
*.container text eol=lf
-38
View File
@@ -1,38 +0,0 @@
# Design handoff
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).
## Brand (official: CTAO Brand Guidelines, April 2024, PDF in the project docs)
- Colors: Galaxy Blue `#00004A`, Cherenkov Blue `#00E4D8`, Moon Gray
`#F5F5F5`, Cosmic Azure `#007AFF` (focus rings only; links use the
darkened `--link` so they pass AA on white), Interstellar Indigo `#00009C`.
Cherenkov is an ACCENT (the brand book rations it; currently: primary CTA,
hero subtitle, logo flash); it fails AA as text on white.
- Comments in the code cite `SPEC §…`, `BRAND D.x` and `REQUIREMENTS`: those
are the CTAO specification, the brand guidelines PDF and the team's
consolidated requirements, kept in the project's document folder at
Cyfronet, not in this repo.
- 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.
## Practical notes (the non-obvious ones)
- **`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, so 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` (types + build + link
check, a few seconds).
+96 -86
View File
@@ -1,100 +1,110 @@
# CTAO Science Portal: static site + git-based CMS
# CTAO Science Portal — simple proof of concept
Public portal for CTAO news, built as a fully static site. Editors get a
browser WYSIWYG editor (Sveltia CMS) that commits Markdown to a Gitea repo;
a build job turns commits into static HTML. No application or database runs
on the public path.
The `main` branch uses the sibling `mockup` project's layout, assets, CSS,
and header interactions. Astro generates a static site. Sveltia CMS edits
exactly four Markdown pages in the separate `ctao/content` repository.
Live demo: <https://astro.isl-dev.grid.cyfronet.pl> ·
Gitea on the demo machine: <https://astro-git.isl-dev.grid.cyfronet.pl>
## Scope
## Repository layout & deployment
| Example submenu | URL | Content file |
|---|---|---|
| Lorem ipsum | `/example/lorem-ipsum/` | `pages/lorem-ipsum.md` |
| Dolor sit amet | `/example/dolor-sit-amet/` | `pages/dolor-sit-amet.md` |
| Consectetur adipiscing | `/example/consectetur-adipiscing/` | `pages/consectetur-adipiscing.md` |
| Sed do eiusmod | `/example/sed-do-eiusmod/` | `pages/sed-do-eiusmod.md` |
Code and content are SEPARATE repositories, so editorial commits never mix
with development:
Home, News, Data, Proposals, Dashboard, Sign in, Search, Support, Privacy,
Disclaimer, and Contact have the shared header/footer, a title, and an empty
body. Search submits to its placeholder. Only English is available; the
other language options are inactive. There are no news articles, news
pagination, RSS feeds, search index, service integrations, or public sign-in.
`/admin/` remains the working CMS entry point.
| 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 |
## Run locally
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.
Use Node 22.19 or newer (deployment uses Node 24).
## Stack
- **Astro** (static output). Pages are `.astro` templates (plain HTML with a
JS frontmatter block). No React/Vue/Svelte components, no client-side
framework.
- **Plain CSS**: everything lives in `src/styles/global.css` as design
tokens (custom properties) + rules. No Tailwind, no preprocessor.
The design system (tokens, type scale, spacing, motion policy) is
documented in `DESIGN.md`. Read it before touching styles.
- **Sveltia CMS**: a single prebuilt JS bundle, vendored in
`public/vendor/sveltia-cms.js` and loaded on `/admin/`
(`src/pages/admin/index.astro`), configured by `public/admin/config.yml`.
It runs entirely in the editor's browser and talks to the Gitea API
(OAuth PKCE). There is no CMS server. To update it:
`curl -sL https://unpkg.com/@sveltia/cms/dist/sveltia-cms.js -o public/vendor/sveltia-cms.js`
- Runtime dependencies: none beyond Astro. This is deliberate; keep it
that way.
## Develop
Prerequisites: Node 22.19 or newer (the machine builds on Node 24), and
network access to the demo machine's Gitea (Cyfronet network / VPN) for the
content clone below. Without content the site still builds, but with no
articles and `npm run check` reports the footer's page links as broken.
```
```sh
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: astro check (types) + build + internal-link check
npm run dev
```
`tsconfig.json` excludes `public/` on purpose: the vendored CMS bundle there is
2 MB of minified JS and would make `astro check` run out of memory.
Open `http://localhost:4321/example/lorem-ipsum/` for the full mockup example.
The first run copies bundled `sample-content/pages/` and `uploads/` into the
ignored runtime directories. Existing content is preserved. An incomplete
existing set fails with a missing-file error instead of silently mixing
sample content with editorial content.
## Layout
```sh
npm run content:setup # explicitly restore the four sample pages
npm run content:sync -- ../ctao-content # copy from a separate content checkout
npm run check # types, static build, internal links
npm run build
npm run preview # inspect the built site
```
| Path | What |
`content:setup` and `content:sync` replace the four runtime Markdown files and
copy uploads, overwriting same-named media. They do not modify the source
content checkout. Only the four allowlisted pages are loaded and rendered;
unrelated Markdown files never create public routes.
## Edit with Sveltia locally, without Gitea
```sh
npm run content:local
npm run dev
```
In Chrome or Edge, open `/admin/`, select **Work with Local Repository**, and
choose this project's `.tmp/content` directory. The setup command creates a
local Git repository there, seeded from the bundled content. Re-running it
preserves edits in that directory.
Save an Example page, then run this in another terminal to refresh the site:
```sh
npm run content:sync -- .tmp/content
```
The local editor writes files without committing them. This follows
[Sveltia's local workflow](https://sveltiacms.app/en/docs/workflows/local).
Choose the content repository root, not the portal code repository root:
the CMS expects `pages/` and `uploads/` directly beneath it.
## Content and publishing
The CMS uses a [fixed file collection](https://sveltiacms.app/en/docs/collections/files).
Editors can change title, introduction, optional image/alternative text/caption,
Markdown body, and optional button label/destination. Both button fields are
needed to display the button. A Markdown blockquote receives the mockup's
highlight styling. Images without alternative text are treated as decorative.
Menu labels, ordering, URLs, and the shared site layout remain code-controlled.
Remote editing uses Gitea OAuth and saves to **`ctao/content`, branch `main`**.
That branch must contain the four files from `sample-content/pages/` and the
sample upload before publishing this version. The code mirror is
**`ctao/portal`, branch `main`**. Deployment independently selects
`CODE_BRANCH` and `CONTENT_BRANCH`, overlays content, runs checks, and publishes
atomically. `CONTENT_BRANCH` must match `backend.branch` in the CMS config.
See [deploy/README.md](deploy/README.md) for migration and publishing steps.
A push to Bitbucket alone does not deploy the site.
## Where things live
| Path | Purpose |
|---|---|
| `src/pages/` | Routes (`.astro` templates), incl. `news/`, `search`, RSS/sitemap |
| `src/layouts/Base.astro` | HTML shell: head, header/nav, footer |
| `src/content/news/*.md` | Articles (from the content repo, gitignored here; schema in `src/content.config.ts`) |
| `src/styles/global.css` | All CSS: tokens + components + prose |
| `DESIGN.md` | Design handoff: where the tokens live, brand basics, practical notes |
| `public/admin/` | CMS config (`config.yml`) + editor preview styles (`preview.css`) |
| `public/uploads/` | Editor-uploaded media (from the content repo, gitignored here) |
| `deploy/` | Runbook + container/systemd units for the demo machine, see `deploy/README.md` |
| `src/layouts/Base.astro` | Mockup header, navigation, footer, page shell |
| `src/components/PageHero.astro` | Breadcrumb, title, introduction, nebula |
| `src/pages/example/[slug].astro` | One reusable editable-page template |
| `src/data/examples.json` | Fixed submenu labels, order, and slugs |
| `src/data/placeholders.json` | Empty destinations |
| `src/styles/global.css` | Mockup styles plus Markdown/placeholder variants |
| `public/assets/`, `public/header.js` | Mockup assets and menu behavior |
| `sample-content/` | Versioned seed content for local use and Gitea setup |
| `src/content/pages/`, `public/uploads/` | Ignored runtime content overlay |
| `public/admin/config.yml` | Four fixed CMS files and Gitea configuration |
| `scripts/preview-styles.mjs` | Generates CMS body preview styles from site CSS |
## Editing content
Editors use `/admin/` (link in the footer) and sign in with a Gitea account.
Saving commits to `main`; the machine polls and republishes automatically
(seconds). Full history/rollback = git history in Gitea. Articles with
`draft: true` are excluded from the build.
Note for styling work: `public/admin/preview.css` mirrors the `.prose` rules
from `global.css` so the editor preview matches the site 1:1; keep them in
sync (both files carry a KEEP IN SYNC comment).
## Upgrading
Versions are pinned exactly; an upgrade is a deliberate change, never a side
effect of `npm install`. Astro ships security fixes only for the current and
one previous major, so plan one upgrade per major rather than skipping several.
```
npx @astrojs/upgrade # moves astro and official integrations together
npm run check # types + build + links must pass
npm run preview # verify the real dist/ output, not the dev server
```
No experimental flags in `astro.config.mjs`: they can change in minor releases.
The vendored CMS bundle remains in `public/vendor/sveltia-cms.js`. Dependencies
remain pinned; this simplification adds no application dependencies.
+1 -23
View File
@@ -1,31 +1,9 @@
import { defineConfig } from 'astro/config';
// Static output (zero runtime) — the whole point of the git-based approach.
export default defineConfig({
output: 'static',
// Public origin — used to build absolute URLs (canonical, og:*, RSS,
// sitemap). Change when the portal moves to its production domain.
site: 'https://astro.isl-dev.grid.cyfronet.pl',
// URL contract, pinned explicitly (these are the defaults): changing either
// later rewrites every public URL. nginx serves /path/ via try_files $uri/.
trailingSlash: 'ignore',
trailingSlash: 'always',
build: { format: 'directory' },
// Lossless whitespace handling instead of the v7 default 'jsx', which drops
// line breaks around inline elements and silently glues words to links when
// a template line wraps before <a>. Templates render as written.
compressHTML: true,
// Built-in prefetch on every internal link (no per-link attributes needed).
// Default 'hover' strategy: near-instant navigation without the bandwidth
// cost of 'viewport' on a 24-card grid; auto-falls back to 'tap' on
// data-saver / slow connections (Astro handles that).
prefetch: { prefetchAll: true },
vite: {
build: {
// Lightning CSS (the default minifier) illegally folds animation-timeline
// INTO the `animation` shorthand (`animation: ... view()`), which browsers
// reject — silently killing every scroll-driven effect in production.
// esbuild minifies without property merging.
cssMinify: 'esbuild',
},
},
});
+40 -1
View File
@@ -29,6 +29,43 @@ missing). Everything else runs inside containers.
| 3000 | Gitea | https://astro-git.isl-dev.grid.cyfronet.pl (ingress vhost) |
| 8080 | portal (nginx, static) | https://astro.isl-dev.grid.cyfronet.pl (ingress vhost) |
## Publish the simplified portal from `main`
1. In a checkout of `ctao/content`, switch to `main`. Copy the missing
Example files from `sample-content/pages/` and
`sample-content/uploads/hero.jpg` from this code repository into its root
`pages/` and `uploads/` directories, then commit the seed files. Preserve
any existing editorial versions of these files. Existing
news or other page files can remain in the content history; this portal
loads only the four Example files.
2. Push the content branch to Gitea as `ctao/content:main`, and the code
branch as `ctao/portal:main`. These are separate pushes; Bitbucket is
not the deployment remote.
3. Copy the updated `build.sh` to `~/ctao-portal-demo/bin/build.sh` and the
updated `ctao-portal-build.service` to `~/.config/systemd/user/`. Reload
systemd with `systemctl --user daemon-reload`. The service explicitly sets
`CODE_BRANCH=main` and `CONTENT_BRANCH=main`; the script defaults agree.
4. Start the build service or let its timer run. It overlays `pages/` and
`uploads/`, clears the previous news overlay, and runs `npm run check`
before the atomic release switch. Missing Example pages fail the build.
5. Verify the four `/example/…/` pages and empty `/news/`. Sign in at `/admin/`,
save an Example title, and check that a commit appears on
`ctao/content:main` and the rebuilt page reflects it.
The OAuth client and public portal origin stay the same. Sveltia's
`backend.branch` in `public/admin/config.yml` must always match
`CONTENT_BRANCH`. Branches are selected independently; a single `BRANCH`
environment variable is no longer used. For a separate preview hostname,
update the site origin, OAuth redirect, and Gitea CORS together.
To undo the simplification in shared code history, use
`git revert -m 1 <simplification-merge-commit>` on `main` and publish the
resulting commit. The previous portal code is also preserved at `0772462`.
For an immediate deployment rollback, restore the previous release and worker
script/service configuration. Content has its own history: reverting a code
commit does not revert CMS edits. Keep the old content files available if
the previous portal may be restored.
## Install (each step reviewed before running; [W] = writes to the machine)
1. **[W]** `loginctl enable-linger $USER`. Without lingering every user unit
@@ -53,7 +90,9 @@ missing). Everything else runs inside containers.
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` with repos `portal` and
`content` (both public read). Then, from your workstation, push both over
`content` (both public read). Seed the content `main` branch with
`sample-content/pages/` and `sample-content/uploads/` as described above.
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`
+17 -15
View File
@@ -7,7 +7,7 @@
#
# 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
# content onto code, so an editor publishing a page and a developer
# shipping CSS never mix histories — either change republishes the site.
set -euo pipefail
@@ -17,7 +17,8 @@ BASE="${BASE:-$HOME/ctao-portal-demo}"
GITEA_URL="${GITEA_URL:-http://localhost:3000}" # published by ctao-demo-gitea
CODE_REPO="${CODE_REPO:-ctao/portal}" # owner/repo in Gitea
CONTENT_REPO="${CONTENT_REPO:-ctao/content}"
BRANCH="${BRANCH:-main}"
CODE_BRANCH="${CODE_BRANCH:-main}"
CONTENT_BRANCH="${CONTENT_BRANCH:-main}"
BUILD_IMAGE="${BUILD_IMAGE:-localhost/ctao-portal-build:1}"
# The build joins the Gitea container's network namespace: localhost inside
# the build = Gitea's loopback (port 3000), host loopback stays unreachable.
@@ -37,16 +38,16 @@ done
# --- 1. Cheap poll: both branch heads via the local Gitea API ---
head_of() {
curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$1/branches/$BRANCH" \
curl -fsS --max-time 5 "$GITEA_URL/api/v1/repos/$1/branches/$2" \
| jq -r '.commit.id' || true
}
code_sha=$(head_of "$CODE_REPO")
content_sha=$(head_of "$CONTENT_REPO")
code_sha=$(head_of "$CODE_REPO" "$CODE_BRANCH")
content_sha=$(head_of "$CONTENT_REPO" "$CONTENT_BRANCH")
# Gitea down/unreachable is a transient, not a unit failure — exit 0 quietly
# instead of painting the journal red every 10 s. Name the repo: a 404 here
# also means "repo/branch missing or renamed", not just "Gitea down".
[[ "$code_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CODE_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
[[ "$content_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CONTENT_REPO@$BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
[[ "$code_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CODE_REPO@$CODE_BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
[[ "$content_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "poll failed for $CONTENT_REPO@$CONTENT_BRANCH (gitea down, or repo/branch missing) — skipping"; exit 0; }
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).
@@ -73,7 +74,7 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
-e ASTRO_TELEMETRY_DISABLED=1 \
-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" \
-e RELEASE="$release" -e CODE_BRANCH="$CODE_BRANCH" -e CONTENT_BRANCH="$CONTENT_BRANCH" \
-v "$BASE/repo:/work/repo:z" \
-v "$BASE/content:/work/content:z" \
-v "$BASE/releases:/work/releases:z" \
@@ -81,21 +82,20 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
-w /work "$BUILD_IMAGE" sh -ec '
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"
sync_clone() { # $1 dir $2 url $3 sha $4 branch
[ -d "$1/.git" ] || git clone --branch "$4" "$2" "$1"
git -C "$1" remote set-url origin "$2" # self-heal if the URL changes
git -C "$1" fetch --quiet origin "$BRANCH"
git -C "$1" fetch --quiet origin "$4"
# --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"
sync_clone repo "$CODE_URL" "$CODE_SHA" "$CODE_BRANCH"
sync_clone content "$CONTENT_URL" "$CONTENT_SHA" "$CONTENT_BRANCH"
# Overlay content onto code (these paths are gitignored in the code repo).
# mkdir -p: checkout prunes the emptied parent dirs, cp needs them back.
rm -rf repo/src/content/news repo/src/content/pages repo/public/uploads
mkdir -p repo/src/content repo/public
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
@@ -109,7 +109,9 @@ podman run --rm --network="$BUILD_NETNS" --memory=1g \
npm ci --ignore-scripts --no-audit --no-fund
echo "$lock" > .deps-hash
fi
npm run build
# Validate the overlay and links before publishing; never seed demo content
# in place of an incomplete content branch.
npm run check
rm -rf "../releases/$RELEASE"
cp -a dist "../releases/$RELEASE"
' || { echo "$release" > "$BASE/state/last-failed"; echo "BUILD FAILED for $release (see above) — will not retry until a new commit"; exit 1; }
+2
View File
@@ -8,6 +8,8 @@ Description=CTAO portal demo — rebuild if the code or content repo has new com
[Service]
Type=oneshot
Environment=CODE_BRANCH=main
Environment=CONTENT_BRANCH=main
ExecStart=%h/ctao-portal-demo/bin/build.sh
# Polite neighbour on the shared VM:
Nice=10
+3
View File
@@ -13,6 +13,9 @@
"devDependencies": {
"@astrojs/check": "0.9.10",
"typescript": "6.0.3"
},
"engines": {
"node": ">=22.19.0"
}
},
"node_modules/@astrojs/check": {
+6
View File
@@ -4,9 +4,15 @@
"version": "0.1.0",
"private": true,
"scripts": {
"content:setup": "node scripts/content.mjs",
"content:sync": "node scripts/content.mjs",
"content:local": "node scripts/local-content.mjs",
"predev": "node scripts/content.mjs --ensure && node scripts/preview-styles.mjs",
"dev": "astro dev",
"prebuild": "node scripts/content.mjs --ensure && node scripts/preview-styles.mjs",
"build": "astro build",
"preview": "astro preview",
"precheck": "node scripts/content.mjs --ensure && node scripts/preview-styles.mjs",
"check": "astro check && astro build && node scripts/check-links.mjs"
},
"dependencies": {
+49 -27
View File
@@ -4,8 +4,8 @@
# these origins in deploy/ctao-demo-gitea.container.
backend:
name: gitea
# Content lives in its OWN repo (editorial history separate from code —
# the build overlays it onto the portal code, see deploy/build.sh).
# Keep this branch in sync with CONTENT_BRANCH in deploy/build.sh.
# Seed the content branch from sample-content/ before deploying.
repo: ctao/content
branch: main
base_url: https://astro-git.isl-dev.grid.cyfronet.pl
@@ -45,36 +45,58 @@ media_libraries:
optimize: true
collections:
- name: news
label: "News"
label_singular: "Article"
folder: "news"
create: true
slug: "{{slug}}"
extension: md
- name: examples
label: "Example pages"
files:
- name: lorem-ipsum
label: "Lorem ipsum"
file: "pages/lorem-ipsum.md"
format: frontmatter
summary: "{{title}} · {{date}}"
fields:
- { name: title, label: "Title", widget: string }
- { name: description, label: "Short description", widget: text }
- { name: date, label: "Date", widget: datetime, date_format: "YYYY-MM-DD", time_format: false }
- { name: category, label: "Category", widget: string, default: "news" }
- { name: author, label: "Author", widget: string, default: "CTAO" }
- { name: cover, label: "Cover image", widget: image, required: false }
- { name: lang, label: "Language (if not English, e.g. pl)", widget: string, required: false }
- { name: draft, label: "Draft (unpublished)", widget: boolean, default: false }
- { name: introduction, label: "Introduction", widget: text, required: false }
- { name: image, label: "Image", widget: image, required: false }
- { name: imageAlt, label: "Image alternative text", widget: string, required: false, hint: "Describe the image; leave empty only for decorative images." }
- { name: imageCaption, label: "Image caption", widget: string, required: false }
- { name: body, label: "Body", widget: markdown }
- name: pages
label: "Pages"
label_singular: "Page"
folder: "pages"
create: true
slug: "{{slug}}"
extension: md
- { name: buttonLabel, label: "Button label", widget: string, required: false }
- { name: buttonUrl, label: "Button destination", widget: string, required: false, hint: "A site path such as /example/lorem-ipsum/, or an https:// URL." }
- name: dolor-sit-amet
label: "Dolor sit amet"
file: "pages/dolor-sit-amet.md"
format: frontmatter
summary: "{{title}}"
fields:
- { name: title, label: "Title", widget: string }
- { name: description, label: "Description", widget: text, required: false }
- { name: introduction, label: "Introduction", widget: text, required: false }
- { name: image, label: "Image", widget: image, required: false }
- { name: imageAlt, label: "Image alternative text", widget: string, required: false, hint: "Describe the image; leave empty only for decorative images." }
- { name: imageCaption, label: "Image caption", widget: string, required: false }
- { name: body, label: "Body", widget: markdown }
- { name: buttonLabel, label: "Button label", widget: string, required: false }
- { name: buttonUrl, label: "Button destination", widget: string, required: false, hint: "A site path such as /example/lorem-ipsum/, or an https:// URL." }
- name: consectetur-adipiscing
label: "Consectetur adipiscing"
file: "pages/consectetur-adipiscing.md"
format: frontmatter
fields:
- { name: title, label: "Title", widget: string }
- { name: introduction, label: "Introduction", widget: text, required: false }
- { name: image, label: "Image", widget: image, required: false }
- { name: imageAlt, label: "Image alternative text", widget: string, required: false, hint: "Describe the image; leave empty only for decorative images." }
- { name: imageCaption, label: "Image caption", widget: string, required: false }
- { name: body, label: "Body", widget: markdown }
- { name: buttonLabel, label: "Button label", widget: string, required: false }
- { name: buttonUrl, label: "Button destination", widget: string, required: false, hint: "A site path such as /example/lorem-ipsum/, or an https:// URL." }
- name: sed-do-eiusmod
label: "Sed do eiusmod"
file: "pages/sed-do-eiusmod.md"
format: frontmatter
fields:
- { name: title, label: "Title", widget: string }
- { name: introduction, label: "Introduction", widget: text, required: false }
- { name: image, label: "Image", widget: image, required: false }
- { name: imageAlt, label: "Image alternative text", widget: string, required: false, hint: "Describe the image; leave empty only for decorative images." }
- { name: imageCaption, label: "Image caption", widget: string, required: false }
- { name: body, label: "Body", widget: markdown }
- { name: buttonLabel, label: "Button label", widget: string, required: false }
- { name: buttonUrl, label: "Button destination", widget: string, required: false, hint: "A site path such as /example/lorem-ipsum/, or an https:// URL." }
+218 -57
View File
@@ -1,71 +1,232 @@
/* Entry-preview styles for the Sveltia editor, registered via the documented
CMS.registerPreviewStyle API (bare element selectors, per the official docs
example). This file MIRRORS the article styles 1:1 — the design tokens from
:root and the `.prose` block in src/styles/global.css — with `.prose`
dropped from selectors (the preview pane has no such wrapper).
KEEP IN SYNC: when you touch `.prose` or the tokens in global.css, update
the corresponding value here (a matching reminder sits next to `.prose`).
Fonts: same self-hosted woff2 files the site uses, same origin. (The
Sveltia bundle still loads its own UI fonts from jsDelivr — only the
PREVIEW pane is CDN-free.) */
/* Generated by scripts/preview-styles.mjs; edit src/styles/global.css. */
/* ==========================================================================
CTAO Science Portal: content page mockup
Colours and type follow the CTAO Brand Guidelines (April 2024).
========================================================================== */
/* Fonts
========================================================================== */
@font-face {
font-family: "Inter"; font-weight: 400 700; font-display: swap;
src: url("/fonts/inter-latin.woff2") format("woff2");
font-family: "Inter";
font-weight: 400 700;
font-display: swap;
src: url("/assets/fonts/inter-latin.woff2") format("woff2");
}
@font-face {
font-family: "Space Grotesk"; font-weight: 500 700; font-display: swap;
src: url("/fonts/space-grotesk-latin.woff2") format("woff2");
font-family: "Space Grotesk";
font-weight: 500 700;
font-display: swap;
src: url("/assets/fonts/space-grotesk-latin.woff2") format("woff2");
}
/* Tokens
========================================================================== */
:root {
/* tokens copied from global.css :root */
--galaxy: #00004a;
--moon: #f5f5f5;
--text: #101228;
--muted: #46536a;
--border: #e2e5ee;
--link: #0057c2;
--font-body: "Inter", Arial, system-ui, sans-serif;
--font-display: "Space Grotesk", var(--font-body);
--fs-s: 0.9rem;
--fs-l: 1.125rem;
--fs-xl: 1.5rem;
--fs-h2: clamp(1.75rem, 3.5vw, 2.5rem);
--radius: 16px;
/* Brand main colours (D.2.1) */
--galaxy-blue: #00004a;
--cherenkov-blue: #00e4d8;
--moon-gray: #f5f5f5;
/* Brand secondary colours (D.2.2) */
--white: #ffffff;
--cosmic-azure: #007aff;
--interstellar-indigo: #00009c;
/* Text */
--text: #111111;
--text-muted: #555555;
/* Cosmic Azure at 84% brightness: 5.4:1 on white, 3.5:1 against body text */
--link: #0066d6;
/* Type: Inter for everything, Space Grotesk for headlines (D.3) */
--font-body: "Inter", Arial, sans-serif;
--font-head: "Space Grotesk", Arial, sans-serif;
/* Layout */
--content-width: 85rem;
--gutter: clamp(1rem, 4vw, 2.5rem);
--header-height: 7rem;
--header-height-scrolled: 5rem;
--radius-lg: 60px;
--radius-md: 30px;
--radius-sm: 15px;
/* Dropdown submenus (Example, language) */
--radius-menu: 8px;
--transition: 0.3s ease-in-out;
/* Film grain tile over the hero background, also hides gradient banding */
--grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='3'/%3E%3C/filter%3E%3Crect width='120' height='120' filter='url(%23n)' opacity='.05'/%3E%3C/svg%3E");
}
/* Base
========================================================================== */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-padding-top: var(--header-height-scrolled);
}
/* preview shell ≈ article column */
body {
margin: 0;
font-family: var(--font-body);
font-size: 1rem;
line-height: 1.5;
color: var(--text);
max-width: 70ch;
margin: 0 auto;
padding: 24px;
background: var(--white);
-webkit-font-smoothing: antialiased;
}
/* = global.css base heading/link rules = */
h1, h2 { font-family: var(--font-display); font-weight: 500; letter-spacing: -0.025em; line-height: 1.1; }
h3 { font-weight: 600; }
h1, h2, h3 { overflow-wrap: break-word; text-wrap: balance; }
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-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; }
ul, ol { margin: 0 0 16px; padding-left: 22px; }
li { margin: 6px 0; }
blockquote {
margin: 22px 0; padding: 12px 20px; border-left: 3px solid var(--galaxy);
background: var(--moon); color: var(--text);
body.no-scroll {
overflow: hidden;
}
img { display: block; max-width: 100%; height: auto; border: 1px solid var(--border); border-radius: var(--radius); }
img + em, p > em:only-child { color: var(--muted); font-size: var(--fs-s); }
hr { border: 0; border-top: 1px solid var(--border); margin: 32px 0; }
table { display: block; width: 100%; overflow-x: auto; border-collapse: collapse; margin: 0 0 16px; font-size: var(--fs-s); }
th { text-align: left; font-weight: 600; color: var(--galaxy); }
th, td { padding: 10px 12px; border-bottom: 1px solid var(--border); }
thead th { border-bottom: 2px solid var(--galaxy); }
img {
display: block;
max-width: 100%;
height: auto;
}
a {
color: var(--link);
text-decoration: none;
}
a:hover,
a:focus-visible {
text-decoration: underline;
text-underline-offset: 0.15em;
}
button {
padding: 0;
font: inherit;
color: inherit;
background: none;
border: 0;
cursor: pointer;
}
/* Focus ring: Link blue on light backgrounds, Cherenkov Blue on dark ones */
:focus-visible {
outline: 2px solid var(--link);
outline-offset: 3px;
}
.site-header :focus-visible,
.page-hero :focus-visible,
.site-footer :focus-visible {
outline-color: var(--cherenkov-blue);
}
/* Prose
========================================================================== */
body {
padding-bottom: 5rem;
}
body > * {
max-width: 64rem;
}
/* Galaxy Blue headline on light background (D.3.4) */
body h2 {
margin: 3rem 0 1rem;
font-family: var(--font-head);
font-size: clamp(1.75rem, 3vw, 2.375rem);
font-weight: 500;
line-height: 1.1;
letter-spacing: -0.02em;
color: var(--galaxy-blue);
}
body h2:first-child {
margin-top: 0;
}
body p {
margin: 0 0 1.25rem;
font-size: 1.0625rem;
line-height: 1.6;
}
/* Text links: colour only at rest, underline on hover and focus */
body a:not(.btn):hover,
body a:not(.btn):focus-visible {
text-decoration: underline;
text-underline-offset: 0.15em;
}
/* Cherenkov Blue fails contrast as text on white, so it is the accent bar */
body .highlight,
body blockquote {
margin: 2.5rem 0;
padding-left: 1.5rem;
font-size: 1.375rem;
font-weight: 500;
line-height: 1.35;
color: var(--galaxy-blue);
border-left: 4px solid var(--cherenkov-blue);
}
/* Buttons
========================================================================== */
.btn {
display: inline-flex;
align-items: center;
gap: 0.75rem;
height: 2.75rem;
padding: 0 1.5rem;
font-size: 0.9375rem;
font-weight: 700;
border-radius: 999px;
}
/* Underline the label only: a flex container would pass it on to the arrow */
.btn:hover,
.btn:focus-visible {
text-decoration: none;
}
.btn:hover .btn__label,
.btn:focus-visible .btn__label {
text-decoration: underline;
text-underline-offset: 0.15em;
}
.btn--primary {
color: var(--white);
background: linear-gradient(
90deg,
var(--galaxy-blue) 0%,
var(--cosmic-azure) 70%,
var(--cherenkov-blue) 100%
);
}
.btn--primary:hover {
filter: brightness(1.1);
}
body { overflow-wrap: anywhere; }
body h3, body h4, body h5, body h6 { color: var(--galaxy-blue); font-family: var(--font-head); }
body li { margin-bottom: 0.5rem; }
body blockquote p { font-size: inherit; line-height: inherit; }
body pre { padding: 1rem; overflow-x: auto; background: var(--moon-gray); }
body code { overflow-wrap: normal; }
body table { display: block; overflow-x: auto; border-collapse: collapse; }
body th, body td { padding: 0.75rem; border-bottom: 1px solid #ddd; text-align: left; }
body hr { margin: 2rem 0; border: 0; border-top: 1px solid #ddd; }
body img { border-radius: var(--radius-sm); }
body { display: block; min-height: 0; max-width: 64rem; margin: 0 auto; padding: 1.5rem; }
h1 { color: var(--galaxy-blue); font-family: var(--font-head); font-weight: 500; line-height: 1.05; }
Binary file not shown.
Binary file not shown.
+102
View File
@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 153 32" style="enable-background:new 0 0 153 32;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;}
.st2{display:inline;clip-path:url(#SVGID_00000049184525615463858320000015264053225314800771_);}
.st3{fill:#FFFFFF;}
.st4{fill:#00E5D9;}
.st5{fill:#00E4D8;}
</style>
<g class="st0">
<defs>
<rect id="SVGID_1_" x="0.9" width="151.4" height="32"/>
</defs>
<clipPath id="SVGID_00000109021271204444604050000000952066022526769593_" class="st1">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="display:inline;clip-path:url(#SVGID_00000109021271204444604050000000952066022526769593_);">
<path class="st3" d="M19.3,0c10.9,0,17.1,4.8,18,12.6h-9.1c-0.6-2.8-3.4-5.1-8.8-5.1c-6.3,0-9.4,3.2-9.4,8.5
c0,5.3,3.1,8.6,9.4,8.6c5.5,0,8.3-2.4,8.8-5.2h9.1c-0.9,7.9-7,12.7-18,12.7C7.4,32,0.9,26.4,0.9,16C0.9,5.6,7.4,0,19.3,0z"/>
<path class="st3" d="M62.3,8.4c0.2-0.1,0.4-0.1,0.6-0.1c4,0,8,0,12.1,0c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3-0.1c0-0.1,0-0.2,0-0.3
c0-2.3,0-4.6,0-6.9c0-0.1,0-0.2,0-0.3c-0.2,0-0.5,0-0.7,0c-11.4,0-22.8,0-34.3,0c-0.2,0-0.3,0-0.4,0c-0.1,0-0.2,0-0.3,0.1
c0,0.2,0,0.4,0,0.5c0,0.8,0,1.6,0,2.4c0,1.3,0,2.6,0,3.9c0,0.2,0,0.4,0.1,0.5c0.2,0.1,0.4,0.1,0.6,0.1c4,0,8.1,0,12.1,0
c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3,0.1c0,0.2,0,0.5,0,0.7c0,6.6,0,13.1,0,19.7c0,0.2,0,0.4,0,0.5c0,0.1,0,0.2,0,0.3
c0.1,0,0.1,0,0.1,0c2.7-0.5,5.4-1.1,8.1-1.6c0.1,0,0.2-0.1,0.3-0.1c0-0.2,0-0.4,0-0.6c0-6,0-12,0-18.1c0-0.2,0-0.4,0-0.5
C62.2,8.6,62.3,8.5,62.3,8.4L62.3,8.4z M53.7,30.2c0,0.3-0.1,0.6,0,0.8c0.4,0.1,6.6,0.1,8.2,0c0.1,0,0.2,0,0.3,0
c0-0.1,0.1-0.1,0.1-0.2c0-0.6,0-1.3,0-1.9c0,0,0-0.1-0.1-0.2C59.4,29.2,56.5,29.7,53.7,30.2L53.7,30.2z"/>
<path class="st3" d="M151.5,11.4c0.1,0,0.2,0,0.2,0c0.1,0.1,0.1,0.2,0.1,0.3c0.1,0.6,0.2,1.3,0.3,1.9c0.2,1.3,0.2,2.6,0.1,3.8
c-0.1,2-0.6,4-1.5,5.9c-1,2.2-2.6,3.9-4.5,5.3c-1.2,0.8-2.5,1.5-3.9,2c-1.1,0.4-2.3,0.7-3.4,0.9c-0.5,0.1-1.1,0.2-1.6,0.3
c-0.5,0.1-0.9,0.1-1.4,0.1c-0.1,0-0.1,0-0.2,0c-0.7,0.1-1.5,0.1-2.2,0.1c-0.7,0-1.5,0-2.2,0c-0.3,0-0.5,0-0.8-0.1
c-0.1,0-0.3,0-0.4,0c-2.2-0.2-4.3-0.6-6.4-1.3c-1.4-0.5-2.7-1.2-4-2c-2.7-1.9-4.5-4.5-5.4-7.7c-0.1-0.5-0.3-1.1-0.3-1.6
c0-0.1,0-0.1,0-0.2c0.1-0.1,0.2-0.1,0.4-0.2c0.5-0.1,1.1-0.2,1.6-0.3c0.9-0.2,1.8-0.4,2.7-0.5c0.9-0.2,1.8-0.4,2.7-0.6
c0.3-0.1,0.5-0.1,0.8-0.2c0.2,0,0.3-0.1,0.5-0.1c0.1,0,0.1,0.1,0.2,0.2c0,0.4,0.1,0.8,0.2,1.2c0.2,0.9,0.6,1.8,1.2,2.6
c0.6,0.8,1.3,1.4,2.2,1.9c0.7,0.4,1.5,0.7,2.4,0.9c0.6,0.1,1.2,0.3,1.8,0.3c0.8,0.1,1.7,0.2,2.5,0.2c0.4,0,0.7,0,1.1,0
c0.1,0,0.3,0,0.4,0c1.3-0.1,2.5-0.3,3.7-0.7c0.5-0.2,1-0.4,1.5-0.7c1.6-0.9,2.7-2.3,3.2-4.1c0.2-0.6,0.3-1.2,0.3-1.8
c0-0.5,0.1-1,0.1-1.4c0-0.7-0.1-1.4-0.2-2.1c0-0.1,0-0.2,0-0.3c0,0,0-0.1,0-0.1c0,0,0.1,0,0.1-0.1c0.1,0,0.3-0.1,0.4-0.1
c1.5-0.3,2.9-0.6,4.4-0.9c0.7-0.1,1.5-0.3,2.2-0.4c0.2,0,0.4-0.1,0.6-0.1c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0-0.1
C151.2,11.3,151.3,11.3,151.5,11.4C151.5,11.4,151.5,11.4,151.5,11.4L151.5,11.4z"/>
<path class="st3" d="M151.1,11.5c-0.4,0-0.7,0.1-1.1,0.1c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.7,0.1-1.3,0.2-2,0.3
c-0.1,0-0.3,0.1-0.4,0.1c-0.1,0-0.3,0-0.4,0c-0.1-0.2-0.2-0.3-0.2-0.5c-0.5-1.4-1.4-2.4-2.6-3.2c-0.5-0.3-1-0.6-1.5-0.8
c-0.7-0.3-1.5-0.5-2.3-0.6c-1.3-0.2-2.6-0.3-3.8-0.3c-0.7,0-1.4,0.1-2.1,0.2c-0.8,0.1-1.6,0.3-2.4,0.5c-0.5,0.2-1,0.4-1.4,0.6
c-1.8,1-3,2.5-3.5,4.5c-0.2,0.7-0.3,1.5-0.3,2.2c0,0.2,0,0.4,0,0.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.3,0.1-0.4,0.1
c-0.6,0.1-1.3,0.2-1.9,0.3c-0.6,0.1-1.3,0.2-1.9,0.3c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.1,0-0.3,0.1-0.4,0
c-0.1,0-0.1-0.1-0.1-0.2c0-0.1,0-0.3,0-0.4c0-0.9-0.1-1.8,0-2.7c0.1-2.3,0.7-4.5,1.8-6.6c1.1-2,2.6-3.6,4.4-4.8
c1.3-0.9,2.7-1.5,4.2-2c1.1-0.4,2.3-0.6,3.4-0.8c0.6-0.1,1.3-0.2,1.9-0.3c0.6-0.1,1.3-0.1,1.9-0.1c1.2,0,2.4,0,3.6,0
c0.2,0,0.4,0,0.5,0c0.1,0,0.2,0,0.3,0c0.8,0,1.6,0.2,2.5,0.3c1.3,0.2,2.5,0.5,3.7,0.9c1.4,0.5,2.8,1.1,4.1,2
c1.9,1.3,3.4,2.9,4.5,5c0.4,0.8,0.8,1.7,1,2.6c0,0.1,0,0.2,0.1,0.2c0,0,0,0.1,0,0.1c-0.1,0-0.1,0.1-0.2,0.1l0,0
C151.4,11.5,151.2,11.4,151.1,11.5L151.1,11.5L151.1,11.5z"/>
<path class="st3" d="M151,11.5c0.1-0.1,0.1-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0C151.4,11.5,151.2,11.5,151,11.5L151,11.5z"/>
<path class="st3" d="M84.5,23.5C84.5,23.4,84.6,23.4,84.5,23.5c2.7-5.4,5.3-10.8,8-16.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0.1,0c0.1,0.1,0.2,0.3,0.2,0.4c0.9,1.8,1.7,3.5,2.6,5.3c1.2,2.4,2.4,4.8,3.6,7.2c0.1,0.2,0.1,0.3,0.3,0.5
c0.6-0.1,1.2-0.2,1.8-0.3c1.1-0.2,2.1-0.4,3.2-0.5c1-0.2,2-0.3,3-0.5c0.1,0,0.2-0.1,0.3-0.1c-0.1-0.2-0.2-0.3-0.2-0.5
c-1.4-2.8-2.8-5.6-4.2-8.4c-1.5-2.9-2.9-5.8-4.4-8.7c-0.1-0.2-0.1-0.4-0.3-0.5c-0.1,0-0.1,0-0.2,0c-3.8,0-7.6,0-11.4,0
c-0.1,0-0.1,0-0.2,0c0,0,0,0-0.1,0c0,0,0,0-0.1,0C82.6,9,78.5,17.2,74.4,25.3c0,0,0,0.1,0,0.1c0,0,0.1,0.1,0.1,0
C77.8,24.8,81.2,24.1,84.5,23.5z M100,21.8c0.3,0.7,4.4,9,4.6,9.3c0.1,0,0.2,0,0.3,0c2.9,0,5.8,0,8.7,0c0,0,0,0,0.1,0
c0,0,0,0,0.1-0.1c0-0.1-0.1-0.2-0.1-0.2c-0.1-0.2-0.3-0.5-0.4-0.8c-1.6-3.1-3.2-6.3-4.7-9.4c-0.1-0.2-0.1-0.3-0.3-0.5
C105.5,20.7,102.8,21.2,100,21.8L100,21.8z M73.7,26.8c-0.7,1.4-1.4,2.7-2,4.1c0,0.1,0,0.2-0.1,0.3h9.1c0.2-0.3,2.6-5,2.9-5.8
c0-0.1,0-0.1,0.1-0.2c-0.1,0-0.2,0-0.3,0C80.2,25.6,76.9,26.2,73.7,26.8L73.7,26.8z M93.8,29C93.8,29,93.8,29,93.8,29
c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6
c0.4-0.2,0.9-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5
c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5c0,0.8-0.2,1.5-0.7,2.1
c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1c0.9,0,1.8,0,2.8,0
c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"/>
<path class="st4" d="M93.8,29C93.8,29,93.8,29,93.8,29c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1
c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6c0.4-0.2,0.8-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0
c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5
c0,0.8-0.2,1.5-0.7,2.1c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1
c0.9,0,1.8,0,2.8,0c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"
/>
</g>
</g>
<g id="Logotype">
<g>
<g>
<polygon class="st3" points="100.4,22.3 104.7,30.8 113.9,30.8 108.6,20.6 "/>
<g>
<path class="st3" d="M122.9,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.3,10.2-8.3c5.7,0,8.8,1.9,9.8,5.2l8.9-1.4
c-1.9-7-8.3-11.1-18.7-11.1c-12.5,0-19.3,5.8-19.3,15.6c0,0.5,0,1.1,0.1,1.6L122.9,16.1L122.9,16.1z"/>
<path class="st3" d="M143.1,13.3c0.2,0.8,0.3,1.7,0.3,2.6c0,5.2-3,8.4-10.3,8.4c-6.5,0-9.6-2.5-10.1-6.8l-8.8,1.8
c1.4,7.8,8,12.2,18.9,12.2c12.4,0,19.3-5.9,19.3-15.7c0-1.6-0.2-3.1-0.5-4.5L143.1,13.3L143.1,13.3z"/>
</g>
<g>
<polygon class="st3" points="73.5,27 71.5,30.8 80.8,30.8 83.5,25.4 "/>
<polygon class="st3" points="98.6,1.2 86.8,1.2 74.6,24.7 84.9,22.6 92.7,7.2 99.2,19.9 107.6,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.6,30.2 53.6,30.8 62.2,30.8 62.2,28.8 "/>
<polygon class="st3" points="53.6,29.1 62.2,27.3 62.2,8.5 75.7,8.5 75.7,1.2 40.1,1.2 40.1,8.5 53.6,8.5 "/>
</g>
<path class="st3" d="M19.3,0.4c10.9,0,17.1,4.7,18,12.3h-9.1c-0.6-2.7-3.4-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.3
c0,5.2,3.1,8.4,9.4,8.4c5.5,0,8.3-2.3,8.8-5.1h9.1c-0.9,7.7-7.1,12.4-18,12.4C7.4,31.6,0.9,26.1,0.9,16S7.4,0.4,19.3,0.4
L19.3,0.4z"/>
</g>
</g>
<path class="st5" 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.8
c-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"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 8.1 KiB

+129
View File
@@ -0,0 +1,129 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 28.3.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Ebene_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 153 32" style="enable-background:new 0 0 153 32;" xml:space="preserve">
<style type="text/css">
.st0{display:none;}
.st1{display:inline;}
.st2{display:inline;clip-path:url(#SVGID_00000021077498389242353310000002629454631782585475_);}
.st3{fill:#00004A;}
.st4{fill:#00E5D9;}
.st5{fill:#00E4D8;}
</style>
<g class="st0">
<defs>
<rect id="SVGID_1_" x="0.9" width="151.4" height="32"/>
</defs>
<clipPath id="SVGID_00000068637063279286702420000005043763006986931103_" class="st1">
<use xlink:href="#SVGID_1_" style="overflow:visible;"/>
</clipPath>
<g style="display:inline;clip-path:url(#SVGID_00000068637063279286702420000005043763006986931103_);">
<path class="st3" d="M19.3,0c10.9,0,17.1,4.8,18,12.6h-9.1c-0.6-2.8-3.4-5.1-8.8-5.1c-6.3,0-9.4,3.2-9.4,8.5
c0,5.3,3.1,8.6,9.4,8.6c5.5,0,8.3-2.4,8.8-5.2h9.1c-0.9,7.9-7,12.7-18,12.7C7.4,32,0.9,26.4,0.9,16C0.9,5.6,7.4,0,19.3,0z"/>
<path class="st3" d="M62.3,8.4c0.2-0.1,0.4-0.1,0.6-0.1c4,0,8,0,12.1,0c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3-0.1c0-0.1,0-0.2,0-0.3
c0-2.3,0-4.6,0-6.9c0-0.1,0-0.2,0-0.3c-0.2,0-0.5,0-0.7,0c-11.4,0-22.8,0-34.3,0c-0.2,0-0.3,0-0.4,0c-0.1,0-0.2,0-0.3,0.1
c0,0.2,0,0.4,0,0.5c0,0.8,0,1.6,0,2.4c0,1.3,0,2.6,0,3.9c0,0.2,0,0.4,0.1,0.5c0.2,0.1,0.4,0.1,0.6,0.1c4,0,8.1,0,12.1,0
c0.2,0,0.3,0,0.5,0c0.1,0,0.2,0,0.3,0.1c0,0.2,0,0.5,0,0.7c0,6.6,0,13.1,0,19.7c0,0.2,0,0.4,0,0.5c0,0.1,0,0.2,0,0.3
c0.1,0,0.1,0,0.1,0c2.7-0.5,5.4-1.1,8.1-1.6c0.1,0,0.2-0.1,0.3-0.1c0-0.2,0-0.4,0-0.6c0-6,0-12,0-18.1c0-0.2,0-0.4,0-0.5
C62.2,8.6,62.3,8.5,62.3,8.4L62.3,8.4z M53.7,30.2c0,0.3-0.1,0.6,0,0.8c0.4,0.1,6.6,0.1,8.2,0c0.1,0,0.2,0,0.3,0
c0-0.1,0.1-0.1,0.1-0.2c0-0.6,0-1.3,0-1.9c0,0,0-0.1-0.1-0.2C59.4,29.2,56.5,29.7,53.7,30.2L53.7,30.2z"/>
<path class="st3" d="M151.5,11.4c0.1,0,0.2,0,0.2,0c0.1,0.1,0.1,0.2,0.1,0.3c0.1,0.6,0.2,1.3,0.3,1.9c0.2,1.3,0.2,2.6,0.1,3.8
c-0.1,2-0.6,4-1.5,5.9c-1,2.2-2.6,3.9-4.5,5.3c-1.2,0.8-2.5,1.5-3.9,2c-1.1,0.4-2.3,0.7-3.4,0.9c-0.5,0.1-1.1,0.2-1.6,0.3
c-0.5,0.1-0.9,0.1-1.4,0.1c-0.1,0-0.1,0-0.2,0c-0.7,0.1-1.5,0.1-2.2,0.1c-0.7,0-1.5,0-2.2,0c-0.3,0-0.5,0-0.8-0.1
c-0.1,0-0.3,0-0.4,0c-2.2-0.2-4.3-0.6-6.4-1.3c-1.4-0.5-2.7-1.2-4-2c-2.7-1.9-4.5-4.5-5.4-7.7c-0.1-0.5-0.3-1.1-0.3-1.6
c0-0.1,0-0.1,0-0.2c0.1-0.1,0.2-0.1,0.4-0.2c0.5-0.1,1.1-0.2,1.6-0.3c0.9-0.2,1.8-0.4,2.7-0.5c0.9-0.2,1.8-0.4,2.7-0.6
c0.3-0.1,0.5-0.1,0.8-0.2c0.2,0,0.3-0.1,0.5-0.1c0.1,0,0.1,0.1,0.2,0.2c0,0.4,0.1,0.8,0.2,1.2c0.2,0.9,0.6,1.8,1.2,2.6
c0.6,0.8,1.3,1.4,2.2,1.9c0.7,0.4,1.5,0.7,2.4,0.9c0.6,0.1,1.2,0.3,1.8,0.3c0.8,0.1,1.7,0.2,2.5,0.2c0.4,0,0.7,0,1.1,0
c0.1,0,0.3,0,0.4,0c1.3-0.1,2.5-0.3,3.7-0.7c0.5-0.2,1-0.4,1.5-0.7c1.6-0.9,2.7-2.3,3.2-4.1c0.2-0.6,0.3-1.2,0.3-1.8
c0-0.5,0.1-1,0.1-1.4c0-0.7-0.1-1.4-0.2-2.1c0-0.1,0-0.2,0-0.3c0,0,0-0.1,0-0.1c0,0,0.1,0,0.1-0.1c0.1,0,0.3-0.1,0.4-0.1
c1.5-0.3,2.9-0.6,4.4-0.9c0.7-0.1,1.5-0.3,2.2-0.4c0.2,0,0.4-0.1,0.6-0.1c0.1,0,0.1-0.1,0.2-0.1c0,0,0,0,0-0.1
C151.2,11.3,151.3,11.3,151.5,11.4C151.5,11.4,151.5,11.4,151.5,11.4L151.5,11.4z"/>
<path class="st3" d="M151.1,11.5c-0.4,0-0.7,0.1-1.1,0.1c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.7,0.1-1.3,0.2-2,0.3
c-0.1,0-0.3,0.1-0.4,0.1c-0.1,0-0.3,0-0.4,0c-0.1-0.2-0.2-0.3-0.2-0.5c-0.5-1.4-1.4-2.4-2.6-3.2c-0.5-0.3-1-0.6-1.5-0.8
c-0.7-0.3-1.5-0.5-2.3-0.6c-1.3-0.2-2.6-0.3-3.8-0.3c-0.7,0-1.4,0.1-2.1,0.2c-0.8,0.1-1.6,0.3-2.4,0.5c-0.5,0.2-1,0.4-1.4,0.6
c-1.8,1-3,2.5-3.5,4.5c-0.2,0.7-0.3,1.5-0.3,2.2c0,0.2,0,0.4,0,0.7c0,0.1,0,0.2-0.1,0.2c-0.1,0.1-0.3,0.1-0.4,0.1
c-0.6,0.1-1.3,0.2-1.9,0.3c-0.6,0.1-1.3,0.2-1.9,0.3c-0.7,0.1-1.4,0.2-2.1,0.4c-0.7,0.1-1.3,0.2-2,0.4c-0.1,0-0.3,0.1-0.4,0
c-0.1,0-0.1-0.1-0.1-0.2c0-0.1,0-0.3,0-0.4c0-0.9-0.1-1.8,0-2.7c0.1-2.3,0.7-4.5,1.8-6.6c1.1-2,2.6-3.6,4.4-4.8
c1.3-0.9,2.7-1.5,4.2-2c1.1-0.4,2.3-0.6,3.4-0.8c0.6-0.1,1.3-0.2,1.9-0.3c0.6-0.1,1.3-0.1,1.9-0.1c1.2,0,2.4,0,3.6,0
c0.2,0,0.4,0,0.5,0c0.1,0,0.2,0,0.3,0c0.8,0,1.6,0.2,2.5,0.3c1.3,0.2,2.5,0.5,3.7,0.9c1.4,0.5,2.8,1.1,4.1,2
c1.9,1.3,3.4,2.9,4.5,5c0.4,0.8,0.8,1.7,1,2.6c0,0.1,0,0.2,0.1,0.2c0,0,0,0.1,0,0.1c-0.1,0-0.1,0.1-0.2,0.1l0,0
C151.4,11.5,151.2,11.4,151.1,11.5L151.1,11.5L151.1,11.5z"/>
<path class="st3" d="M151,11.5c0.1-0.1,0.1-0.1,0.2-0.1c0.1,0,0.2,0,0.3,0C151.4,11.5,151.2,11.5,151,11.5L151,11.5z"/>
<path class="st3" d="M84.5,23.5C84.5,23.4,84.6,23.4,84.5,23.5c2.7-5.4,5.3-10.8,8-16.1c0,0,0-0.1,0.1-0.1c0,0,0-0.1,0.1-0.1
c0,0,0,0,0,0c0,0,0,0,0.1,0c0.1,0.1,0.2,0.3,0.2,0.4c0.9,1.8,1.7,3.5,2.6,5.3c1.2,2.4,2.4,4.8,3.6,7.2c0.1,0.2,0.1,0.3,0.3,0.5
c0.6-0.1,1.2-0.2,1.8-0.3c1.1-0.2,2.1-0.4,3.2-0.5c1-0.2,2-0.3,3-0.5c0.1,0,0.2-0.1,0.3-0.1c-0.1-0.2-0.2-0.3-0.2-0.5
c-1.4-2.8-2.8-5.6-4.2-8.4c-1.5-2.9-2.9-5.8-4.4-8.7c-0.1-0.2-0.1-0.4-0.3-0.5c-0.1,0-0.1,0-0.2,0c-3.8,0-7.6,0-11.4,0
c-0.1,0-0.1,0-0.2,0c0,0,0,0-0.1,0c0,0,0,0-0.1,0C82.6,9,78.5,17.2,74.4,25.3c0,0,0,0.1,0,0.1c0,0,0.1,0.1,0.1,0
C77.8,24.8,81.2,24.1,84.5,23.5z M100,21.8c0.3,0.7,4.4,9,4.6,9.3c0.1,0,0.2,0,0.3,0c2.9,0,5.8,0,8.7,0c0,0,0,0,0.1,0
c0,0,0,0,0.1-0.1c0-0.1-0.1-0.2-0.1-0.2c-0.1-0.2-0.3-0.5-0.4-0.8c-1.6-3.1-3.2-6.3-4.7-9.4c-0.1-0.2-0.1-0.3-0.3-0.5
C105.5,20.7,102.8,21.2,100,21.8L100,21.8z M73.7,26.8c-0.7,1.4-1.4,2.7-2,4.1c0,0.1,0,0.2-0.1,0.3h9.1c0.2-0.3,2.6-5,2.9-5.8
c0-0.1,0-0.1,0.1-0.2c-0.1,0-0.2,0-0.3,0C80.2,25.6,76.9,26.2,73.7,26.8L73.7,26.8z M93.8,29C93.8,29,93.8,29,93.8,29
c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6
c0.4-0.2,0.9-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5
c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5c0,0.8-0.2,1.5-0.7,2.1
c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1c0.9,0,1.8,0,2.8,0
c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"/>
<path class="st4" d="M93.8,29C93.8,29,93.8,29,93.8,29c0.1-0.5,0.1-1,0-1.5c0-0.5-0.1-1.1,0-1.6c0-0.3,0.1-0.7,0.2-1
c0.3-1.2,1.1-2,2.1-2.4c0.5-0.2,1.1-0.4,1.6-0.6c0.4-0.2,0.8-0.3,1.3-0.5c-0.1-0.1-0.2-0.1-0.3-0.1c-1,0-1.9,0-2.9,0
c-0.9,0-1.7-0.4-2.4-1c-0.4-0.4-0.8-0.9-1-1.5c-0.3-0.8-0.6-1.6-0.8-2.4c0-0.1,0-0.1-0.2-0.1c0,0.2,0,0.3,0,0.5c0,0.8,0,1.7,0,2.5
c0,0.8-0.2,1.5-0.7,2.1c-0.5,0.7-1.1,1.1-1.8,1.3c-0.8,0.3-1.6,0.6-2.4,0.9c-0.1,0-0.1,0.1-0.2,0.1c0.1,0.1,0.3,0.1,0.4,0.1
c0.9,0,1.8,0,2.8,0c0.2,0,0.4,0,0.6,0.1c1.3,0.3,2.1,1.1,2.6,2.3c0.1,0.2,0.2,0.5,0.3,0.7C93.3,27.6,93.5,28.3,93.8,29L93.8,29z"
/>
</g>
</g>
<g id="Logotype" class="st0">
<g class="st1">
<g>
<polygon class="st3" points="100.4,22.3 104.7,30.8 113.9,30.8 108.6,20.6 "/>
<g>
<path class="st3" d="M122.9,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.3,10.2-8.3c5.7,0,8.8,1.9,9.8,5.2l8.9-1.4
c-1.9-7-8.3-11.1-18.7-11.1c-12.5,0-19.3,5.8-19.3,15.6c0,0.5,0,1.1,0.1,1.6L122.9,16.1L122.9,16.1z"/>
<path class="st3" d="M143.1,13.3c0.2,0.8,0.3,1.7,0.3,2.6c0,5.2-3,8.4-10.3,8.4c-6.5,0-9.6-2.5-10.1-6.8l-8.8,1.8
c1.4,7.8,8,12.2,18.9,12.2c12.4,0,19.3-5.9,19.3-15.7c0-1.6-0.2-3.1-0.5-4.5L143.1,13.3L143.1,13.3z"/>
</g>
<g>
<polygon class="st3" points="73.5,27 71.5,30.8 80.8,30.8 83.5,25.4 "/>
<polygon class="st3" points="98.6,1.2 86.8,1.2 74.6,24.7 84.9,22.6 92.7,7.2 99.2,19.9 107.6,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.6,30.2 53.6,30.8 62.2,30.8 62.2,28.8 "/>
<polygon class="st3" points="53.6,29.1 62.2,27.3 62.2,8.5 75.7,8.5 75.7,1.2 40.1,1.2 40.1,8.5 53.6,8.5 "/>
</g>
<path class="st3" d="M19.3,0.4c10.9,0,17.1,4.7,18,12.3h-9.1c-0.6-2.7-3.4-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.3
c0,5.2,3.1,8.4,9.4,8.4c5.5,0,8.3-2.3,8.8-5.1h9.1c-0.9,7.7-7.1,12.4-18,12.4C7.4,31.6,0.9,26.1,0.9,16S7.4,0.4,19.3,0.4
L19.3,0.4z"/>
</g>
</g>
<path class="st5" 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.8
c-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"/>
</g>
</g>
<g id="Logotype_00000134236662009793237430000016451412871672361145_">
<g>
<g>
<polygon class="st3" points="100,22.3 104.3,30.7 113.5,30.7 108.2,20.5 "/>
<g>
<path class="st3" d="M122.4,16.1c0,0,0-0.1,0-0.1c0-5.2,2.9-8.2,10.2-8.2c5.7,0,8.7,1.9,9.8,5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11
c-12.4,0-19.1,5.7-19.1,15.5c0,0.5,0,1.1,0.1,1.6L122.4,16.1L122.4,16.1z"/>
<path class="st3" d="M142.5,13.4c0.2,0.8,0.3,1.7,0.3,2.6c0,5.1-3,8.3-10.2,8.3c-6.4,0-9.5-2.5-10.1-6.7l-8.7,1.8
c1.4,7.7,7.9,12.2,18.8,12.2c12.4,0,19.1-5.8,19.1-15.6c0-1.6-0.2-3.1-0.5-4.4L142.5,13.4L142.5,13.4z"/>
</g>
<g>
<polygon class="st3" points="73.4,26.9 71.4,30.7 80.6,30.7 83.3,25.3 "/>
<polygon class="st3" points="98.4,1.3 86.6,1.3 74.5,24.7 84.7,22.5 92.5,7.3 98.9,19.9 107.2,18.6 "/>
</g>
<g>
<g>
<polygon class="st3" points="53.7,30.1 53.7,30.7 62.2,30.7 62.2,28.7 "/>
<polygon class="st3" points="53.7,29 62.2,27.3 62.2,8.5 75.6,8.5 75.6,1.3 40.2,1.3 40.2,8.5 53.7,8.5 "/>
</g>
<path class="st3" d="M19.6,0.5c10.8,0,17,4.7,17.8,12.2h-9.1c-0.6-2.7-3.3-5-8.8-5c-6.3,0-9.4,3.1-9.4,8.2s3.1,8.3,9.4,8.3
c5.4,0,8.2-2.3,8.8-5.1h9.1c-0.9,7.6-7,12.3-17.8,12.3C7.8,31.5,1.3,26.1,1.3,16S7.8,0.5,19.6,0.5L19.6,0.5z"/>
</g>
</g>
<path class="st3" d="M94.4,23.7c1-1.4,3.3-2.1,5.1-2.7c-2,0.1-4.4,0.3-5.8-0.6c-1.4-1-2.1-3.3-2.7-5.1c0.1,2,0.3,4.4-0.6,5.8
c-1,1.4-3.3,2.1-5.1,2.7c2-0.1,4.4-0.3,5.8,0.6c1.4,1,2.1,3.3,2.7,5.1C93.6,27.5,93.4,25.1,94.4,23.7z"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.6 KiB

+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Ebene_1" x="0" y="0" viewBox="0 0 153 43.9">
<style>
.st3{fill:#ffffff}
.st5{fill:#00E4D8}
</style>
<g id="Logotype_00000134236662009793237430000016451412871672361145_">
<path d="m100 22.3 4.3 8.4h9.2l-5.3-10.2zm22.4-6.2V16c0-5.2 2.9-8.2 10.2-8.2 5.7 0 8.7 1.9 9.8 5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11-12.4 0-19.1 5.7-19.1 15.5 0 .5 0 1.1.1 1.6zm20.1-2.7c.2.8.3 1.7.3 2.6 0 5.1-3 8.3-10.2 8.3-6.4 0-9.5-2.5-10.1-6.7l-8.7 1.8c1.4 7.7 7.9 12.2 18.8 12.2 12.4 0 19.1-5.8 19.1-15.6 0-1.6-.2-3.1-.5-4.4zM73.4 26.9l-2 3.8h9.2l2.7-5.4zm25-25.6H86.6L74.5 24.7l10.2-2.2 7.8-15.2 6.4 12.6 8.3-1.3zM53.7 30.1v.6h8.5v-2zm0-1.1 8.5-1.7V8.5h13.4V1.3H40.2v7.2h13.5zM19.6.5c10.8 0 17 4.7 17.8 12.2h-9.1c-.6-2.7-3.3-5-8.8-5-6.3 0-9.4 3.1-9.4 8.2s3.1 8.3 9.4 8.3c5.4 0 8.2-2.3 8.8-5.1h9.1c-.9 7.6-7 12.3-17.8 12.3C7.8 31.5 1.3 26.1 1.3 16S7.8.5 19.6.5" class="st3"/>
<path d="M94.4 23.7c1-1.4 3.3-2.1 5.1-2.7-2 .1-4.4.3-5.8-.6-1.4-1-2.1-3.3-2.7-5.1.1 2 .3 4.4-.6 5.8-1 1.4-3.3 2.1-5.1 2.7 2-.1 4.4-.3 5.8.6 1.4 1 2.1 3.3 2.7 5.1-.2-2-.4-4.4.6-5.8" class="st5"/>
</g>
<path d="M5.6 42.3c-.6 0-1-.1-1.4-.3s-.7-.4-1-.8c-.2-.3-.4-.7-.4-1.2H4c0 .2.1.5.2.6.1.2.3.3.5.4s.5.1.7.1c.3 0 .5 0 .7-.1s.4-.2.5-.4c.3 0 .4-.2.4-.4s-.1-.3-.2-.5-.3-.2-.4-.3c-.2-.1-.4-.2-.7-.2l-.8-.2c-.6-.2-1.1-.4-1.4-.7-.4-.3-.5-.7-.5-1.2 0-.4.1-.8.3-1.1s.5-.6.9-.7.9-.3 1.4-.3 1 .1 1.3.3c.4.2.7.4.9.7s.3.7.3 1.1H6.9c0-.3-.2-.5-.4-.7s-.6-.3-.9-.3-.5 0-.7.1-.3.2-.4.3-.2.3-.2.5.1.4.2.5.3.2.5.3.3.2.5.2l.7.2c.3.1.5.1.8.3s.5.2.7.4.4.4.5.6.2.5.2.8c0 .4-.1.8-.3 1.1s-.5.6-.9.7c-.6.2-1.1.2-1.6.2m11.8 0c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3c-.3-.5-.4-1.2-.4-2s.1-1.4.4-2c.3-.5.7-1 1.2-1.3s1-.4 1.7-.4c.4 0 .8.1 1.1.2s.6.3.9.5.5.5.7.8.3.7.3 1h-1.3c0-.2-.1-.4-.2-.6s-.2-.3-.4-.4c-.1-.1-.3-.2-.5-.3s-.4-.1-.6-.1c-.4 0-.7.1-1 .3s-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.8c.3.2.6.3 1 .3.2 0 .4 0 .6-.1s.4-.1.5-.3c.2-.1.3-.3.4-.4s.2-.4.2-.6h1.3c0 .3-.2.7-.3 1-.2.3-.4.6-.6.8-.3.2-.6.4-.9.5-.4.3-.8.3-1.2.3m10.3-7.2v7.1h-1.3v-7.1zm6.3 7.1v-7.1h4.6v1.1h-3.4v1.9h3.1v1.1h-3.1v2h3.4v1.1H34zm10.9 0v-7.1h1.4l2.5 3.9c.1.2.2.3.3.6s.2.4.4.7c.1.3.2.5.4.8h-.1c0-.3 0-.5-.1-.8s0-.5 0-.8v-4.3H51v7.1h-1.4l-2.2-3.5c-.1-.2-.3-.4-.4-.7-.1-.2-.3-.5-.4-.7l-.5-1h.2c0 .3 0 .7.1 1v4.9h-1.5zm15.3.1c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3c-.3-.5-.4-1.2-.4-2s.1-1.4.4-2c.3-.5.7-1 1.2-1.3s1-.4 1.7-.4c.4 0 .8.1 1.1.2s.6.3.9.5.5.5.7.8.3.7.3 1H62c0-.2-.1-.4-.2-.6s-.2-.3-.4-.4c-.1-.1-.3-.2-.5-.3s-.4-.1-.6-.1c-.4 0-.7.1-1 .3s-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.8c.3.2.6.3 1 .3.2 0 .4 0 .6-.1s.4-.1.5-.3c.2-.1.3-.3.4-.4s.2-.4.2-.6h1.3c0 .3-.2.7-.3 1-.2.3-.4.6-.6.8-.3.2-.6.4-.9.5-.5.3-.8.3-1.3.3m9.1-.1v-7.1H74v1.1h-3.4v1.9h3.1v1.1h-3.1v2H74v1.1h-4.7zm18.2 0v-7.1h2.7c.5 0 1 .1 1.4.3s.7.5.8.8.3.8.3 1.2q0 .75-.3 1.2c-.2.4-.5.6-.9.8s-.8.3-1.4.3h-1.8v-1.1H90c.3 0 .6-.1.8-.2s.4-.3.5-.5.1-.4.1-.7 0-.5-.1-.7-.2-.3-.5-.5-.5-.2-.8-.2h-1.2v6.1h-1.3zm14.3.1c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3-.4-1.2-.4-2 .1-1.4.4-2 .7-1 1.2-1.3 1.1-.4 1.7-.4 1.2.1 1.7.4.9.7 1.2 1.3c.3.5.4 1.2.4 2s-.1 1.4-.4 2c-.3.5-.7 1-1.2 1.3s-1.1.4-1.7.4m0-1.1c.4 0 .7-.1 1-.3s.5-.5.7-.9.3-.8.3-1.4c0-.5-.1-1-.3-1.4s-.4-.7-.7-.9-.6-.3-1-.3-.7.1-1 .3-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.9.6.3 1 .3m9.4 1v-7.1h2.7c.5 0 1 .1 1.4.3s.7.5.8.8.3.7.3 1.2-.1.8-.3 1.2c-.2.3-.5.6-.9.8s-.8.3-1.4.3h-1.9v-1.1h1.7c.3 0 .6 0 .8-.1s.4-.2.5-.4.1-.4.1-.6c0-.3 0-.5-.1-.7s-.3-.3-.5-.4-.5-.1-.8-.1h-1.2v6.1h-1.2zm4 0-1.7-3.2h1.4l1.8 3.2zm6.9-6v-1.1h5.7v1.1h-2.2v6.1h-1.3v-6.1zm10.4 6 2.5-7.1h1.6l2.5 7.1h-1.4l-1.3-3.7c-.1-.4-.3-.8-.4-1.3s-.3-1-.4-1.6h.3c-.2.6-.3 1.2-.4 1.7s-.3.9-.4 1.3l-1.2 3.7h-1.4zm1.5-1.7v-1h3.8v1zm11 1.7v-7.1h1.3v6.1h3.1v1.1H145z" class="st3"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" id="Ebene_1" x="0" y="0" viewBox="0 0 153 43.9">
<style>
.st3{fill:#00004a}
</style>
<g id="Logotype_00000134236662009793237430000016451412871672361145_">
<path d="m100 22.3 4.3 8.4h9.2l-5.3-10.2zm22.4-6.2V16c0-5.2 2.9-8.2 10.2-8.2 5.7 0 8.7 1.9 9.8 5.2l8.8-1.4c-1.8-7-8.2-11-18.6-11-12.4 0-19.1 5.7-19.1 15.5 0 .5 0 1.1.1 1.6zm20.1-2.7c.2.8.3 1.7.3 2.6 0 5.1-3 8.3-10.2 8.3-6.4 0-9.5-2.5-10.1-6.7l-8.7 1.8c1.4 7.7 7.9 12.2 18.8 12.2 12.4 0 19.1-5.8 19.1-15.6 0-1.6-.2-3.1-.5-4.4zM73.4 26.9l-2 3.8h9.2l2.7-5.4zm25-25.6H86.6L74.5 24.7l10.2-2.2 7.8-15.2 6.4 12.6 8.3-1.3zM53.7 30.1v.6h8.5v-2zm0-1.1 8.5-1.7V8.5h13.4V1.3H40.2v7.2h13.5zM19.6.5c10.8 0 17 4.7 17.8 12.2h-9.1c-.6-2.7-3.3-5-8.8-5-6.3 0-9.4 3.1-9.4 8.2s3.1 8.3 9.4 8.3c5.4 0 8.2-2.3 8.8-5.1h9.1c-.9 7.6-7 12.3-17.8 12.3C7.8 31.5 1.3 26.1 1.3 16S7.8.5 19.6.5" class="st3"/>
<path d="M94.4 23.7c1-1.4 3.3-2.1 5.1-2.7-2 .1-4.4.3-5.8-.6-1.4-1-2.1-3.3-2.7-5.1.1 2 .3 4.4-.6 5.8-1 1.4-3.3 2.1-5.1 2.7 2-.1 4.4-.3 5.8.6 1.4 1 2.1 3.3 2.7 5.1-.2-2-.4-4.4.6-5.8" class="st3"/>
</g>
<path d="M5.6 42.3c-.6 0-1-.1-1.4-.3s-.7-.4-1-.8c-.2-.3-.4-.7-.4-1.2H4c0 .2.1.5.2.6.1.2.3.3.5.4s.5.1.7.1c.3 0 .5 0 .7-.1s.4-.2.5-.4c.3 0 .4-.2.4-.4s-.1-.3-.2-.5-.3-.2-.4-.3c-.2-.1-.4-.2-.7-.2l-.8-.2c-.6-.2-1.1-.4-1.4-.7-.4-.3-.5-.7-.5-1.2 0-.4.1-.8.3-1.1s.5-.6.9-.7.9-.3 1.4-.3 1 .1 1.3.3c.4.2.7.4.9.7s.3.7.3 1.1H6.9c0-.3-.2-.5-.4-.7s-.6-.3-.9-.3-.5 0-.7.1-.3.2-.4.3-.2.3-.2.5.1.4.2.5.3.2.5.3.3.2.5.2l.7.2c.3.1.5.1.8.3s.5.2.7.4.4.4.5.6.2.5.2.8c0 .4-.1.8-.3 1.1s-.5.6-.9.7c-.6.2-1.1.2-1.6.2m11.8 0c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3c-.3-.5-.4-1.2-.4-2s.1-1.4.4-2c.3-.5.7-1 1.2-1.3s1-.4 1.7-.4c.4 0 .8.1 1.1.2s.6.3.9.5.5.5.7.8.3.7.3 1h-1.3c0-.2-.1-.4-.2-.6s-.2-.3-.4-.4c-.1-.1-.3-.2-.5-.3s-.4-.1-.6-.1c-.4 0-.7.1-1 .3s-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.8c.3.2.6.3 1 .3.2 0 .4 0 .6-.1s.4-.1.5-.3c.2-.1.3-.3.4-.4s.2-.4.2-.6h1.3c0 .3-.2.7-.3 1-.2.3-.4.6-.6.8-.3.2-.6.4-.9.5-.4.3-.8.3-1.2.3m10.3-7.2v7.1h-1.3v-7.1zm6.3 7.1v-7.1h4.6v1.1h-3.4v1.9h3.1v1.1h-3.1v2h3.4v1.1H34zm10.9 0v-7.1h1.4l2.5 3.9c.1.2.2.3.3.6s.2.4.4.7c.1.3.2.5.4.8h-.1c0-.3 0-.5-.1-.8s0-.5 0-.8v-4.3H51v7.1h-1.4l-2.2-3.5c-.1-.2-.3-.4-.4-.7-.1-.2-.3-.5-.4-.7l-.5-1h.2c0 .3 0 .7.1 1v4.9h-1.5zm15.3.1c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3c-.3-.5-.4-1.2-.4-2s.1-1.4.4-2c.3-.5.7-1 1.2-1.3s1-.4 1.7-.4c.4 0 .8.1 1.1.2s.6.3.9.5.5.5.7.8.3.7.3 1H62c0-.2-.1-.4-.2-.6s-.2-.3-.4-.4c-.1-.1-.3-.2-.5-.3s-.4-.1-.6-.1c-.4 0-.7.1-1 .3s-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.8c.3.2.6.3 1 .3.2 0 .4 0 .6-.1s.4-.1.5-.3c.2-.1.3-.3.4-.4s.2-.4.2-.6h1.3c0 .3-.2.7-.3 1-.2.3-.4.6-.6.8-.3.2-.6.4-.9.5-.5.3-.8.3-1.3.3m9.1-.1v-7.1H74v1.1h-3.4v1.9h3.1v1.1h-3.1v2H74v1.1h-4.7zm18.2 0v-7.1h2.7c.5 0 1 .1 1.4.3s.7.5.8.8.3.8.3 1.2q0 .75-.3 1.2c-.2.4-.5.6-.9.8s-.8.3-1.4.3h-1.8v-1.1H90c.3 0 .6-.1.8-.2s.4-.3.5-.5.1-.4.1-.7 0-.5-.1-.7-.2-.3-.5-.5-.5-.2-.8-.2h-1.2v6.1h-1.3zm14.3.1c-.6 0-1.2-.1-1.7-.4s-.9-.7-1.2-1.3-.4-1.2-.4-2 .1-1.4.4-2 .7-1 1.2-1.3 1.1-.4 1.7-.4 1.2.1 1.7.4.9.7 1.2 1.3c.3.5.4 1.2.4 2s-.1 1.4-.4 2c-.3.5-.7 1-1.2 1.3s-1.1.4-1.7.4m0-1.1c.4 0 .7-.1 1-.3s.5-.5.7-.9.3-.8.3-1.4c0-.5-.1-1-.3-1.4s-.4-.7-.7-.9-.6-.3-1-.3-.7.1-1 .3-.5.5-.7.9-.3.8-.3 1.4c0 .5.1 1 .3 1.4s.4.7.7.9.6.3 1 .3m9.4 1v-7.1h2.7c.5 0 1 .1 1.4.3s.7.5.8.8.3.7.3 1.2-.1.8-.3 1.2c-.2.3-.5.6-.9.8s-.8.3-1.4.3h-1.9v-1.1h1.7c.3 0 .6 0 .8-.1s.4-.2.5-.4.1-.4.1-.6c0-.3 0-.5-.1-.7s-.3-.3-.5-.4-.5-.1-.8-.1h-1.2v6.1h-1.2zm4 0-1.7-3.2h1.4l1.8 3.2zm6.9-6v-1.1h5.7v1.1h-2.2v6.1h-1.3v-6.1zm10.4 6 2.5-7.1h1.6l2.5 7.1h-1.4l-1.3-3.7c-.1-.4-.3-.8-.4-1.3s-.3-1-.4-1.6h.3c-.2.6-.3 1.2-.4 1.7s-.3.9-.4 1.3l-1.2 3.7h-1.4zm1.5-1.7v-1h3.8v1zm11 1.7v-7.1h1.3v6.1h3.1v1.1H145z" class="st3"/>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

+151
View File
@@ -0,0 +1,151 @@
/*
* CTAO portal mockup: header behaviour
*
* 1. The sticky header turns solid Galaxy Blue after 40px of scroll.
* 2. Submenus: every button with data-submenu-toggle (Example, language)
* toggles the element named in its aria-controls. One open at a time.
* 3. The search button toggles the search panel.
* 4. The burger button toggles the main nav on small screens.
* 5. Escape and clicks outside the header close open menus.
*
* Review helper: index.html#open=<id> opens a panel on load,
* for example #open=submenu-example, #open=submenu-lang,
* #open=search-panel or #open=site-nav.
*/
(function () {
"use strict";
// Keep in sync with the small-screen media query in css/styles.css
const MOBILE_QUERY = window.matchMedia("(max-width: 1079px)");
const header = document.getElementById("site-header");
const nav = document.getElementById("site-nav");
const searchPanel = document.getElementById("search-panel");
const searchButton = header.querySelector('[aria-controls="search-panel"]');
const menuButton = header.querySelector('[aria-controls="site-nav"]');
const submenuButtons = Array.from(
header.querySelectorAll("[data-submenu-toggle]"),
);
/* 1. Scrolled state */
function updateScrolled() {
header.classList.toggle("is-scrolled", window.scrollY > 40);
}
window.addEventListener("scroll", updateScrolled, { passive: true });
updateScrolled();
/* Keeps the header solid while a full-width panel is open */
function updateHeaderState() {
const panelOpen =
!searchPanel.hidden || header.classList.contains("is-nav-open");
header.classList.toggle("is-open", panelOpen);
}
/* 2. Submenus */
function setSubmenu(button, open) {
const submenu = document.getElementById(
button.getAttribute("aria-controls"),
);
button.setAttribute("aria-expanded", String(open));
submenu.hidden = !open;
}
function closeSubmenus(except) {
submenuButtons.forEach(function (button) {
if (button !== except) setSubmenu(button, false);
});
}
submenuButtons.forEach(function (button) {
button.addEventListener("click", function () {
const open = button.getAttribute("aria-expanded") !== "true";
closeSubmenus(button);
setSearch(false);
setSubmenu(button, open);
});
});
/* 3. Search */
function setSearch(open) {
searchButton.setAttribute("aria-expanded", String(open));
searchPanel.hidden = !open;
updateHeaderState();
if (open) searchPanel.querySelector("input").focus();
}
searchButton.addEventListener("click", function () {
const open = searchPanel.hidden;
closeSubmenus();
setMobileNav(false);
setSearch(open);
});
/* 4. Mobile nav */
function setMobileNav(open) {
menuButton.setAttribute("aria-expanded", String(open));
header.classList.toggle("is-nav-open", open);
document.body.classList.toggle("no-scroll", open);
updateHeaderState();
}
menuButton.addEventListener("click", function () {
const open = !header.classList.contains("is-nav-open");
closeSubmenus();
setSearch(false);
setMobileNav(open);
});
/* 5. Dismiss */
function closeAll() {
closeSubmenus();
setSearch(false);
setMobileNav(false);
}
document.addEventListener("click", function (event) {
if (!header.contains(event.target)) closeAll();
});
document.addEventListener("keydown", function (event) {
if (event.key !== "Escape") return;
const openSubmenuButton = submenuButtons.find(function (button) {
return button.getAttribute("aria-expanded") === "true";
});
if (openSubmenuButton) {
setSubmenu(openSubmenuButton, false);
openSubmenuButton.focus();
} else if (!searchPanel.hidden) {
setSearch(false);
searchButton.focus();
} else if (header.classList.contains("is-nav-open")) {
setMobileNav(false);
menuButton.focus();
}
});
/* Switching between desktop and small-screen layouts starts clean */
MOBILE_QUERY.addEventListener("change", closeAll);
/* Review helpers */
const openMatch = location.hash.match(/^#open=([\w-]+)$/);
if (openMatch) {
const target = header.querySelector(
'[aria-controls="' + openMatch[1] + '"]',
);
if (target) {
if (MOBILE_QUERY.matches && nav.contains(target)) menuButton.click();
target.click();
}
}
if (location.hash === "#scrolled") window.scrollTo(0, 420);
})();
-164
View File
@@ -1,164 +0,0 @@
// Shared client-side news search over /search.json (no dependencies, no modules).
// Used by the header search (field + mobile reveal) and /search (full results).
// Focused-empty state per Baymard autocomplete guidance: recent searches (local
// only — localStorage, no network) plus the newest articles, never a void.
(function () {
const RKEY = 'ctao-recent-searches';
function recents() {
try { return JSON.parse(localStorage.getItem(RKEY) || '[]'); } catch { return []; }
}
function remember(q) {
q = (q || '').trim();
if (q.length < 2) return;
try {
const r = [q, ...recents().filter((x) => x.toLowerCase() !== q.toLowerCase())].slice(0, 4);
localStorage.setItem(RKEY, JSON.stringify(r));
} catch { /* private mode — recents are a nicety */ }
}
window.newsSearch = function (opts) {
const input = document.getElementById(opts.input);
const list = document.getElementById(opts.list);
const status = document.getElementById(opts.status);
// Null-safe: a page may render only some search surfaces — bail silently
// (returning a no-op) so one missing input can't kill later inits.
if (!input || !list || !status) return function () {};
let index = null;
let timer;
async function ensureIndex() {
if (index) return index;
try {
index = await (await fetch('/search.json')).json();
} catch {
// Network hiccup: say so instead of failing silently; index stays
// null so the next keystroke retries.
status.textContent = 'Search is unavailable right now.';
return null;
}
return index;
}
// First title match wrapped in <mark> (Starlight search pattern) — built
// from text nodes, never innerHTML.
function highlight(text, q) {
const i = text.toLowerCase().indexOf(q);
if (i < 0) return [text];
const mark = document.createElement('mark');
mark.textContent = text.slice(i, i + q.length);
return [text.slice(0, i), mark, text.slice(i + q.length)];
}
function label(text) {
const li = document.createElement('li');
li.className = 'suggest-label';
li.setAttribute('aria-hidden', 'true'); // the status line announces it
li.textContent = text;
return li;
}
function row(children, href, q) {
const li = document.createElement('li');
const a = document.createElement('a');
a.href = href;
if (q) a.dataset.q = q;
a.append(...children);
li.append(a);
return li;
}
// Dropdowns close with a "Press Enter for all results" row — a real list
// element (was a CSS ::after), aria-hidden like the group labels so
// screen-reader exposure stays consistent (the status line announces).
function hint() {
if (!opts.dismiss || !list.firstChild) return;
const li = document.createElement('li');
li.className = 'suggest-hint';
li.setAttribute('aria-hidden', 'true');
li.textContent = 'Press Enter for all results';
list.append(li);
}
// Focused-empty state: up to 4 recent queries, topped up to 5 rows with
// the newest articles from the same index.
async function idle() {
if (input.value.trim().length >= 2) return;
// Build off-DOM, swap atomically — clearing before the await shrinks the
// page mid-flight and the browser clamps the scroll position ("jumps up").
const frag = document.createDocumentFragment();
const r = recents();
if (r.length) {
frag.append(label('Recent'));
for (const term of r) frag.append(row([term], '/search?q=' + encodeURIComponent(term), term));
}
const idx = await ensureIndex();
if (!idx) return;
const fresh = idx.slice(0, Math.max(1, 5 - r.length));
if (input.value.trim().length >= 2) return; // typed meanwhile — run() owns the list
frag.append(label('Latest news'));
for (const p of fresh) {
const li = row([p.title], '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
li.append(small);
frag.append(li);
}
list.replaceChildren(frag);
hint();
status.textContent = opts.hint || (r.length ? 'Showing recent searches and latest news' : 'Showing latest news');
}
async function run() {
const q = input.value.trim().toLowerCase();
if (q.length < 2) { list.replaceChildren(); idle(); return; }
const idx = await ensureIndex();
if (!idx) { list.replaceChildren(); return; }
const hits = idx
.filter((p) => (p.title + ' ' + p.description + ' ' + p.category).toLowerCase().includes(q))
.slice(0, opts.limit);
const unit = opts.unit || 'result';
status.textContent = hits.length
? hits.length + ' ' + unit + (hits.length === 1 ? '' : 's') + ' for “' + input.value.trim() + '”'
: 'No results for “' + input.value.trim() + '”. Try a shorter or different term.';
// Off-DOM build + atomic swap (no transient page-height collapse)
const frag = document.createDocumentFragment();
for (const p of hits) {
const li = row(highlight(p.title, q), '/news/' + encodeURIComponent(p.slug));
const small = document.createElement('small');
small.textContent = p.date;
li.append(small);
frag.append(li);
}
// Dropdown only: an empty list would collapse (.suggest:empty) and the
// panel would just vanish — show the no-results line sighted users too
// (the status line above is sr-only in the header; /search shows a
// visible notice instead).
if (!hits.length && opts.dismiss) {
const li = document.createElement('li');
li.className = 'suggest-hint';
li.setAttribute('aria-hidden', 'true'); // the status line announces it
li.textContent = 'No results for “' + input.value.trim() + '”';
frag.append(li);
}
list.replaceChildren(frag);
hint();
}
input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(run, 150); });
input.addEventListener('focus', () => { if (input.value.trim().length < 2) idle(); });
// Remember successful queries: Enter (form submit) and suggestion clicks
if (input.form) input.form.addEventListener('submit', () => remember(input.value));
list.addEventListener('click', (e) => {
const a = e.target.closest && e.target.closest('a');
remember((a && a.dataset.q) || input.value);
});
// Dropdown dismiss (opt-in — /search keeps its results list). The
// suggestion list is a NON-MODAL popup (combobox-style; plain
// Tab-reachable links, no roving arrow-key focus): interacting outside
// the field+list closes it but the interaction is NOT swallowed —
// unlike the modal header panels, which scrim-dismiss in Base.astro.
if (opts.dismiss) {
const box = input.closest('form') || input.parentElement;
const hide = () => { clearTimeout(timer); list.replaceChildren(); status.textContent = ''; };
document.addEventListener('pointerdown', (e) => { if (list.firstChild && !box.contains(e.target)) hide(); });
// relatedTarget may be null mid-click on our own links — pointerdown covers that path
box.addEventListener('focusout', (e) => { if (e.relatedTarget && !box.contains(e.relatedTarget)) hide(); });
input.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && list.firstChild) { hide(); e.stopPropagation(); } // panel Escape stays a second step
});
}
return run; // callers may run() immediately (e.g. /search?q=…)
};
})();
@@ -0,0 +1,12 @@
---
title: "Consectetur adipiscing"
introduction: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
---
## Consectetur adipiscing
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
- Lorem ipsum dolor sit amet.
- Consectetur adipiscing elit.
- Sed do eiusmod tempor incididunt.
+19
View File
@@ -0,0 +1,19 @@
---
title: "Dolor sit amet"
introduction: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
image: "/uploads/hero.jpg"
imageAlt: "Telescope beneath a star-filled night sky"
imageCaption: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
---
## Dolor sit amet
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam.
Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
+25
View File
@@ -0,0 +1,25 @@
---
title: "Lorem Ipsum Dolor Sit Amet Consectetur"
introduction: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
image: "/uploads/hero.jpg"
imageAlt: "Telescope beneath a star-filled night sky"
imageCaption: "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
buttonLabel: "Lorem ipsum"
buttonUrl: "/example/dolor-sit-amet/"
---
## Lorem Ipsum Dolor
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat ([lorem ipsum](/example/dolor-sit-amet/)). Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.
Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo [inventore veritatis](/example/consectetur-adipiscing/) et quasi architecto beatae vitae dicta sunt explicabo. Nemo enim ipsam voluptatem quia voluptas sit aspernatur aut odit aut fugit, sed quia [consequuntur magni](/example/sed-do-eiusmod/) dolores eos qui ratione voluptatem sequi nesciunt.
> Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.
## Consectetur Adipiscing Elit
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam.
Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
+16
View File
@@ -0,0 +1,16 @@
---
title: "Sed do eiusmod"
introduction: "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat."
---
## Sed do eiusmod
Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.
Neque porro quisquam est, qui dolorem ipsum quia dolor sit amet, consectetur, adipisci velit, sed quia non numquam eius modi tempora incidunt ut labore et dolore magnam aliquam quaerat voluptatem. Ut enim ad minima veniam, quis nostrum exercitationem ullam corporis suscipit laboriosam.
Quis autem vel eum iure reprehenderit qui in ea voluptate velit esse quam nihil molestiae consequatur, vel illum qui dolorem eum fugiat quo voluptas nulla pariatur. At vero eos et accusamus et iusto odio dignissimos ducimus qui blanditiis praesentium voluptatum deleniti atque corrupti.
Nam libero tempore, cum soluta nobis est eligendi optio cumque nihil impedit quo minus id quod maxime placeat facere possimus, omnis voluptas assumenda est, omnis dolor repellendus.
Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

+2 -1
View File
@@ -4,8 +4,9 @@
// URLs, mailto:, data: and pure-#fragment links are out of scope.
import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
const DIST = new URL('../dist', import.meta.url).pathname;
const DIST = fileURLToPath(new URL('../dist', import.meta.url));
if (!existsSync(DIST)) { console.error('dist/ not found — run `npm run build` first'); process.exit(1); }
const htmlFiles = [];
+34
View File
@@ -0,0 +1,34 @@
// Copy the four editable files into Astro's ignored content directory.
// --ensure seeds only a fresh checkout; an incomplete existing set fails loudly.
import { cpSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../', import.meta.url));
const examples = JSON.parse(readFileSync(join(root, 'src/data/examples.json'), 'utf8'));
const ensure = process.argv.includes('--ensure');
const source = resolve(process.argv[2] && !ensure ? process.argv[2] : join(root, 'sample-content'));
const pages = join(root, 'src/content/pages');
const uploads = join(root, 'public/uploads');
function validate(directory) {
for (const { slug } of examples) {
if (!existsSync(join(directory, `${slug}.md`))) {
throw new Error(`Missing ${join(directory, `${slug}.md`)}. Run npm run content:setup or npm run content:sync -- <content-clone>.`);
}
}
}
if (ensure && existsSync(pages)) {
validate(pages);
} else {
validate(join(source, 'pages'));
if (!existsSync(join(source, 'uploads'))) throw new Error(`Missing uploads directory in ${source}`);
mkdirSync(pages, { recursive: true });
mkdirSync(uploads, { recursive: true });
for (const { slug } of examples) {
cpSync(join(source, 'pages', `${slug}.md`), join(pages, `${slug}.md`));
}
cpSync(join(source, 'uploads'), uploads, { recursive: true });
console.log(`Example content copied from ${source}`);
}
+2 -12
View File
@@ -1,17 +1,7 @@
#!/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.
# Cross-platform implementation is shared with npm run content:sync.
set -euo pipefail
cd "$(dirname "$0")/.."
src="${1:-../ctao-content}"
for d in news pages uploads; do
[ -d "$src/$d" ] || { echo "content clone incomplete: $src/$d missing (git clone <gitea>/ctao/content.git first)"; exit 1; }
done
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"
node scripts/content.mjs "${1:-../ctao-content}"
+19
View File
@@ -0,0 +1,19 @@
// Prepare a disposable content repository for Sveltia's local folder picker.
import { cpSync, existsSync, mkdirSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
const root = fileURLToPath(new URL('../', import.meta.url));
const local = join(root, '.tmp/content');
if (!existsSync(local)) {
mkdirSync(local, { recursive: true });
cpSync(join(root, 'sample-content/pages'), join(local, 'pages'), { recursive: true });
cpSync(join(root, 'sample-content/uploads'), join(local, 'uploads'), { recursive: true });
}
if (!existsSync(join(local, '.git'))) {
execFileSync('git', ['init', '--quiet', '--initial-branch=main', local], { stdio: 'inherit' });
}
execFileSync(process.execPath, [join(root, 'scripts/content.mjs'), local], { stdio: 'inherit' });
console.log(`In Sveltia, choose Work with Local Repository and select: ${local}`);
console.log('After saving, run: npm run content:sync -- .tmp/content');
+13
View File
@@ -0,0 +1,13 @@
// Derive editor body styles from the mockup CSS so the preview cannot drift.
import { readFileSync, writeFileSync } from 'node:fs';
const css = readFileSync(new URL('../src/styles/global.css', import.meta.url), 'utf8');
const base = css.slice(0, css.indexOf('/* Utilities'));
const prose = css.slice(css.indexOf('/* Prose'), css.indexOf('/* Footer'));
const markdown = css.slice(css.indexOf('.prose { overflow-wrap:'), css.indexOf('.submenu a[aria-current="page"]'));
const preview = (base + prose + markdown).replace(/\.prose\b/g, 'body');
writeFileSync(new URL('../public/admin/preview.css', import.meta.url),
'/* Generated by scripts/preview-styles.mjs; edit src/styles/global.css. */\n' + preview + `
body { display: block; min-height: 0; max-width: 64rem; margin: 0 auto; padding: 1.5rem; }
h1 { color: var(--galaxy-blue); font-family: var(--font-head); font-weight: 500; line-height: 1.05; }
`);
-16
View File
@@ -1,16 +0,0 @@
---
// The one news card — used by the home page and the /news archive.
// `featured` (archive page 1, first item) switches the wide lead variant and
// eager-loads its cover. Cover alt stays empty by design: the image is
// decorative next to the always-visible title.
import { fmt, iso, readMin } from '../lib/news.js';
const { post, featured = false } = Astro.props;
---
<article class={featured ? 'card card--featured' : 'card'}>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" loading={featured ? 'eager' : 'lazy'} />}
<div class="body">
<h3><a href={`/news/${post.id}`}>{post.data.title}</a></h3>
<p class="desc">{post.data.description}</p>
<div class="meta"><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg><time datetime={iso(post.data.date)}>{fmt(post.data.date)}</time></span><span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin(post)} min read</span></div>
</div>
</article>
+17
View File
@@ -0,0 +1,17 @@
---
interface Props { title: string; introduction?: string; example?: boolean; hasImage?: boolean }
const { title, introduction, example = false, hasImage = false } = Astro.props;
---
<section class:list={['page-hero', { 'page-hero--compact': !hasImage }]}>
<div class="container">
<nav class="breadcrumb" aria-label="Breadcrumb">
<ol>
{Astro.url.pathname !== '/' && <li><a href="/">Home</a></li>}
{example && <li>Example</li>}
<li aria-current="page">{title}</li>
</ol>
</nav>
<h1 class="page-hero__title">{title}</h1>
{introduction && <p class="page-hero__lead">{introduction}</p>}
</div>
</section>
+18 -33
View File
@@ -1,41 +1,26 @@
import { defineCollection } from 'astro:content';
import { z } from 'astro/zod';
import { glob } from 'astro/loaders';
import examples from './data/examples.json';
// News/articles live as Markdown files in src/content/news/*.md.
// This is exactly what Sveltia CMS edits — the CMS writes these files, Astro
// renders them to static HTML at build time.
const news = defineCollection({
// Top-level only ('*.md', not '**'): entry ids feed a non-rest [slug]
// route, and an id from a subfolder would contain '/' and break it.
loader: glob({ pattern: '*.md', base: './src/content/news' }),
schema: z.object({
title: z.string(),
description: z.string(),
date: z.coerce.date(),
category: z.string().default('news'),
author: z.string().default('CTAO'),
// Public-path string, e.g. "/uploads/foo.jpg" — media lives in the
// content repo's uploads/, overlaid to public/uploads at build time,
// which Astro's image() helper can't validate (src/ only).
cover: z.string().optional(),
// BCP-47 tag when an article is not in English (e.g. "pl") — set as lang
// on the <article> element so screen readers pick the right voice.
lang: z.string().optional(),
draft: z.boolean().default(false),
}),
});
// Static portal pages (privacy, disclaimer, contact…) — separate collection
// because the content type differs (no dates/covers/categories), and editors
// get a distinct "Pages" section in the CMS.
const destination = z.string().refine(
(value) => !value || /^(\/(?!\/)|https?:\/\/|mailto:|#)/.test(value),
'Use a site path, https:// URL, mailto: address, or fragment.',
);
const pages = defineCollection({
// Top-level only, same reason as news: the [slug] route is non-rest.
loader: glob({ pattern: '*.md', base: './src/content/pages' }),
// Only these four files can become public content pages.
loader: glob({
pattern: `{${examples.map(({ slug }) => slug).join(',')}}.md`,
base: './src/content/pages',
}),
schema: z.object({
title: z.string(),
description: z.string().optional(),
title: z.string().min(1),
introduction: z.string().optional(),
image: z.string().optional(),
imageAlt: z.string().optional(),
imageCaption: z.string().optional(),
buttonLabel: z.string().optional(),
buttonUrl: destination.optional(),
}),
});
export const collections = { news, pages };
export const collections = { pages };
+18
View File
@@ -0,0 +1,18 @@
[
{
"slug": "lorem-ipsum",
"label": "Lorem ipsum"
},
{
"slug": "dolor-sit-amet",
"label": "Dolor sit amet"
},
{
"slug": "consectetur-adipiscing",
"label": "Consectetur adipiscing"
},
{
"slug": "sed-do-eiusmod",
"label": "Sed do eiusmod"
}
]
+42
View File
@@ -0,0 +1,42 @@
[
{
"slug": "news",
"title": "News"
},
{
"slug": "data",
"title": "Data"
},
{
"slug": "proposals",
"title": "Proposals"
},
{
"slug": "dashboard",
"title": "Dashboard"
},
{
"slug": "login",
"title": "Sign in"
},
{
"slug": "search",
"title": "Search"
},
{
"slug": "support",
"title": "Support"
},
{
"slug": "pages/privacy",
"title": "Privacy"
},
{
"slug": "pages/disclaimer",
"title": "Disclaimer"
},
{
"slug": "pages/contact",
"title": "Contact"
}
]
+99 -171
View File
@@ -1,191 +1,119 @@
---
import '../styles/global.css';
// `ambient` opts a page into the whole-page drifting brand wash (home only)
// `type`/`image` feed the social meta: articles pass type="article" + their cover.
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal demo', ambient = false, type = 'website', image } = Astro.props;
// Absolute URLs for canonical/OG/RSS (head pattern from the official Astro
// blog template). `site` comes from astro.config.mjs and is always set.
const site = Astro.site;
const canonical = new URL(Astro.url.pathname, site);
// SVG covers never reach og:image — link-preview crawlers (Slack/Teams/
// LinkedIn) don't render SVG, so those articles fall back to the brand card.
const ogImage = new URL(image && !image.endsWith('.svg') ? image : '/brand/og-card.jpg', site);
const path = Astro.url.pathname;
const isActive = (href: string) => (href === '/' ? path === '/' : path.startsWith(href));
// Navigation = services from the Science Portal spec (REQUIREMENTS.md §2/§5).
// 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 = [
{ href: '/news', label: 'News' },
{ href: 'https://padc-ctao-data-explorer.obspm.fr/', label: 'Data', ext: true },
{ href: '/proposals', label: 'Proposals' },
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/support', label: 'Support' },
];
import examples from '../data/examples.json';
interface Props { title?: string; description?: string }
const { title = 'CTAO Science Portal', description = 'CTAO Science Portal' } = Astro.props;
const path = Astro.url.pathname.replace(/\/?$/, '/');
const canonical = new URL(Astro.url.pathname, Astro.site);
---
<!doctype html>
{/* Always "en": the chrome (nav/footer) is English; a non-English article
sets lang on its <article> element instead ([slug].astro). */}
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<title>{title === 'CTAO Science Portal' ? title : `${title} – CTAO`}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{/* Social cards (Open Graph + Twitter) — article cover when present, brand card otherwise */}
<meta property="og:site_name" content="CTAO Science Portal" />
<meta property="og:title" content={title} />
<meta property="og:description" content={description} />
<meta property="og:type" content={type} />
<meta property="og:url" content={canonical} />
<meta property="og:image" content={ogImage} />
<meta name="twitter:card" content="summary_large_image" />
<link rel="alternate" type="application/rss+xml" title="CTAO Science Portal News" href={new URL('/rss.xml', site)} />
<link rel="sitemap" href="/sitemap.xml" />
<link rel="icon" href="/brand/CTAO_Logo_positive.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#00004a" />
{/* The home hero photo is a CSS background, which browsers discover late —
preloading it moves the page's LCP element to the front of the queue. */}
{path === '/' && <link rel="preload" as="image" href="/brand/hero.jpg" fetchpriority="high" />}
{/* Brand typography (BRAND D.3): Inter (body) + Space Grotesk (headlines).
Both ship inlined as data: URIs in the render-blocking stylesheet —
no font request, no swap (see global.css @font-face). */}
<link rel="canonical" href={canonical} />
<link rel="icon" href="/assets/logo-sp-positive.svg" type="image/svg+xml" />
<link rel="sitemap" href="/sitemap.xml" />
</head>
<body class={ambient ? 'has-ambient' : undefined}>
<a class="skip" href="#main">Skip to content</a>
<header class="site-header">
<div class="container nav">
<a class="brand" href="/" aria-label="CTAO Science Portal, home">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<span class="brand-sub">Science&nbsp;Portal</span>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header" id="site-header">
<div class="site-header__inner">
<a class="site-header__logo" href="/">
<img src="/assets/logo-sp-negative.svg" alt="CTAO – Home" width="153" height="43.9">
</a>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="navlink external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a class="navlink" href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
<nav class="site-nav" id="site-nav" aria-label="Main">
<ul class="site-nav__list">
<li class="site-nav__item">
<a class="site-nav__link" href="/news/" aria-current={path === "/news/" ? "page" : undefined}><span class="site-nav__label">News</span></a>
</li>
<li class="site-nav__item">
<a class="site-nav__link" href="/data/" aria-current={path === "/data/" ? "page" : undefined}><span class="site-nav__label">Data</span></a>
</li>
<li class="site-nav__item">
<a class="site-nav__link" href="/proposals/" aria-current={path === "/proposals/" ? "page" : undefined}><span class="site-nav__label">Proposals</span></a>
</li>
<li class="site-nav__item">
<a class="site-nav__link" href="/dashboard/" aria-current={path === "/dashboard/" ? "page" : undefined}><span class="site-nav__label">Dashboard</span></a>
</li>
<li class="site-nav__item has-submenu">
<button class="site-nav__link site-nav__toggle" type="button" data-submenu-toggle aria-expanded="false" aria-controls="submenu-example">
<span class="site-nav__label">Example</span>
<svg class="chevron" width="10" height="6" viewBox="0 0 10 6" aria-hidden="true" focusable="false"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<ul class="submenu site-nav__submenu" id="submenu-example" hidden>
{examples.map(({ slug, label }) => <li><a href={`/example/${slug}/`} aria-current={path === `/example/${slug}/` ? 'page' : undefined}>{label}</a></li>)}
</ul>
</li>
<li class="site-nav__item site-nav__item--signin">
<a class="site-nav__link site-nav__signin" href="/login/">Sign in</a>
</li>
</ul>
</nav>
<span class="spacer"></span>
{/* Site search. Desktop ≥1025px: a VISIBLE quiet field — NN/g's
magnifying-glass-icon research: hiding search behind an icon
measurably reduces discoverability and usage; a visible input is
recommended when search matters (200+ articles, spec-required).
Plain GET form (works without JS); suggestions via search-client.js. */}
<form class="nav-search" role="search" aria-label="Site search" action="/search" method="get">
<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>
<div class="site-header__tools">
<a class="pill site-header__signin" href="/login/">Sign in</a>
<div class="lang has-submenu">
<button class="pill lang__toggle" type="button" data-submenu-toggle aria-expanded="false" aria-controls="submenu-lang">
<span class="visually-hidden">Language:</span>
<span>EN</span>
<svg class="chevron" width="10" height="6" viewBox="0 0 10 6" aria-hidden="true" focusable="false"><path d="M1 1l4 4 4-4" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
</button>
<ul class="submenu submenu--end" id="submenu-lang" hidden>
<li><a href={Astro.url.pathname} lang="en" aria-current="true">English</a></li>
<li><span class="submenu__unavailable" lang="de" aria-disabled="true">Deutsch</span></li>
<li><span class="submenu__unavailable" lang="es" aria-disabled="true">Español</span></li>
<li><span class="submenu__unavailable" lang="fr" aria-disabled="true">Français</span></li>
<li><span class="submenu__unavailable" lang="it" aria-disabled="true">Italiano</span></li>
<li><span class="submenu__unavailable" lang="ja" aria-disabled="true">日本語</span></li>
</ul>
</div>
<button class="icon-btn" type="button" aria-label="Search" aria-expanded="false" aria-controls="search-panel">
<svg class="icon-btn__open" width="16" height="16" viewBox="0 0 16 15" aria-hidden="true" focusable="false"><path d="M5.917.292a5.417 5.417 0 0 1 4.116 8.942l.225.225h.659l4.166 4.166-1.25 1.25-4.166-4.166v-.659l-.225-.225A5.417 5.417 0 1 1 5.917.292Zm0 1.667a3.75 3.75 0 1 0 0 7.5 3.75 3.75 0 0 0 0-7.5Z" fill="currentColor"/></svg>
<svg class="icon-btn__close" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3.05 1.636 8 6.586l4.95-4.95 1.414 1.414L9.414 8l4.95 4.95-1.414 1.414L8 9.414l-4.95 4.95-1.414-1.414L6.586 8l-4.95-4.95L3.05 1.636Z" fill="currentColor"/></svg>
</button>
<button class="icon-btn icon-btn--menu" type="button" aria-label="Menu" aria-expanded="false" aria-controls="site-nav">
<svg class="icon-btn__open" width="21" height="20" viewBox="0 0 21 20" aria-hidden="true" focusable="false"><path d="M3.415 6h15v1.667h-15V6Zm0 4.167h15v1.666h-15v-1.666Zm0 4.166h15V16h-15v-1.667Z" fill="currentColor"/></svg>
<svg class="icon-btn__close" width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false"><path d="M3.05 1.636 8 6.586l4.95-4.95 1.414 1.414L9.414 8l4.95 4.95-1.414 1.414L8 9.414l-4.95 4.95-1.414-1.414L6.586 8l-4.95-4.95L3.05 1.636Z" fill="currentColor"/></svg>
</button>
</div>
</div>
<div class="search-panel" id="search-panel" hidden>
<form class="search-panel__form" role="search" action="/search/" method="get">
<input class="search-panel__input" type="search" name="q" placeholder="Type Search Text Here" aria-label="Search" autocomplete="off">
<button class="search-panel__submit" type="submit" aria-label="Submit search">
<span class="arrow" aria-hidden="true">→</span>
</button>
</form>
{/* ≤1024px: the magnifier reveals a search row under the header (NN/g
mobile-search pattern). name="header-panel" pairs it with the menu as
a native exclusive group — opening one closes the other, no JS. */}
<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>
<a class="login-mock" href="/login">Sign in</a>
{/* Mobile disclosure menu — native <details>/<summary>, no JS; desktop hides it via CSS */}
<details class="menu" name="header-panel">
<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>
<span class="menu-label">Menu</span>
</summary>
<nav aria-label="Main navigation">
{links.map((l) => l.ext ? (
<a class="external" href={l.href} target="_blank" rel="noopener">{l.label}</a>
) : (
<a href={l.href} aria-current={isActive(l.href) ? 'page' : undefined}>{l.label}</a>
))}
</nav>
</details>
</div>
</header>
{/* Search enhancement is deferred — a synchronous script here would stall
the parser mid-body on every navigation (blank below the header until
it arrives). `defer` + the inline `type="module"` init join the same
after-parse in-order queue (HTML spec), so newsSearch is defined first.
The forms are plain GET /search, so nothing user-facing waits on JS. */}
<script is:inline src="/search-client.js" defer></script>
<script is:inline type="module">
// 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', dismiss: true });
newsSearch({ input: 'pop-q', list: 'pop-suggest', status: 'pop-suggest-status', limit: 6, unit: 'suggestion', dismiss: true });
var pop = document.querySelector('.search-pop');
if (pop) pop.addEventListener('toggle', function () {
var q = document.getElementById('pop-q');
if (pop.open && q) q.focus();
});
// Light dismiss for the header panels (menu + search reveal) — scrim
// convention (Material/iOS, NN/g error prevention): a tap outside an
// open panel ONLY closes it and must never activate what's underneath.
// <details> has no native light dismiss; the Popover API was evaluated
// and rejected here — its light dismiss deliberately lets the outside
// click THROUGH (::backdrop is spec-forced pointer-events: none), the
// exact behavior research ruled out. Canceling pointerdown doesn't
// cancel the later click (Pointer Events spec), hence the click guard.
var swallow = false;
document.addEventListener('pointerdown', function (e) {
var open = document.querySelector('details[name="header-panel"][open]');
swallow = !!(open && !open.contains(e.target));
if (swallow) { open.open = false; e.preventDefault(); }
}, true);
document.addEventListener('click', function (e) {
if (swallow) { swallow = false; e.preventDefault(); e.stopPropagation(); }
}, true);
// Escape closes the open panel and returns focus to its trigger button
document.addEventListener('keydown', function (e) {
if (e.key !== 'Escape') return;
var open = document.querySelector('details[name="header-panel"][open]');
if (open) { open.open = false; open.querySelector('summary').focus(); }
});
</script>
<main id="main">
<slot />
</main>
<footer class="footer">
{/* Bottom menu required by SPEC §3.3.1: disclaimers, privacy, contact, settings, search.
Site settings has no target yet — placeholder link = <a> without href. */}
<div class="container">
<div class="foot-top">
<img src="/brand/AAFF_CTAO_Logo_RGB_Monochrome-negative.svg" alt="CTAO" />
<p>CTAO Science Portal demo by Cyfronet (SUSS-PORT team).</p>
</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">
<a href="/pages/contact">Contact</a>
<a href="/search">Search</a>
<a href="/rss.xml">RSS</a>
<a href="/pages/disclaimer">Disclaimer</a>
<a href="/pages/privacy">Privacy</a>
<a>Site settings</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>
<div class="foot-note">
{/* The real brand flash — path taken from the official logo SVG (BRAND D.4).
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>
<span>© 2026 CTAO. Demonstration, not an official service.</span>
</div>
<main id="main" tabindex="-1"><slot /></main>
<footer class="site-footer">
<div class="container site-footer__inner">
<img class="site-footer__logo" src="/assets/logo-sp-negative.svg" alt="CTAO" width="153" height="43.9" />
<ul class="site-footer__nav">
<li><a href="/pages/privacy/">Privacy</a></li>
<li><a href="/pages/disclaimer/">Disclaimer</a></li>
<li><a href="/pages/contact/">Contact</a></li>
<li><a href="/admin/">Content editor</a></li>
</ul>
<p class="site-footer__copy">© {new Date().getFullYear()} CTAO</p>
</div>
</footer>
<script is:inline src="/header.js"></script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
---
import Base from './Base.astro';
import PageHero from '../components/PageHero.astro';
interface Props { title: string }
const { title } = Astro.props;
---
<Base title={title}>
<PageHero title={title} />
<div class="placeholder-body"></div>
</Base>
-11
View File
@@ -1,11 +0,0 @@
// Shared news helpers — single source for card metadata and archive chunking.
export const fmt = (d) => new Intl.DateTimeFormat('en-GB', { dateStyle: 'long' }).format(d);
export 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).
export const readMin = (p) => Math.max(1, Math.round((p.body ?? '').split(/\s+/).length / 220));
// Archive chunking: page 1 holds 25 items (1 featured lead + 24 grid = even
// 3-column rows), later pages 24. Used by BOTH the /news/[...page] route and
// sitemap.xml.js — the page counts cannot drift apart.
export const PAGE_FIRST = 25;
export const PAGE_REST = 24;
+4 -20
View File
@@ -1,24 +1,8 @@
---
import Base from '../layouts/Base.astro';
import PageHero from '../components/PageHero.astro';
---
<Base title="CTAO Science Portal - Page not found" description="This page does not exist">
{/* Document archetype: the document IS the page — h1, one line,
recovery links, and a real GET search form as the way forward. */}
<div class="container article-layout">
<article class="article">
<h1>Page not found</h1>
<div class="prose">
{/* 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>
<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="nf-q" name="q" type="search" placeholder="Search news…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
</article>
</div>
<Base title="Page not found">
<PageHero title="Page not found" />
<div class="container prose prose--no-image"><p><a href="/">Return to Home</a></p></div>
</Base>
+9
View File
@@ -0,0 +1,9 @@
---
import Placeholder from '../layouts/Placeholder.astro';
import placeholders from '../data/placeholders.json';
export function getStaticPaths() {
return placeholders.map(({ slug, title }) => ({ params: { placeholder: slug }, props: { title } }));
}
const { title } = Astro.props;
---
<Placeholder title={title} />
+1 -1
View File
@@ -29,7 +29,7 @@
<noscript><p>The CTAO content editor requires JavaScript.<br />Please enable it, or return to the <a href="/">Science Portal</a>.</p></noscript>
<script is:inline src="/vendor/sveltia-cms.js"></script>
<script is:inline>
// Officially supported API: article previews rendered with portal-like styles.
// Generated from the site's CSS; see scripts/preview-styles.mjs.
window.CMS?.registerPreviewStyle?.('/admin/preview.css');
</script>
</body>
-30
View File
@@ -1,30 +0,0 @@
---
import Base from '../layouts/Base.astro';
// 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">
<header class="container page-head">
<h1>Dashboard</h1>
<p class="standfirst">Your proposals, data products and support tickets in one place.</p>
</header>
<div class="container page">
<div class="dash-grid">
<div class="panel">
<h2 class="panel-title">My proposals</h2>
<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>
<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>
<p class="notice">Help-desk tickets will appear here once User Support is connected.</p>
</div>
</div>
</div>
</section>
</Base>
+35
View File
@@ -0,0 +1,35 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import PageHero from '../../components/PageHero.astro';
import examples from '../../data/examples.json';
export async function getStaticPaths() {
const pages = await getCollection('pages');
return examples.map(({ slug }) => {
const page = pages.find(({ id }) => id === slug);
if (!page) throw new Error(`Missing Example page: ${slug}. Run npm run content:setup.`);
return { params: { slug }, props: { page } };
});
}
const { page } = Astro.props;
const { Content } = await render(page);
const { title, introduction, image, imageAlt, imageCaption, buttonLabel, buttonUrl } = page.data;
---
<Base title={title} description={introduction}>
<PageHero title={title} introduction={introduction} example hasImage={Boolean(image)} />
{image && <div class="container">
<figure class="page-figure">
<img src={image} alt={imageAlt || ''} />
{imageCaption && <figcaption>{imageCaption}</figcaption>}
</figure>
</div>}
<article class:list={['container', 'prose', { 'prose--no-image': !image }]}>
<Content />
{buttonLabel && buttonUrl && <p>
<a class="btn btn--primary" href={buttonUrl}>
<span class="btn__label">{buttonLabel}</span><span class="arrow" aria-hidden="true">→</span>
</a>
</p>}
</article>
</Base>
+2 -91
View File
@@ -1,93 +1,4 @@
---
import { getCollection } from 'astro:content';
import Base from '../layouts/Base.astro';
import NewsCard from '../components/NewsCard.astro';
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 3);
// Services per the Science Portal spec (REQUIREMENTS §5). Tiles carry no
// status chrome; the footer's demo note is the single mock disclosure.
// Planned services stay in the grid but subdued (muted, no link) with
// availability folded into the description text.
// Tile icons: hand-drawn stroke glyphs in the header-icon family (.ico —
// currentColor, round caps), aria-hidden. NN/g icon research: icons alone are
// ambiguous, but NEXT TO an always-visible label they differentiate otherwise
// uniform tiles and speed up grid scanning.
const services = [
{ title: 'Proposals', href: '/proposals', desc: 'Submit observation proposals, including ToO / MWL requests.',
icon: '<path d="M13.5 3H7a1 1 0 0 0-1 1v16a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1V7.5z"/><path d="M13.5 3v4.5H18"/><path d="M9 12.5h6M9 16h4"/>' },
{ title: 'Data Explorer', href: 'https://padc-ctao-data-explorer.obspm.fr/', ext: true, desc: 'Search and download CTAO science data products.',
icon: '<ellipse cx="12" cy="5.5" rx="7" ry="2.5"/><path d="M5 5.5V18c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5V5.5"/><path d="M5 11.8c0 1.4 3.1 2.5 7 2.5s7-1.1 7-2.5"/>' },
{ title: 'Dashboard', href: '/dashboard', desc: 'Your proposals, data products and support tickets in one place.',
icon: '<rect x="4" y="4" width="7" height="7" rx="1.5"/><rect x="13" y="4" width="7" height="7" rx="1.5"/><rect x="4" y="13" width="7" height="7" rx="1.5"/><rect x="13" y="13" width="7" height="7" rx="1.5"/>' },
{ title: 'User Support', href: '/support', desc: 'Help desk, FAQ, user forum and mailing lists.',
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: '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: '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'];
import Placeholder from '../layouts/Placeholder.astro';
---
<Base title="CTAO Science Portal" description="News, observation proposals, data and user services of the Cherenkov Telescope Array Observatory" ambient>
{/* Photographic hero: LST-1 under the La Palma night sky (copied from the
content library to /brand/hero.jpg) beneath the Galaxy scrim — text
contrast computed worst-case at design time. Three elements only:
headline, brand subtitle, CTA pair (Apple hero restraint). */}
<section class="hero-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="container hero">
<div class="eyebrow">Cherenkov Telescope Array Observatory</div>
<h1>CTAO Science Portal</h1>
<p>Exploring the Universe at the Highest Energies</p>
{/* Hero actions = the two REAL things a logged-out visitor can do today:
news (SPEC §3.1 lists general information/announcements FIRST; §3.3.1
"displays the main information"; calls for proposals arrive VIA news)
and the external Data Explorer. Proposals stays in nav + tiles — a
mock doesn't earn hero billing. Search lives in the header. */}
<div class="hero-cta">
<a class="btn" href="/news">Browse news &amp; announcements</a>
<a class="btn btn--ghost external" href="https://padc-ctao-data-explorer.obspm.fr/" target="_blank" rel="noopener">Explore the data</a>
</div>
</div>
</section>
{/* Section order is task-first: Services directly under the hero, news after.
NN/g homepage principles — the homepage must give clear starting points
for the user's top tasks; for scientists that is proposals/data/support,
not reading news. Freshness is still signalled above the fold by the
one-line hero teaser, so the 3-card news section can sit below.
Surface rhythm: services on Moon Gray, editorial news on white. */}
<section class="band--moon">
<div class="container page">
<div class="section-head"><h2>Services</h2></div>
<div class="grid">
{services.map((s) => (
<article class={s.planned ? 'card tile tile--planned' : 'card tile'}>
<svg class="ico" aria-hidden="true" viewBox="0 0 24 24" set:html={s.icon} />
<h3>{s.planned
? s.title
: (s.ext
? <a class="external" href={s.href} target="_blank" rel="noopener">{s.title}</a>
: <a href={s.href}>{s.title}</a>)}</h3>
<p class="desc">{s.desc}</p>
</article>
))}
</div>
<p class="planned-note">Planned services: {planned.join(' · ')}</p>
</div>
</section>
<section class="container page">
<div class="section-head">
<h2>Latest news</h2>
<a class="more" href="/news">All news →</a>
</div>
<div class="grid">
{posts.map((post) => <NewsCard post={post} />)}
</div>
</section>
</Base>
<Placeholder title="CTAO Science Portal" />
-16
View File
@@ -1,16 +0,0 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Sign in" description="Sign in via CTAO AAI (mock)">
<section class="auth-band">
<div class="band-bg" aria-hidden="true"></div>
<div class="auth-card">
<img class="auth-logo" src="/brand/CTAO_Logo_positive.svg" alt="CTAO" />
<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>
<button class="btn btn-wide" type="button">Continue with your CTAO account</button>
<p class="notice">No account? <a>Register via CTAO AAI</a></p>
</div>
</section>
</Base>
-83
View File
@@ -1,83 +0,0 @@
---
import { getCollection } from 'astro:content';
import Base from '../../layouts/Base.astro';
import NewsCard from '../../components/NewsCard.astro';
import { PAGE_FIRST as FIRST, PAGE_REST as REST } from '../../lib/news.js';
// Paginated archive; the [...page] rest route makes page 1 the bare /news URL,
// then /news/2 … /news/N. Custom slicing instead of paginate(): page 1 holds
// FIRST items (1 featured lead + 24 grid = even 3-column rows), later pages
// REST — paginate() cannot vary page size. Chunk sizes live in lib/news.js,
// shared with sitemap.xml.js.
export async function getStaticPaths() {
const posts = (await getCollection('news', ({ data }) => !data.draft)).sort(
(a, b) => b.data.date.valueOf() - a.data.date.valueOf(),
);
const chunks = [posts.slice(0, FIRST)];
for (let i = FIRST; i < posts.length; i += REST) chunks.push(posts.slice(i, i + REST));
const lastPage = chunks.length;
const hrefOf = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
return chunks.map((data, i) => {
const n = i + 1;
return {
params: { page: n === 1 ? undefined : String(n) },
props: { page: {
data,
currentPage: n,
lastPage,
url: {
prev: n > 1 ? hrefOf(n - 1) : undefined,
next: n < lastPage ? hrefOf(n + 1) : undefined,
},
} },
};
});
}
const { page } = Astro.props;
const first = page.currentPage === 1;
// Windowed page list — 1 … n-1 n n+1 … last (0 marks an ellipsis)
const nums = [];
for (let n = 1; n <= page.lastPage; n++) {
if (n === 1 || n === page.lastPage || Math.abs(n - page.currentPage) <= 1) nums.push(n);
else if (nums.at(-1) !== 0) nums.push(0);
}
const hrefFor = (n: number) => (n === 1 ? '/news' : `/news/${n}`);
---
<Base
title={first ? 'CTAO Science Portal - News' : `CTAO Science Portal - News, page ${page.currentPage}`}
description={first ? 'News and announcements of the CTAO' : `News and announcements of the CTAO, page ${page.currentPage} of ${page.lastPage}`}
>
{/* Section-landing archetype: white head, display Galaxy h1 + standfirst
(the navy band is reserved for / and /login). */}
<header class="container page-head">
<h1>News &amp; Announcements</h1>
<p class="standfirst">Science highlights and updates from the observatory and its partners.</p>
</header>
<section class="container page">
<h2 class="sr-only">{first ? 'All news' : `All news, page ${page.currentPage}`}</h2>
<div class="grid">
{page.data.map((post, i) => <NewsCard post={post} featured={first && i === 0} />)}
</div>
{page.lastPage > 1 && (
<nav class="pagination" aria-label="News pages">
{page.url.prev
? <a class="page-link page-step" href={page.url.prev} rel="prev">← Newer</a>
: <span class="page-link page-step" aria-hidden="true">← Newer</span>}
<ol>
{nums.map((n) => (
<li>
{n === 0
? <span class="page-gap" aria-hidden="true">…</span>
: <a class="page-link" href={hrefFor(n)} aria-current={n === page.currentPage ? 'page' : undefined}><span class="sr-only">Page </span>{n}</a>}
</li>
))}
</ol>
{page.url.next
? <a class="page-link page-step" href={page.url.next} rel="next">Older →</a>
: <span class="page-link page-step" aria-hidden="true">Older →</span>}
</nav>
)}
</section>
</Base>
-144
View File
@@ -1,144 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
import { fmt, readMin as readMinOf } from '../../lib/news.js';
export async function getStaticPaths() {
// Date-sorted so each article knows its sequential neighbours (prev/next
// 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, newer, older } = Astro.props;
const { Content, headings } = await render(post);
const toc = headings.filter((h) => h.depth === 2 || h.depth === 3);
const readMin = readMinOf(post);
---
<Base title={`${post.data.title} - CTAO`} description={post.data.description} type="article" image={post.data.cover && encodeURI(post.data.cover)}>
{/* Reading progress (scroll-driven CSS, no JS) — styled only where
animation-timeline is supported; elsewhere it stays an empty div. */}
<div class="read-progress" aria-hidden="true"></div>
{/* TOC pattern (MDN/Stripe/NN-g): sticky right rail ≥1200px, collapsed <details>
under the title below that. CSS shows exactly one of the two. */}
{/* lang sits on the article, not <html>: the chrome (nav/footer) stays
English for screen readers even when the article body is not. */}
<div class="container article-layout">
<article class="article" lang={post.data.lang}>
<a class="back" href="/news">← All news</a>
{/* Editorial anatomy (Guardian/BBC/Reuters convention): headline →
standfirst → byline/meta → lead image → body. The standfirst is the
description made visible — cards already show it, the article should too. */}
<h1>{post.data.title}</h1>
<p class="standfirst">{post.data.description}</p>
{/* Meta — one quiet muted line: category · author · date · reading time */}
<div class="meta">
<span class="cat"><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="M20 13.2 13.2 20a2 2 0 0 1-2.9 0L4 13.7V4h9.7l6.3 6.3a2 2 0 0 1 0 2.9Z" stroke-linejoin="round" /><circle cx="8.5" cy="8.5" r="1" /></svg>{post.data.category}</span>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4" /><path d="M4.5 20c1.6-3.8 4.6-5.5 7.5-5.5s5.9 1.7 7.5 5.5" /></svg>{post.data.author}</span>·
<time datetime={post.data.date.toISOString().slice(0, 10)}><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><rect x="4" y="5" width="16" height="16" rx="2" /><path d="M4 10h16M8 3v4M16 3v4" /></svg>{fmt(post.data.date)}</time>·
<span><svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9" /><path d="M12 7v5l3 2" /></svg>{readMin} min read</span>
</div>
{post.data.cover && <img class="cover" src={encodeURI(post.data.cover)} alt="" />}
{/* TOC sits between lead image and body (Wikipedia/GOV.UK contents
position): the press head above stays unbroken, and the list is
adjacent to the content it indexes. */}
{toc.length >= 3 && (
<details class="toc toc--inline">
<summary>On this page <svg class="ico" aria-hidden="true" viewBox="0 0 24 24"><path d="m5 9 7 7 7-7" /></svg></summary>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</details>
)}
<div class="prose">
<Content />
</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>
{toc.length >= 3 && (
<nav class="toc toc--rail" aria-label="On this page">
<strong>On this page</strong>
<ol>
{toc.map((h) => (
<li class={h.depth === 3 ? 'd3' : undefined}><a href={`#${h.slug}`}>{h.text}</a></li>
))}
</ol>
</nav>
)}
</div>
</Base>
<script>
// TOC scrollspy — minimal vanilla take on Starlight's starlight-toc.ts:
// an IntersectionObserver band near the viewport top; whichever prose block
// enters it is mapped back to its governing h2/h3, whose TOC link(s) get
// aria-current="true" (set in BOTH renderings — inline <details> and rail).
// Progressive enhancement: without JS the TOC is plain working anchors.
const links = [...document.querySelectorAll<HTMLAnchorElement>('.toc a[href^="#"]')];
if (links.length) {
let current: HTMLAnchorElement[] = [];
const setCurrent = (id: string | null) => {
const next = id ? links.filter((a) => decodeURIComponent(a.hash.slice(1)) === id) : [];
if (next[0] === current[0]) return;
for (const a of current) a.removeAttribute('aria-current');
for (const a of next) a.setAttribute('aria-current', 'true');
current = next;
};
const blocks = [...document.querySelectorAll('.prose > *')];
// Governing heading = the block itself or the nearest h2/h3[id] above it
const headingFor = (el: Element) => {
for (let i = blocks.indexOf(el); i >= 0; i--) {
if (blocks[i].matches('h2[id], h3[id]')) return blocks[i].id;
}
return null; // intro before the first heading — nothing highlighted
};
const onIntersect = (entries: IntersectionObserverEntry[]) => {
for (const e of entries) {
if (e.isIntersecting) { setCurrent(headingFor(e.target)); break; }
}
};
let observer: IntersectionObserver | undefined;
const observe = () => {
observer?.disconnect();
// Narrow band below the sticky header (--header-h 56px + 12px slack;
// JS can't read the token cheaply, keep in sync) — Starlight's rootMargin trick
const top = 68, band = 64;
const io = new IntersectionObserver(onIntersect, {
rootMargin: `-${top}px 0px ${top + band - document.documentElement.clientHeight}px`,
});
observer = io;
blocks.forEach((b) => io.observe(b));
};
observe();
let timer: ReturnType<typeof setTimeout> | undefined;
addEventListener('resize', () => { clearTimeout(timer); timer = setTimeout(observe, 200); });
// Short final section: at page bottom, the last heading wins
addEventListener('scroll', () => {
if (innerHeight + scrollY >= document.documentElement.scrollHeight - 4) {
setCurrent(headingFor(blocks[blocks.length - 1]));
}
}, { passive: true });
}
</script>
-27
View File
@@ -1,27 +0,0 @@
---
import { getCollection, render } from 'astro:content';
import Base from '../../layouts/Base.astro';
export async function getStaticPaths() {
const pages = await getCollection('pages');
return pages.map((page) => ({ params: { slug: page.id }, props: { page } }));
}
const { page } = Astro.props;
const { Content } = await render(page);
---
<Base title={`${page.data.title} - CTAO Science Portal`} description={page.data.description}>
{/* Same wrapper pattern as news/[slug] (never .container and .article on
one element — both set max-width and would depend on rule order). */}
<div class="container article-layout">
<article class="article">
{/* Static pages are footer-reached documents; same back affordance as
articles ("← All news") so deep links aren't dead ends. */}
<a class="back" href="/">← Home</a>
<h1>{page.data.title}</h1>
<div class="prose">
<Content />
</div>
</article>
</div>
</Base>
-28
View File
@@ -1,28 +0,0 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Proposals" description="Observation proposals: Proposal Handling System integration (planned)">
<section class="band--moon">
<header class="container page-head">
<h1>Observation proposals</h1>
<p class="standfirst">Submit observation proposals, including ToO and MWL requests.</p>
</header>
<div class="container page">
{/* 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. */}
<div class="panel panel--narrow">
<h2 class="panel-title">Proposal Handling System</h2>
<p>Submitting observation proposals, including Target of Opportunity (ToO)
and multi-wavelength (MWL) requests, happens in the Proposal Handling
System. That system is built by the APC team and integrated into this portal.</p>
<p>What the portal contributes:</p>
<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>
</ul>
<button class="btn" type="button" disabled>Open the Proposal Handling System (integration planned)</button>
</div>
</div>
</section>
</Base>
-43
View File
@@ -1,43 +0,0 @@
// RSS 2.0 feed — hand-rolled static endpoint like search.json.js (no @astrojs/rss
// dependency; the spec is 20 lines of XML). Newest 30 items, absolute URLs from
// the configured `site`.
import { getCollection } from 'astro:content';
const esc = (s = '') =>
s.replace(/[<>&'"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', "'": '&apos;', '"': '&quot;' })[c]);
export async function GET(context) {
const site = context.site; // always set in astro.config.mjs
const posts = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.slice(0, 30);
const items = posts
.map((p) => {
const url = new URL(`/news/${p.id}`, site).href;
return ` <item>
<title>${esc(p.data.title)}</title>
<link>${url}</link>
<guid isPermaLink="true">${url}</guid>
<pubDate>${p.data.date.toUTCString()}</pubDate>
<description>${esc(p.data.description)}</description>
<category>${esc(p.data.category)}</category>
</item>`;
})
.join('\n');
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
<channel>
<title>CTAO Science Portal News</title>
<link>${new URL('/news', site).href}</link>
<description>News and announcements of the Cherenkov Telescope Array Observatory</description>
<language>en</language>
<lastBuildDate>${(posts[0]?.data.date ?? new Date()).toUTCString()}</lastBuildDate>
<atom:link href="${new URL('/rss.xml', site).href}" rel="self" type="application/rss+xml"/>
${items}
</channel>
</rss>
`;
return new Response(xml, { headers: { 'Content-Type': 'application/rss+xml; charset=utf-8' } });
}
-36
View File
@@ -1,36 +0,0 @@
---
import Base from '../layouts/Base.astro';
---
<Base title="CTAO Science Portal - Search" description="Search CTAO news and announcements">
{/* Utility archetype: ONE surface per page — Moon canvas from the landing
head down (borderless cards/panels pop against it); editorial pages stay
all-white. No mid-page surface seams. */}
<section class="band--moon">
<header class="container page-head">
<h1>Search</h1>
<p class="standfirst">Find news and announcements.</p>
</header>
<div class="container page">
<form class="search-form" role="search" action="/search" method="get">
<label class="sr-only" for="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="q" name="q" type="search" placeholder="e.g. LST camera, Council, data…" autocomplete="off" />
<button class="btn" type="submit">Search</button>
</form>
<p class="notice" id="search-status" role="status" aria-live="polite">Type to search the news archive.</p>
<ol class="search-results" id="search-results"></ol>
<noscript><p class="notice">Search requires JavaScript. Browse all news on the <a href="/news">news page</a>.</p></noscript>
</div>
</section>
</Base>
{/* search-client.js is loaded once (deferred) by the Base layout; this inline
module runs after it in the shared after-parse queue — order guaranteed. */}
<script is:inline type="module">
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)
const q0 = new URLSearchParams(location.search).get('q');
const q = document.getElementById('q');
if (q0) { q.value = q0; run(); }
q.focus();
</script>
-19
View File
@@ -1,19 +0,0 @@
// Build-time search index (Astro static endpoint — no server, no dependencies).
// Kept lean: title/description/category/date/slug only (~60 KB for 200+ articles).
import { getCollection } from 'astro:content';
export async function GET() {
const posts = await getCollection('news', ({ data }) => !data.draft);
const index = posts
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf())
.map((p) => ({
slug: p.id,
title: p.data.title,
description: p.data.description,
category: p.data.category,
date: p.data.date.toISOString().slice(0, 10),
}));
return new Response(JSON.stringify(index), {
headers: { 'Content-Type': 'application/json; charset=utf-8' },
});
}
+7 -35
View File
@@ -1,40 +1,12 @@
// Sitemap — hand-rolled static endpoint (no @astrojs/sitemap dependency; our URL
// set is fully known at build time). /admin is intentionally excluded.
import { getCollection } from 'astro:content';
import { PAGE_FIRST, PAGE_REST } from '../lib/news.js';
// Chunk sizes are imported from lib/news.js — the same values the
// /news/[...page] route slices with, so the page count here cannot drift.
const newsPageCount = (n) => (n <= PAGE_FIRST ? 1 : 1 + Math.ceil((n - PAGE_FIRST) / PAGE_REST));
export async function GET(context) {
const site = context.site; // always set in astro.config.mjs
const news = (await getCollection('news', ({ data }) => !data.draft))
.sort((a, b) => b.data.date.valueOf() - a.data.date.valueOf());
const pages = await getCollection('pages');
// Static pages come from the actual route files, so a new src/pages/*.astro
// can never be forgotten here. 404 is excluded; /admin (subdirectory) is
// intentionally unlisted. Go-live note: /login and /dashboard are mock
// pages — filter them out before robots.txt stops disallowing everything.
const staticPages = Object.keys(import.meta.glob('./*.astro'))
.map((p) => p.slice(1, -'.astro'.length))
.filter((p) => p !== '/404')
.map((p) => (p === '/index' ? '/' : p))
.sort();
const urls = [
...staticPages, '/news',
...Array.from({ length: newsPageCount(news.length) - 1 }, (_, i) => `/news/${i + 2}`),
...pages.map((p) => `/pages/${p.id}`),
].map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`);
const articles = news.map(
(p) => ` <url><loc>${new URL(`/news/${p.id}`, site).href}</loc><lastmod>${p.data.date.toISOString().slice(0, 10)}</lastmod></url>`,
);
import examples from '../data/examples.json';
import placeholders from '../data/placeholders.json';
export function GET({ site }) {
const paths = ['/', ...placeholders.map(({ slug }) => `/${slug}/`),
...examples.map(({ slug }) => `/example/${slug}/`)];
const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${[...urls, ...articles].join('\n')}
</urlset>
`;
${paths.map((path) => ` <url><loc>${new URL(path, site).href}</loc></url>`).join('\n')}
</urlset>`;
return new Response(xml, { headers: { 'Content-Type': 'application/xml; charset=utf-8' } });
}
-29
View File
@@ -1,29 +0,0 @@
---
import Base from '../layouts/Base.astro';
// User support channels per the Science Portal spec — all mock for now.
const channels = [
{ title: 'Help desk', desc: 'Submit and track support tickets.' },
{ title: 'FAQ & troubleshooting', desc: 'Common questions and step-by-step guides.' },
{ title: 'User forum', desc: 'Discuss with the CTAO user community.' },
{ title: 'Mailing lists', desc: 'Announcements and topical mailing lists.' },
];
---
<Base title="CTAO Science Portal - Support" description="User support: help desk, FAQ, forum, mailing lists (mock)">
<section class="band--moon">
<header class="container page-head">
<h1>User Support</h1>
<p class="standfirst">Help desk, FAQ, user forum and mailing lists.</p>
</header>
<div class="container page">
<h2 class="sr-only">Support channels</h2>
<div class="grid">
{channels.map((c) => (
<article class="card tile">
<h3>{c.title}</h3>
<p class="desc">{c.desc}</p>
</article>
))}
</div>
</div>
</section>
</Base>
+817 -797
View File
File diff suppressed because one or more lines are too long