From 321680e4b869bb9163187db92de7ffdfebaa6bb1 Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Mon, 10 Aug 2026 12:42:43 +0000 Subject: [PATCH 1/4] fix: harden the package table pipeline - Retry transient HTTP failures and regenerate all distros in one `--all` run that keeps going when a single distro fails - Single source for the distro and platform lists: `src/data/distros.json`, read by `distros.ts` and the pipeline - Deploy only after a successful table refresh, add concurrency groups - Trim the payload: drop the unused `license` field, cap the mutex generations at 4 - Show channel-only packages as rows, marked `indexed` 0 - Normalize seconds-vs-milliseconds timestamps in `collect_builds`, fail the staging copy loudly on fetch errors, use `pathlib` --- .github/workflows/deploy.yml | 10 ++ .github/workflows/update-package-table.yml | 43 +++--- pixi.toml | 4 + scripts/compare_pkg_completeness.py | 166 +++++++++++++++------ scripts/copy-to-distro-specific-channel.py | 26 ++-- src/components/PackageTable.svelte | 101 ++++++++----- src/data/distros.json | 100 +++++++++++++ src/data/distros.ts | 123 ++++++--------- 8 files changed, 374 insertions(+), 199 deletions(-) create mode 100644 src/data/distros.json diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e2183fa9..568908e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -6,9 +6,19 @@ on: workflow_run: workflows: ["Update package table"] types: [completed] + +# ghp-import force-pushes gh-pages; two runs racing each other could publish +# the older build last. +concurrency: + group: deploy + cancel-in-progress: false + jobs: deploy: runs-on: ubuntu-latest + # Only redeploy for a table refresh that actually succeeded; a push to main + # deploys unconditionally. + if: github.event_name == 'push' || github.event.workflow_run.conclusion == 'success' # `ghp-import --push` pushes the built site to the gh-pages branch permissions: contents: write diff --git a/.github/workflows/update-package-table.yml b/.github/workflows/update-package-table.yml index bcc46658..19fc6af8 100644 --- a/.github/workflows/update-package-table.yml +++ b/.github/workflows/update-package-table.yml @@ -5,6 +5,12 @@ on: - cron: "0 */6 * * *" workflow_dispatch: +# A second run pushing over a still-running first one would fail or interleave +# commits; queue instead. +concurrency: + group: update-package-table + cancel-in-progress: false + jobs: build: runs-on: ubuntu-latest @@ -18,37 +24,21 @@ jobs: with: persist-credentials: false - uses: prefix-dev/setup-pixi@f00437f565399d418b0acc85936d12c1fb668347 # v0.10.1 - # foxy and galactic are end-of-life; public/data/{foxy,galactic}.json are - # committed snapshots and deliberately not regenerated here. - - name: Create table noetic - run: | - pixi run compare-completeness noetic robostack-noetic - - name: Create table humble - run: | - pixi run compare-completeness humble robostack-humble - - name: Create table jazzy - run: | - pixi run compare-completeness jazzy robostack-jazzy - - name: Create table kilted - run: | - pixi run compare-completeness kilted robostack-kilted - - name: Create table rolling - run: | - pixi run compare-completeness rolling https://prefix.dev/robostack-rolling - - name: Create table lyrical + # Regenerates every distro with a dataChannel in src/data/distros.json; + # a distro without one (foxy, galactic) is a committed snapshot. A failed + # distro does not stop the others: whatever succeeded is still committed + # below, and the run is marked failed at the end. + - name: Update tables + id: update + continue-on-error: true run: | - pixi run compare-completeness lyrical https://prefix.dev/robostack-lyrical + pixi run update-tables - name: Commit changes id: commit run: | git config --local user.email "action@github.com" git config --local user.name "GitHub Action" - git add public/data/noetic.json - git add public/data/humble.json - git add public/data/jazzy.json - git add public/data/kilted.json - git add public/data/rolling.json - git add public/data/lyrical.json + git add public/data git commit -m "Update tables" continue-on-error: true - name: Push changes @@ -57,3 +47,6 @@ jobs: with: github_token: ${{ secrets.GITHUB_TOKEN }} branch: ${{ github.ref }} + - name: Surface update failures + if: steps.update.outcome == 'failure' + run: exit 1 diff --git a/pixi.toml b/pixi.toml index 203c5588..819fa1cd 100644 --- a/pixi.toml +++ b/pixi.toml @@ -91,6 +91,10 @@ compare-completeness = { cmd = "python scripts/compare_pkg_completeness.py", description = "Add two arguments to give it the DISTRO and CHANNEL", } +update-tables = { + cmd = "python scripts/compare_pkg_completeness.py --all", + description = "Regenerate the package tables for every distro the pipeline maintains", +} [environments] default = ["site", "lint", "scripts"] diff --git a/scripts/compare_pkg_completeness.py b/scripts/compare_pkg_completeness.py index 51066849..ccb383b8 100644 --- a/scripts/compare_pkg_completeness.py +++ b/scripts/compare_pkg_completeness.py @@ -6,9 +6,13 @@ - `rosdistro`'s `distribution.yaml` for the package list, the released version, and the upstream source repository. - `rosdistro`'s distribution cache for each package's `package.xml`, which is where - the descriptions and licences come from. + the descriptions come from. - The channel's `repodata.json` per platform, for what actually got built. +Packages that exist on the channel but were never released into `rosdistro` (extra +recipes, mostly) get a row too, marked with `indexed` 0: they have no description +and no index version, but they are installable and should be findable. + Availability is always relative to a mutex. Everything on a channel is built against one version of `ros2-distro-mutex` (`ros-distro-mutex` on ROS 1), and builds for different mutex versions cannot be installed together. A package built for 0.8 but @@ -21,11 +25,13 @@ would work today, but the specs appear in two forms (`0.9.* humble_*` and `>=0.9.0,<0.10.0a0`) and nothing stops a third from showing up. -The JSON is positional to keep it small; `PackageTable.svelte` unpacks it by -index, so the order in `PackageRecordJson` is load-bearing. +The JSON is positional to keep it small; `PackageTable.svelte` unpacks it via the +`fields` list in the document head, so the two only have to agree on the names. Usage: python scripts/compare_pkg_completeness.py channel is an anaconda.org channel name or a full base URL. + python scripts/compare_pkg_completeness.py --all + regenerates every distro with a `dataChannel` in src/data/distros.json. """ from __future__ import annotations @@ -34,29 +40,27 @@ import concurrent.futures import gzip import json -import os import re import sys import xml.etree.ElementTree as ET from collections.abc import Callable from dataclasses import dataclass +from pathlib import Path from typing import Any, TypeAlias import niquests import yaml from rattler import MatchSpec, PackageRecord +from urllib3.util.retry import Retry + +# The distro list and the platform list are shared with the site through +# src/data/distros.json. +DISTROS_JSON = Path(__file__).parent.parent / "src" / "data" / "distros.json" -# The bit positions here are the bit positions the page reads. The page takes -# the platform order from the JSON itself; only the icon map in +# The order is the bit-position order the page reads. The page takes the +# platform order from the generated JSON itself; only the icon map in # src/components/PackageTable.svelte is keyed by platform id. -PLATFORMS: list[str] = [ - "linux-64", - "linux-aarch64", - "osx-64", - "osx-arm64", - "win-64", - "emscripten-wasm32", -] +PLATFORMS: list[str] = json.loads(DISTROS_JSON.read_text())["platforms"] ROSDISTRO = "https://raw.githubusercontent.com/ros/rosdistro/master" LOADER = getattr(yaml, "CSafeLoader", yaml.SafeLoader) @@ -64,13 +68,23 @@ # ROS 1 and ROS 2 name their mutex differently, and a channel only ever has one. MUTEX_NAMES: tuple[str, ...] = ("ros2-distro-mutex", "ros-distro-mutex") +# Newest mutex generations to keep. Jazzy has published eleven; the old ones +# dominate the payload while only the recent generations are still useful to +# select in the table. +MUTEX_LIMIT = 4 + # A raw repodata record, as it comes out of the JSON. Artifact: TypeAlias = dict[str, Any] # Mutex version -> every artifact published for it. A version can ship more than # one build, and a spec only has to match one of them. MutexRecords: TypeAlias = dict[str, list[PackageRecord]] -session = niquests.Session() +# The whole document is a handful of GETs against rosdistro and the channel; +# retrying transient failures (anaconda.org 5xxs, mostly) keeps one hiccup from +# failing a whole six-hourly refresh. +session = niquests.Session( + retries=Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) +) @dataclass @@ -118,8 +132,8 @@ def index_packages(distro: str) -> dict[str, IndexEntry]: return packages -def package_metadata(distro: str) -> dict[str, tuple[str, str]]: - """`{package: (description, licence)}` from the rosdistro distribution cache. +def package_metadata(distro: str) -> dict[str, str]: + """`{package: description}` from the rosdistro distribution cache. The cache is the only place these live, but the table is still useful without them, so a failure here is logged and skipped rather than raised. @@ -134,14 +148,13 @@ def package_metadata(distro: str) -> dict[str, tuple[str, str]]: print(f" warning: no distribution cache ({error})", file=sys.stderr) return {} - metadata: dict[str, tuple[str, str]] = {} + metadata: dict[str, str] = {} for name, package_xml in cache.get("release_package_xmls", {}).items(): try: root = ET.fromstring(package_xml) except ET.ParseError: continue - description = " ".join((root.findtext("description") or "").split()) - metadata[name] = (description, (root.findtext("license") or "").strip()) + metadata[name] = " ".join((root.findtext("description") or "").split()) return metadata @@ -187,6 +200,17 @@ def version_key(version: str) -> tuple[int, ...]: return tuple(int(p) if p.isdigit() else -1 for p in re.split(r"[._-]", str(version))[:4]) +def normalize_timestamp(timestamp: int) -> int: + """Repodata timestamps in milliseconds; some artifacts carry seconds instead. + + Workaround for https://github.com/RoboStack/ros-humble/issues/258: a timestamp + that would place the build before 2001 is taken to be in seconds. + """ + if 0 < timestamp < 1_000_000_000_000: + timestamp *= 1000 + return timestamp + + def collect_mutexes(repos: dict[str, list[Artifact]]) -> tuple[str, MutexRecords]: """Find the channel's mutex package and every version of it that was published. @@ -257,7 +281,9 @@ def collect_builds( if not name.startswith("ros-") or name in MUTEX_NAMES: continue - newest_build[name] = max(newest_build.get(name, 0), artifact.get("timestamp") or 0) + newest_build[name] = max( + newest_build.get(name, 0), normalize_timestamp(artifact.get("timestamp") or 0) + ) specs = [d for d in artifact.get("depends", []) if d.split(" ")[0] in MUTEX_NAMES] if specs: @@ -290,7 +316,7 @@ def build(distro: str, channel: str) -> dict[str, Any]: print(f" {platform}: {len(repos[platform])}", file=sys.stderr) mutex_package, mutex_records = collect_mutexes(repos) - mutexes = sorted(mutex_records, key=version_key, reverse=True) + mutexes = sorted(mutex_records, key=version_key, reverse=True)[:MUTEX_LIMIT] print(f" mutex: {mutex_package} {mutexes}", file=sys.stderr) builds, newest_build = collect_builds(repos, mutexes, mutex_matcher(mutex_records)) @@ -300,6 +326,11 @@ def build(distro: str, channel: str) -> dict[str, Any]: repo_urls: list[str] = [] repo_index: dict[str, int] = {} + def slots(per_mutex: dict[str, Slot]) -> list[Any]: + # Aligned with "mutexes": 0 where nothing is built for that mutex, + # otherwise [platform bitmask, newest version built there]. + return [[per_mutex[v].mask, per_mutex[v].version] if v in per_mutex else 0 for v in mutexes] + packages: list[list[Any]] = [] for name in sorted(index): conda_name = f"ros-{distro}-{name.replace('_', '-')}" @@ -310,23 +341,37 @@ def build(distro: str, channel: str) -> dict[str, Any]: repo_index[entry.source] = len(repo_urls) repo_urls.append(entry.source) - description, package_license = metadata.get(name, ("", "")) packages.append( [ name.replace("_", "-"), # conda spelling, `ros--` stripped - description, - package_license, + metadata.get(name, ""), entry.version, # as released into the ROS index newest_build.get(conda_name, 0) // 1000, # newest build, seconds repo_index.get(entry.source, -1), # index into "repos" - # Aligned with "mutexes": 0 where nothing is built for that mutex, - # otherwise [platform bitmask, newest version built there]. - [ - [per_mutex[v].mask, per_mutex[v].version] if v in per_mutex else 0 - for v in mutexes - ], + 1, # released into the ROS index + slots(per_mutex), + ] + ) + + # Packages on the channel that rosdistro has never released: no description, + # index version or source repository, but installable all the same. + prefix = f"ros-{distro}-" + released = {f"ros-{distro}-{name.replace('_', '-')}" for name in index} + for conda_name in sorted(set(builds) - released): + if not conda_name.startswith(prefix): + continue + packages.append( + [ + conda_name.removeprefix(prefix), + "", + "", + newest_build.get(conda_name, 0) // 1000, + -1, + 0, + slots(builds[conda_name]), ] ) + packages.sort(key=lambda package: package[0]) return { "distro": distro, @@ -334,13 +379,13 @@ def build(distro: str, channel: str) -> dict[str, Any]: "platforms": PLATFORMS, "mutexPackage": mutex_package, "mutexes": mutexes, - "fields": ["name", "desc", "license", "indexVersion", "updated", "repo", "builds"], + "fields": ["name", "desc", "indexVersion", "updated", "repo", "indexed", "builds"], "repos": repo_urls, "packages": packages, } -def write(document: dict[str, Any], path: str) -> None: +def write(document: dict[str, Any], path: Path) -> None: """Write the document with one package per line. Compact JSON on a single line would make every rebuild a one-line diff covering @@ -354,22 +399,17 @@ def write(document: dict[str, Any], path: str) -> None: body = ",\n".join(json.dumps(p, separators=(",", ":")) for p in document["packages"]) text = json.dumps(head, separators=(",", ":"))[:-1] + ',"packages":[\n' + body + "\n]}\n" - os.makedirs(os.path.dirname(path), exist_ok=True) - with open(path, "w", encoding="utf-8", newline="\n") as handle: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", encoding="utf-8", newline="\n") as handle: handle.write(text) -def main() -> None: - """Build one distro and report what came out.""" - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("distro", help="ROS distro to build the table for") - parser.add_argument("channel", help="conda channel name, or a full base URL") - args = parser.parse_args() - - print(f"{args.distro} ({args.channel}):", file=sys.stderr) - document = build(args.distro, args.channel) +def refresh(distro: str, channel: str) -> None: + """Build one distro, write it, and report what came out.""" + print(f"{distro} ({channel}):", file=sys.stderr) + document = build(distro, channel) - path = os.path.join("public", "data", f"{args.distro}.json") + path = Path("public") / "data" / f"{distro}.json" write(document, path) total = len(document["packages"]) @@ -378,10 +418,44 @@ def main() -> None: ever = sum(1 for p in document["packages"] if any(p[6])) print( f" -> {path}: {total} packages, {ever} built at some point, " - f"{on_newest} on mutex {newest}, {os.path.getsize(path) / 1e6:.2f} MB", + f"{on_newest} on mutex {newest}, {path.stat().st_size / 1e6:.2f} MB", file=sys.stderr, ) +def main() -> None: + """Build one distro, or every distro the pipeline maintains.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("distro", nargs="?", help="ROS distro to build the table for") + parser.add_argument("channel", nargs="?", help="conda channel name, or a full base URL") + parser.add_argument( + "--all", + action="store_true", + help="regenerate every distro with a dataChannel in src/data/distros.json", + ) + args = parser.parse_args() + + if args.all == bool(args.distro) or (args.distro and not args.channel): + parser.error("pass either or --all") + + if not args.all: + refresh(args.distro, args.channel) + return + + # One distro failing must not take the other five down with it: finish the + # loop, keep whatever succeeded, and only then report the failures. + failed: list[str] = [] + for entry in json.loads(DISTROS_JSON.read_text())["distros"]: + if not entry["dataChannel"]: + continue + try: + refresh(entry["name"], entry["dataChannel"]) + except Exception as error: # noqa: BLE001 - reported and folded into the exit code + print(f" error: {entry['name']} failed: {error}", file=sys.stderr) + failed.append(entry["name"]) + if failed: + sys.exit(f"failed to update: {', '.join(failed)}") + + if __name__ == "__main__": main() diff --git a/scripts/copy-to-distro-specific-channel.py b/scripts/copy-to-distro-specific-channel.py index c4228554..0f3137b1 100644 --- a/scripts/copy-to-distro-specific-channel.py +++ b/scripts/copy-to-distro-specific-channel.py @@ -3,6 +3,7 @@ import subprocess import niquests +from urllib3.util.retry import Retry # Configuration BASE_URL = "https://conda.anaconda.org" @@ -17,19 +18,26 @@ "emscripten-wasm32", ] +session = niquests.Session( + retries=Retry(total=5, backoff_factor=1, status_forcelist=[429, 500, 502, 503, 504]) +) -def fetch_repodata(channel: str, platform: str) -> dict | None: + +def fetch_repodata(channel: str, platform: str) -> dict: """ Fetch the repodata.json file from a given channel and platform. + + A 404 means the channel has no such platform yet and reads as empty. Anything + else raises: treating a transient error as an empty channel would make the run + copy nothing and still report success. """ url = f"{BASE_URL}/{channel}/{platform}/repodata.json" - response = niquests.get(url) + response = session.get(url) - if response.status_code == 200: - return response.json() - else: - print(f"Error fetching repodata.json from {channel}/{platform}: {response.status_code}") - return None + if response.status_code == 404: + return {} + response.raise_for_status() + return response.json() or {} def upload_package( @@ -106,8 +114,8 @@ def main() -> None: source_repodata = {} destination_repodata = {} for platform in PLATFORMS: - source_repodata[platform] = fetch_repodata(SOURCE_CHANNEL, platform) or {} - destination_repodata[platform] = fetch_repodata(destination_channel, platform) or {} + source_repodata[platform] = fetch_repodata(SOURCE_CHANNEL, platform) + destination_repodata[platform] = fetch_repodata(destination_channel, platform) # Process packages for each platform for platform in PLATFORMS: diff --git a/src/components/PackageTable.svelte b/src/components/PackageTable.svelte index d6583548..88457f61 100644 --- a/src/components/PackageTable.svelte +++ b/src/components/PackageTable.svelte @@ -54,17 +54,8 @@ * for that mutex, otherwise a platform bitmask plus the version built. */ type BuildSlot = 0 | [number, string]; - /* name, description, license, index version, last-built timestamp, index - * into doc.repos (-1 for none), build slots. */ - type PackageEntry = [ - string, - string, - string, - string, - number, - number, - BuildSlot[], - ]; + /* Positional; doc.fields names the positions. */ + type PackageEntry = (string | number | BuildSlot[])[]; interface Doc { distro: string; @@ -72,6 +63,7 @@ platforms: string[]; mutexPackage: string; mutexes: string[]; + fields: string[]; repos: string[]; packages: PackageEntry[]; } @@ -84,10 +76,12 @@ interface Row { name: string; desc: string; - license: string; indexVersion: string; updated: number; repo: string; + /* Released into the ROS index. False for packages that only exist on the + * channel: no description, index version or source repository. */ + indexed: boolean; builds: BuildSlot[]; haystack: string; } @@ -160,18 +154,32 @@ const mutexes = $derived(doc?.mutexes ?? []); const currentMutex = $derived(mutexes[mutex] ?? ""); - const all: Row[] = $derived( - (doc?.packages ?? []).map((pkg) => ({ - name: pkg[0], - desc: pkg[1], - license: pkg[2], - indexVersion: pkg[3], - updated: pkg[4], - repo: pkg[5] >= 0 ? (doc?.repos[pkg[5]] ?? "") : "", - builds: pkg[6], // aligned with doc.mutexes - haystack: (pkg[0] + " " + pkg[1]).toLowerCase(), - })), - ); + /* The JSON is positional and the head's `fields` list names the positions, + * so unpacking goes through it instead of hard-coded indices. That keeps + * the committed end-of-life snapshots working across schema changes: they + * still carry a `license` field and predate `indexed`, which defaults to + * released-into-the-index for them. */ + const all: Row[] = $derived.by(() => { + const data = doc; + if (!data) return []; + const at: Record = {}; + data.fields.forEach((field, i) => (at[field] = i)); + return data.packages.map((pkg) => { + const name = pkg[at.name] as string; + const desc = (pkg[at.desc] ?? "") as string; + const repo = pkg[at.repo] as number; + return { + name, + desc, + indexVersion: (pkg[at.indexVersion] ?? "") as string, + updated: (pkg[at.updated] ?? 0) as number, + repo: repo >= 0 ? (data.repos[repo] ?? "") : "", + indexed: at.indexed === undefined || Boolean(pkg[at.indexed]), + builds: pkg[at.builds] as BuildSlot[], // aligned with doc.mutexes + haystack: (name + " " + desc).toLowerCase(), + }; + }); + }); /* Everything that depends on the selected mutex, derived in one pass: * O(n) over 2,300 rows, which is far cheaper than re-fetching. */ @@ -252,7 +260,16 @@ older: rows.filter((r) => !r.mask && r.older.length).length, }; - return { rows, active, counts }; + // The summary figure is "how much of the ROS index is on RoboStack", so + // channel-only packages count in the table but not in this ratio. + const indexed = rows.filter((r) => r.indexed); + return { + rows, + active, + counts, + indexTotal: indexed.length, + indexAvailable: indexed.filter((r) => r.built > 0).length, + }; }); const active = $derived(mutexData.active); @@ -311,19 +328,20 @@ const slice = $derived(rows.slice(first, last)); const padBottom = $derived(rows.length - last); - const available = $derived((counts.full ?? 0) + (counts.partial ?? 0)); + const indexTotal = $derived(mutexData.indexTotal); + const indexAvailable = $derived(mutexData.indexAvailable); const percent = $derived( - all.length ? Math.round((available / all.length) * 100) : 0, + indexTotal ? Math.round((indexAvailable / indexTotal) * 100) : 0, ); // Behind-index packages are a subset of the available ones (a package needs // a version on the channel before it can be compared), so the bar splits // the filled portion rather than adding to it. Unrounded widths, so the two // segments cannot drift apart from the total. const availablePct = $derived( - all.length ? (available / all.length) * 100 : 0, + indexTotal ? (indexAvailable / indexTotal) * 100 : 0, ); const behindPct = $derived( - all.length ? ((counts.behind ?? 0) / all.length) * 100 : 0, + indexTotal ? ((counts.behind ?? 0) / indexTotal) * 100 : 0, ); const currentPct = $derived(Math.max(0, availablePct - behindPct)); @@ -475,7 +493,7 @@ @@ -528,7 +546,7 @@ autocomplete="off" spellcheck="false" aria-label="Search packages" - placeholder="Search {all.length} index packages…" + placeholder="Search {all.length} packages…" bind:value={query} bind:this={searchEl} /> @@ -563,7 +581,7 @@

Showing {rows.length.toLocaleString()} of {all.length.toLocaleString()} - index packages. {#if hiddenPlatforms.length} {hiddenPlatforms.join(", ")} hidden: nothing built for this mutex. @@ -674,14 +692,17 @@ "channel", )} {/if} - {@render extLink( - "https://index.ros.org/p/" + - encodeURIComponent(rosName) + - "/#" + - distro, - rosName + " on the ROS index", - "docs", - )} + + {#if row.indexed} + {@render extLink( + "https://index.ros.org/p/" + + encodeURIComponent(rosName) + + "/#" + + distro, + rosName + " on the ROS index", + "docs", + )} + {/if} {#if row.repo} {@render extLink( row.repo, diff --git a/src/data/distros.json b/src/data/distros.json new file mode 100644 index 00000000..1ea6f791 --- /dev/null +++ b/src/data/distros.json @@ -0,0 +1,100 @@ +{ + "platforms": [ + "linux-64", + "linux-aarch64", + "osx-64", + "osx-arm64", + "win-64", + "emscripten-wasm32" + ], + "distros": [ + { + "name": "noetic", + "ros": 1, + "channel": "robostack-noetic", + "base": "prefix", + "status": "eol", + "released": "2020-05", + "eol": "2025-05", + "dataChannel": "robostack-noetic", + "lts": true + }, + { + "name": "foxy", + "ros": 2, + "channel": "robostack", + "base": "prefix", + "status": "eol", + "released": "2020-05", + "eol": "2023-05", + "dataChannel": null, + "lts": true + }, + { + "name": "galactic", + "ros": 2, + "channel": "robostack-experimental", + "base": "anaconda", + "status": "eol", + "released": "2021-05", + "eol": "2022-11", + "dataChannel": null, + "lts": false + }, + { + "name": "humble", + "ros": 2, + "channel": "robostack-humble", + "base": "prefix", + "status": "active", + "released": "2022-05", + "eol": "2027-05", + "dataChannel": "robostack-humble", + "lts": true + }, + { + "name": "jazzy", + "ros": 2, + "channel": "robostack-jazzy", + "base": "prefix", + "status": "active", + "released": "2024-05", + "eol": "2029-05", + "dataChannel": "robostack-jazzy", + "lts": true + }, + { + "name": "kilted", + "ros": 2, + "channel": "robostack-kilted", + "base": "prefix", + "status": "active", + "released": "2025-05", + "eol": "2026-11", + "dataChannel": "robostack-kilted", + "lts": false + }, + { + "name": "lyrical", + "ros": 2, + "channel": "robostack-lyrical", + "base": "prefix", + "status": "active", + "released": "2026-05", + "eol": "2031-05", + "dataChannel": "https://prefix.dev/robostack-lyrical", + "lts": true + }, + { + "name": "rolling", + "ros": 2, + "channel": "robostack-rolling", + "base": "prefix", + "status": "rolling", + "released": "2020-06", + "eol": null, + "dataChannel": "https://prefix.dev/robostack-rolling", + "lts": false + } + ] +} diff --git a/src/data/distros.ts b/src/data/distros.ts index 6a767b3f..4fea4318 100644 --- a/src/data/distros.ts +++ b/src/data/distros.ts @@ -1,3 +1,5 @@ +import distrosJson from "./distros.json"; + /** * One RoboStack channel and the ROS release behind it. * @@ -13,10 +15,17 @@ export interface Distro { base: typeof PREFIX | typeof ANACONDA; /** rosdistro's own vocabulary from index-v4.yaml: rolling, active or eol. */ status: "rolling" | "active" | "eol"; + /** Long-term support release: the even-year May releases per REP-2000. */ + lts: boolean; /** YYYY-MM. */ released: string; /** YYYY-MM, or null when the support window is not published yet. */ eol: string | null; + /** + * The pipeline still rebuilds this distro's table (it has a `dataChannel` + * in distros.json). False for the frozen end-of-life snapshots. + */ + maintained: boolean; } // Channel bases, as passed to `pixi workspace channel add`. @@ -30,89 +39,35 @@ const BROWSE = { [ANACONDA]: (channel: string) => `https://anaconda.org/${channel}`, }; +// The list itself lives in distros.json, which the table pipeline +// (`scripts/compare_pkg_completeness.py --all`) reads too: its `dataChannel` +// is the channel repodata is fetched from, null for the end-of-life distros +// whose `public/data/.json` is a committed snapshot. The platform list +// is shared the same way; its order is the pipeline's bitmask order. +// // Dates come from REP-2000 for ROS 2 and REP-3 for ROS 1, not from the actual -// tag dates, so the pages agree with what ROS itself documents. +// tag dates, so the pages agree with what ROS itself documents. Lyrical is +// not in REP-2000 yet; May 2031 follows the established cadence of five-year +// LTS windows for even-year releases. // // Galactic is the odd one out for `base`: `robostack-experimental` 404s on // prefix.dev, so it points at anaconda.org. -export const DISTROS: Distro[] = [ - { - name: "noetic", - ros: 1, - channel: "robostack-noetic", - base: PREFIX, - status: "eol", - released: "2020-05", - eol: "2025-05", - }, - { - name: "foxy", - ros: 2, - channel: "robostack", - base: PREFIX, - status: "eol", - released: "2020-05", - eol: "2023-05", - }, - { - name: "galactic", - ros: 2, - channel: "robostack-experimental", - base: ANACONDA, - status: "eol", - released: "2021-05", - eol: "2022-11", - }, - { - name: "humble", - ros: 2, - channel: "robostack-humble", - base: PREFIX, - status: "active", - released: "2022-05", - eol: "2027-05", - }, - { - name: "jazzy", - ros: 2, - channel: "robostack-jazzy", - base: PREFIX, - status: "active", - released: "2024-05", - eol: "2029-05", - }, - { - name: "kilted", - ros: 2, - channel: "robostack-kilted", - base: PREFIX, - status: "active", - released: "2025-05", - eol: "2026-11", - }, - // Lyrical is not in REP-2000 yet. May 2031 follows the established cadence: - // even-year releases are LTS with five years of support (Humble 2022-2027, - // Jazzy 2024-2029), odd-year ones get eighteen months (Kilted 2025-2026). - // Replace it with the published date once the REP lists it. - { - name: "lyrical", - ros: 2, - channel: "robostack-lyrical", - base: PREFIX, - status: "active", - released: "2026-05", - eol: "2031-05", - }, - { - name: "rolling", - ros: 2, - channel: "robostack-rolling", - base: PREFIX, - status: "rolling", - released: "2020-06", - eol: null, - }, -]; +const BASES = { prefix: PREFIX, anaconda: ANACONDA } as const; + +export const DISTROS: Distro[] = distrosJson.distros.map((d) => ({ + name: d.name, + ros: d.ros as Distro["ros"], + channel: d.channel, + base: BASES[d.base as keyof typeof BASES], + status: d.status as Distro["status"], + lts: d.lts, + released: d.released, + eol: d.eol, + maintained: d.dataChannel !== null, +})); + +/** Platforms the channels build for. */ +export const PLATFORMS: string[] = distrosJson.platforms; const MONTHS = [ "January", @@ -174,3 +129,13 @@ export function tabOrder(distros: Distro[] = DISTROS): Distro[] { .sort((a, b) => b.released.localeCompare(a.released) || b.ros - a.ros); return [...rolling, ...dated]; } + +/** + * The newest dated release: the second tab on the distro pages, after + * rolling. The site recommends no distro; links into the package tables and + * the example commands simply land here, and move on their own when the next + * release enters `DISTROS`. + */ +export function newestRelease(): Distro { + return tabOrder()[1]; +} From 58f090754bc54120e18403d42a482f698325cc0b Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Mon, 10 Aug 2026 12:50:02 +0000 Subject: [PATCH 2/4] fix: stop recommending a distro and polish the tables - All package links land on the newest release via `newestRelease()`; the hero terminal and the home distro cards derive from the same data, so a new release moves them on its own - Home: "Get started" is the primary CTA, cards lose the meaningless colored dots and the "best choice" wording, tab title no longer reads "RoboStack | RoboStack" - Table: search ranks exact > prefix > substring, the filter chips double as the legend instead of triplicating the counts, a "/" hint on the search box, one sentence explaining the distro mutex - Narrow screens collapse the platform columns into a tap-to-expand coverage pill with finger-sized targets - "ROS 2 Kilted" instead of "ROS2 Kilted", end-of-life tabs dimmed - Inline SVG icons move to `src/assets/icons/*.svg`, painted with `currentColor` --- astro.config.mjs | 3 +- src/assets/icons/bolt.svg | 14 ++ src/assets/icons/book.svg | 17 ++ src/assets/icons/chat.svg | 16 ++ src/assets/icons/copy.svg | 15 ++ src/assets/icons/layers.svg | 16 ++ src/assets/icons/screen.svg | 15 ++ src/assets/icons/search.svg | 12 + src/assets/icons/shield.svg | 15 ++ src/components/DistroHead.astro | 2 +- src/components/DistroTabs.astro | 13 +- src/components/PackageTable.svelte | 304 ++++++++++++++++++----- src/components/home/ClosingCta.astro | 58 +++++ src/components/home/Contribute.astro | 72 ++++++ src/components/home/DistroCard.astro | 28 +-- src/components/home/DistroCards.astro | 75 ++++++ src/components/home/PropCard.astro | 20 +- src/components/home/QuickStart.astro | 38 +-- src/components/home/Stats.astro | 102 ++++++++ src/pages/[distro].astro | 2 +- src/pages/index.astro | 338 +++----------------------- src/styles/custom.css | 18 ++ 22 files changed, 766 insertions(+), 427 deletions(-) create mode 100644 src/assets/icons/bolt.svg create mode 100644 src/assets/icons/book.svg create mode 100644 src/assets/icons/chat.svg create mode 100644 src/assets/icons/copy.svg create mode 100644 src/assets/icons/layers.svg create mode 100644 src/assets/icons/screen.svg create mode 100644 src/assets/icons/search.svg create mode 100644 src/assets/icons/shield.svg create mode 100644 src/components/home/ClosingCta.astro create mode 100644 src/components/home/Contribute.astro create mode 100644 src/components/home/DistroCards.astro create mode 100644 src/components/home/Stats.astro diff --git a/astro.config.mjs b/astro.config.mjs index 457aacfa..c5b956ee 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -2,6 +2,7 @@ import { defineConfig } from "astro/config"; import starlight from "@astrojs/starlight"; import svelte from "@astrojs/svelte"; +import { newestRelease } from "./src/data/distros.ts"; export default defineConfig({ site: "https://robostack.github.io", @@ -46,7 +47,7 @@ export default defineConfig({ { label: "Conda", slug: "conda" }, ], }, - { label: "Packages", link: "/lyrical.html" }, + { label: "Packages", link: `/${newestRelease().name}.html` }, { label: "JupyterRos", slug: "JupyterRos" }, { label: "Support", slug: "support" }, { label: "Contributing", slug: "Contributing" }, diff --git a/src/assets/icons/bolt.svg b/src/assets/icons/bolt.svg new file mode 100644 index 00000000..6be7d091 --- /dev/null +++ b/src/assets/icons/bolt.svg @@ -0,0 +1,14 @@ + diff --git a/src/assets/icons/book.svg b/src/assets/icons/book.svg new file mode 100644 index 00000000..5c571a71 --- /dev/null +++ b/src/assets/icons/book.svg @@ -0,0 +1,17 @@ + diff --git a/src/assets/icons/chat.svg b/src/assets/icons/chat.svg new file mode 100644 index 00000000..b05acb80 --- /dev/null +++ b/src/assets/icons/chat.svg @@ -0,0 +1,16 @@ + diff --git a/src/assets/icons/copy.svg b/src/assets/icons/copy.svg new file mode 100644 index 00000000..c71bf587 --- /dev/null +++ b/src/assets/icons/copy.svg @@ -0,0 +1,15 @@ + diff --git a/src/assets/icons/layers.svg b/src/assets/icons/layers.svg new file mode 100644 index 00000000..99da13d6 --- /dev/null +++ b/src/assets/icons/layers.svg @@ -0,0 +1,16 @@ + diff --git a/src/assets/icons/screen.svg b/src/assets/icons/screen.svg new file mode 100644 index 00000000..cdc20691 --- /dev/null +++ b/src/assets/icons/screen.svg @@ -0,0 +1,15 @@ + diff --git a/src/assets/icons/search.svg b/src/assets/icons/search.svg new file mode 100644 index 00000000..da5d05e1 --- /dev/null +++ b/src/assets/icons/search.svg @@ -0,0 +1,12 @@ + diff --git a/src/assets/icons/shield.svg b/src/assets/icons/shield.svg new file mode 100644 index 00000000..4c6c3025 --- /dev/null +++ b/src/assets/icons/shield.svg @@ -0,0 +1,15 @@ + diff --git a/src/components/DistroHead.astro b/src/components/DistroHead.astro index e006e4cb..d312311e 100644 --- a/src/components/DistroHead.astro +++ b/src/components/DistroHead.astro @@ -19,7 +19,7 @@ const { distro } = Astro.props; height="96" />

-

ROS{distro.ros} {title(distro)}

+

ROS {distro.ros} {title(distro)}

{supportLine(distro)}

diff --git a/src/components/DistroTabs.astro b/src/components/DistroTabs.astro index e2250533..d9c8d1fa 100644 --- a/src/components/DistroTabs.astro +++ b/src/components/DistroTabs.astro @@ -12,9 +12,16 @@ const { current } = Astro.props; { tabOrder().map((d) => ( {d.name} @@ -50,6 +57,10 @@ const { current } = Astro.props; .distro:hover { color: var(--sl-color-white); } + /* End-of-life releases stay reachable but recede; the active tab wins. */ + .distro--eol:not(.distro--active) { + color: var(--sl-color-gray-4); + } .distro--active { color: var(--sl-color-white); border-bottom-color: var(--sl-color-white); diff --git a/src/components/PackageTable.svelte b/src/components/PackageTable.svelte index 88457f61..a9eee34a 100644 --- a/src/components/PackageTable.svelte +++ b/src/components/PackageTable.svelte @@ -115,6 +115,9 @@ let filter = $state("all"); let sort = $state("name"); let mutex = $state(0); + /* Narrow screens collapse the platform columns into a per-row pill; this + * is the row whose platform detail is currently expanded under it. */ + let expanded = $state(""); // Windowing state, driven by the scroll handler. let first = $state(0); @@ -321,7 +324,21 @@ const filtered = mutexData.rows.filter( (row) => matchesFilter(row, filter) && (!q || row.haystack.includes(q)), ); - return filtered.sort(SORTERS[sort] ?? SORTERS.name); + const sorter = SORTERS[sort] ?? SORTERS.name; + if (!q) return filtered.sort(sorter); + /* While searching, relevance outranks the chosen sort: exact name match, + * then name prefix, then name substring, then matches only in the + * description. Without this, "moveit" surfaces + * dual-arm-panda-moveit-config before moveit itself. */ + const tier = (row: MutexRow): number => + row.name === q + ? 0 + : row.name.startsWith(q) + ? 1 + : row.name.includes(q) + ? 2 + : 3; + return filtered.sort((a, b) => tier(a) - tier(b) || sorter(a, b)); }); const last = $derived(Math.min(rows.length, first + visibleCount)); @@ -376,10 +393,11 @@ /* The stylesheet declares the row height, but td padding can outweigh it, * and zoom or a font change shifts it again. Measuring a real row keeps the - * padding rows honest whatever the CSS ends up doing. */ + * padding rows honest whatever the CSS ends up doing. An expanded row is + * deliberately taller than the rest, so it must not be the sample. */ $effect(() => { void slice; - const row = tbodyEl?.querySelector("tr:not(.rs-pad)"); + const row = tbodyEl?.querySelector("tr:not(.rs-pad):not(.rs-open)"); if (!row) return; const measured = Math.round(row.getBoundingClientRect().height); if (measured && measured !== rowHeight) rowHeight = measured; @@ -503,16 +521,6 @@ title="{counts.behind} packages older than the version the ROS index released" > -

- {counts.full} on every platform - {counts.partial} partial - {counts.missing} not on channel - of those, {counts.behind} behind the index -

{#if mutexes.length}

{/if}
- + + + + + + {counts[f.id]} {/each}
@@ -579,10 +601,14 @@

- Showing {rows.length.toLocaleString()} of {all.length.toLocaleString()} - packages. + + {#if rows.length !== all.length} + Showing {rows.length.toLocaleString()} of {all.length.toLocaleString()} + packages. + {/if} {#if hiddenPlatforms.length} {hiddenPlatforms.join(", ")} hidden: nothing built for this mutex. {/if} @@ -593,7 +619,7 @@ {#each active as p (p.id)} - + {/each} @@ -622,7 +648,10 @@ {@const rosName = row.name.replace(/-/g, "_")} - + {/if} + + {row.desc || "-"} + {#if expanded === row.name} + + {#each active as p (p.id)} + {@const meta = PLATFORMS[p.id] ?? { + icon: "linux", + arch: "", + }} + {@const on = ((row.mask >> p.bit) & 1) === 1} + + {@render iconSpan(meta.icon, 12)}{meta.arch} + {on ? "✓" : "·"} + + {/each} + + {/if} {#each active as p (p.id)} {@const on = ((row.mask >> p.bit) & 1) === 1} @@ -844,31 +909,6 @@ .rs-bar__behind { background: var(--rs-warn-fg); } - .rs-summary__legend { - display: flex; - flex-wrap: wrap; - gap: 1.1rem; - margin: 0; - font-size: 0.8rem; - font-variant-numeric: tabular-nums; - } - .rs-key--full { - color: var(--rs-yes-fg); - } - .rs-key--partial { - color: var(--rs-warn-fg); - } - .rs-key--missing { - color: var(--sl-color-gray-3); - } - /* Overlaps the three above rather than extending them, so it is set apart - by a rule and phrased as a subset. */ - .rs-key--behind { - color: var(--rs-warn-fg); - padding-left: 1.1rem; - border-left: 1px solid var(--sl-color-gray-5); - } - /* Mutex picker: availability is only meaningful against one of these, so it sits inside the summary card rather than among the table controls. */ .rs-mutex { @@ -893,6 +933,15 @@ .rs-mutex__note--up { color: var(--rs-up-fg); } + /* "ros2-distro-mutex" is channel jargon; one muted sentence keeps the + picker from being a mystery dropdown. */ + .rs-mutex__hint { + flex-basis: 100%; + color: var(--sl-color-gray-3); + } + .rs-mutex__hint code { + font-size: 0.95em; + } /* ---- toolbar ----------------------------------------------------------- */ .rs-tools { @@ -921,10 +970,40 @@ width, which clamps the flex base size upward and pushes the whole line over the limit. The filters and sort were bumped to a second row even when there was room for everything. */ - .rs-tools input { + .rs-search { + position: relative; + display: flex; flex: 1 1 12rem; min-width: 0; } + .rs-search input { + flex: 1; + min-width: 0; + } + /* Advertises the "/" shortcut. Gone while typing, and gone entirely on + touch devices, where there is no key to press. */ + .rs-slash { + position: absolute; + right: 0.5rem; + top: 50%; + transform: translateY(-50%); + padding: 0.05em 0.45em; + border: 1px solid var(--sl-color-gray-5); + border-radius: 0.25rem; + font-family: inherit; + font-size: 0.75rem; + color: var(--sl-color-gray-3); + pointer-events: none; + } + .rs-search:focus-within .rs-slash, + .rs-search input:not(:placeholder-shown) ~ .rs-slash { + display: none; + } + @media (hover: none) and (pointer: coarse) { + .rs-slash { + display: none; + } + } /* Filters and sort wrap as one unit, so the sort control keeps its place next to them: when the row runs out of space the pair moves below the search box together, rather than the sort dropping off on its own. Within @@ -968,6 +1047,30 @@ border-color: var(--sl-color-accent); color: var(--sl-color-black); } + /* The chip counts double as the colour legend: the same hues the + availability dots use. On the active chip the accent background takes + over and the count follows the label. */ + .rs-chipcount { + font-variant-numeric: tabular-nums; + } + .rs-chipcount--full { + color: var(--rs-yes-fg); + } + .rs-chipcount--partial { + color: var(--rs-warn-fg); + } + .rs-chipcount--missing { + color: var(--sl-color-gray-3); + } + .rs-chipcount--behind { + color: var(--rs-warn-fg); + } + .rs-chipcount--upgrade { + color: var(--rs-up-fg); + } + .rs-filter--on .rs-chipcount { + color: inherit; + } .rs-count { margin: 0 0 0.6rem; @@ -997,6 +1100,12 @@ font-size: inherit; border-collapse: collapse; } + /* The fixed layout takes column widths from the elements, so this is + where the platform columns get their 5rem - and where the narrow-screen + block can take it back, which hiding the cells alone would not. */ + .rs-packages col.rs-col-plat { + width: 5rem; + } .rs-packages th { padding: 0.6em 0.9em; white-space: nowrap; @@ -1224,6 +1333,12 @@ color: var(--sl-color-black); background: var(--sl-color-accent); } + /* Coarse pointers need more than a 21px square to hit. */ + @media (hover: none) and (pointer: coarse) { + .rs-link { + padding: 0.5rem 0.45rem; + } + } /* Icons are masks rather than images, so they inherit the text colour: the same file serves a muted column header and a link that inverts on hover. */ .rs-icon { @@ -1291,6 +1406,85 @@ color: var(--sl-color-gray-3); } + /* ---- narrow screens ---------------------------------------------------- */ + /* Six ~5rem availability columns cannot fit next to the package cell, so + below 48rem they collapse into one coverage pill per row that expands + into a per-platform list. Desktop never shows the pill. */ + .rs-pill, + .rs-detail { + display: none; + } + @media screen and (max-width: 48rem) { + .rs-packages thead th:not(:first-child), + .rs-packages tbody td:not(:first-child) { + display: none; + } + /* Only one column left; scrolling sideways would reveal nothing. The + hidden columns must also give up their width, or the fixed + layout keeps reserving 5rem apiece for them. */ + .rs-packages table { + min-width: 0; + } + .rs-packages col.rs-col-plat { + width: 0; + } + /* Competes with the pill for the right edge; the Contributing page + remains reachable through the missing-package notice on desktop. */ + .rs-add { + display: none; + } + /* Third grid column for the pill, and room to grow for the expanded + platform list. The row height floor stays where it was. */ + .rs-packages tbody td:first-child { + grid-template-columns: auto minmax(0, 1fr) auto; + height: auto; + min-height: var(--rs-row-h); + } + .rs-pill { + display: inline-flex; + align-items: center; + justify-content: center; + grid-column: 3; + grid-row: 1 / span 2; + align-self: center; + min-height: 2.75rem; /* a finger-sized target */ + min-width: 3.2rem; + padding: 0 0.7em; + border: 1px solid var(--sl-color-gray-5); + border-radius: 2rem; + background: var(--sl-color-bg); + color: var(--sl-color-gray-2); + font: inherit; + font-size: 0.8rem; + font-variant-numeric: tabular-nums; + cursor: pointer; + } + .rs-pill[aria-expanded="true"] { + border-color: var(--sl-color-accent); + color: var(--sl-color-accent); + } + .rs-detail { + display: flex; + flex-wrap: wrap; + gap: 0.4rem 0.9rem; + grid-column: 2 / 4; + grid-row: 3; + padding: 0.3rem 0 0.6rem; + font-size: 0.8rem; + } + .rs-det { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } + .rs-det--yes { + color: var(--rs-yes-fg); + } + .rs-det--no { + color: var(--sl-color-gray-4); + } + } + /* ---- notices ----------------------------------------------------------- */ .rs-notice { border: 1px solid var(--sl-color-gray-5); diff --git a/src/components/home/ClosingCta.astro b/src/components/home/ClosingCta.astro new file mode 100644 index 00000000..1a54b25c --- /dev/null +++ b/src/components/home/ClosingCta.astro @@ -0,0 +1,58 @@ +--- +import RsButton from "./RsButton.astro"; + +interface Props { + packagesHref: string; +} + +const { packagesHref } = Astro.props; +--- + +

+

Your robot doesn't care what OS you run

+

+ Four commands from an empty folder to rviz on your machine - whichever + machine that is. +

+
+ Get started + Browse the packages +
+
+ + diff --git a/src/components/home/Contribute.astro b/src/components/home/Contribute.astro new file mode 100644 index 00000000..2c820c5d --- /dev/null +++ b/src/components/home/Contribute.astro @@ -0,0 +1,72 @@ +--- +import RsButton from "./RsButton.astro"; +--- + +
+
+

Missing a package? Add it in one line

+

+ { + /* The space before the code chip is explicit: Astro drops an + indented line break instead of collapsing it to a space. */ + } + Most packages join RoboStack as a one-line pull request: put the name in a{ + " " + } + vinca.yaml and CI builds it for every platform. +

+
+ Add a package → +
+ + diff --git a/src/components/home/DistroCard.astro b/src/components/home/DistroCard.astro index c4f01147..3ee8351a 100644 --- a/src/components/home/DistroCard.astro +++ b/src/components/home/DistroCard.astro @@ -2,22 +2,16 @@ interface Props { href: string; channel: string; - /** CSS color for the channel dot. */ - dot: string; name: string; description: string; } -const { href, channel, dot, name, description } = Astro.props; +const { href, channel, name, description } = Astro.props; --- - +
- {channel} + {channel}
{name}
{description}
@@ -28,19 +22,12 @@ const { href, channel, dot, name, description } = Astro.props; .card { display: block; padding: 22px; - background: var(--bg-surface); - border: 1px solid var(--border-1); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); color: var(--fg-1); text-decoration: none; - transition: - box-shadow var(--dur-3) var(--ease-out), - transform var(--dur-3) var(--ease-out); + transition: box-shadow var(--dur-3) var(--ease-out); } .card:hover { box-shadow: var(--shadow-hover-accent); - transform: translateY(-2px); text-decoration: none; } .top { @@ -54,13 +41,6 @@ const { href, channel, dot, name, description } = Astro.props; align-items: center; gap: 10px; } - .dot { - width: 12px; - height: 12px; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; - } .channel code { font-family: var(--font-mono); font-size: 14px; diff --git a/src/components/home/DistroCards.astro b/src/components/home/DistroCards.astro new file mode 100644 index 00000000..aab424ec --- /dev/null +++ b/src/components/home/DistroCards.astro @@ -0,0 +1,75 @@ +--- +import type { Distro } from "../../data/distros"; +import { monthYear, tabOrder, title } from "../../data/distros"; +import DistroCard from "./DistroCard.astro"; + +/** + * The three distro cards, derived so a new release reshuffles them on its + * own: the two newest releases, then the newest LTS after them. The + * descriptions come from the same data: LTS or not, and the support window. + */ +const dated = tabOrder().filter((d) => d.status !== "rolling"); +const [newest, previous] = dated; +const olderLts = dated.slice(2).find((d) => d.lts && d.status === "active"); + +function supportSpan(d: Distro): string { + if (d.status === "eol") return `end of life since ${monthYear(d.eol ?? "")}`; + if (d.eol) return `supported until ${monthYear(d.eol)}`; + return "support window not yet published"; +} + +function kind(d: Distro): string { + return d.lts ? "LTS release" : "release"; +} + +const cards: { distro: Distro; description: string }[] = []; +if (newest) { + cards.push({ + distro: newest, + description: `The newest ${kind(newest)}, ${supportSpan(newest)}.`, + }); +} +if (previous) { + cards.push({ + distro: previous, + description: `The previous ${kind(previous)}, ${supportSpan(previous)}.`, + }); +} +if (olderLts) { + cards.push({ + distro: olderLts, + description: `An ${kind(olderLts)}, ${supportSpan(olderLts)}.`, + }); +} +--- + +
+ { + cards.map(({ distro, description }) => ( + + )) + } +
+ + diff --git a/src/components/home/PropCard.astro b/src/components/home/PropCard.astro index cb70bbcc..54742d09 100644 --- a/src/components/home/PropCard.astro +++ b/src/components/home/PropCard.astro @@ -14,7 +14,7 @@ const { title, href, linkLabel } = Astro.props; const Tag = href ? "a" : "div"; --- - +
{title}

@@ -26,21 +26,14 @@ const Tag = href ? "a" : "div"; position: relative; display: block; padding: 24px; - background: var(--bg-surface); - border: 1px solid var(--border-1); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); color: var(--fg-1); text-decoration: none; } a.card { - transition: - box-shadow var(--dur-3) var(--ease-out), - transform var(--dur-3) var(--ease-out); + transition: box-shadow var(--dur-3) var(--ease-out); } a.card:hover { box-shadow: var(--shadow-hover-accent); - transform: translateY(-2px); text-decoration: none; } .icon { @@ -52,13 +45,8 @@ const Tag = href ? "a" : "div"; align-items: center; justify-content: center; margin-bottom: 16px; - } - .icon :global(svg) { - fill: var(--fg-1); - } - .icon :global(svg.stroke) { - fill: none; - stroke: var(--fg-1); + /* The icon files in src/assets/icons paint with currentColor. */ + color: var(--fg-1); } .title { font-family: var(--font-display); diff --git a/src/components/home/QuickStart.astro b/src/components/home/QuickStart.astro index 15f22c04..77e11d63 100644 --- a/src/components/home/QuickStart.astro +++ b/src/components/home/QuickStart.astro @@ -1,14 +1,22 @@ --- -/** The hero's quick-start card: a mock terminal with a copy button. */ +import Copy from "../../assets/icons/copy.svg"; +import { newestRelease } from "../../data/distros"; + +/** + * The hero's quick-start card: a mock terminal with a copy button. The + * commands use the newest release as their example, matching where the + * package links land. + */ +const distro = newestRelease(); const commands = [ - "pixi init ros_ws -c https://prefix.dev/robostack-humble", + `pixi init ros_ws -c ${distro.base}/${distro.channel}`, "cd ros_ws", - "pixi add ros-humble-desktop", + `pixi add ros-${distro.name}-desktop`, "pixi run rviz2", ]; --- -
+
Quick start
@@ -19,18 +27,7 @@ const commands = [ terminal
@@ -52,17 +49,8 @@ const commands = [ diff --git a/src/pages/[distro].astro b/src/pages/[distro].astro index d6ac9cdc..31567f1a 100644 --- a/src/pages/[distro].astro +++ b/src/pages/[distro].astro @@ -26,7 +26,7 @@ const install = [ diff --git a/src/pages/index.astro b/src/pages/index.astro index 6726f1d4..bdd7380e 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,15 +1,32 @@ --- import StarlightPage from "@astrojs/starlight/components/StarlightPage.astro"; +import Bolt from "../assets/icons/bolt.svg"; +import Book from "../assets/icons/book.svg"; +import Chat from "../assets/icons/chat.svg"; +import Layers from "../assets/icons/layers.svg"; +import Screen from "../assets/icons/screen.svg"; +import Search from "../assets/icons/search.svg"; +import Shield from "../assets/icons/shield.svg"; import Citation from "../components/home/Citation.astro"; -import DistroCard from "../components/home/DistroCard.astro"; +import ClosingCta from "../components/home/ClosingCta.astro"; +import Contribute from "../components/home/Contribute.astro"; +import DistroCards from "../components/home/DistroCards.astro"; import PropCard from "../components/home/PropCard.astro"; import QuickStart from "../components/home/QuickStart.astro"; import RsButton from "../components/home/RsButton.astro"; +import Stats from "../components/home/Stats.astro"; +import { newestRelease } from "../data/distros"; + +// Package links land on the newest release's table; the site recommends no +// distro, so this is a landing page, not an endorsement. +const packagesHref = `/${newestRelease().name}.html`; --- | RoboStack"; the site name + as the page title would read "RoboStack | RoboStack". */ + title: "Manage ROS with ease", template: "splash", tableOfContents: false, }} @@ -28,9 +45,9 @@ import RsButton from "../components/home/RsButton.astro"; Linux, macOS and Windows.

- Browse the packages - Get startedGet started + Browse the packages
@@ -45,69 +62,22 @@ import RsButton from "../components/home/RsButton.astro";
- + Run any ROS distro on Fedora, Arch, macOS with Apple Silicon or Windows. The one-Ubuntu-version-per-distro rule simply doesn't apply here. - + Each project gets its own environment. Run two distros side by side, delete a folder and it's gone. No Docker, no dual-boot, no VM. - + Every package ships as a prebuilt binary. No compiling from source, no hunting down build dependencies - install and launch rviz. - + PyTorch, Jupyter and OpenCV from conda-forge live in the same environment as ROS. Robotics and machine learning, one pixi add apart. @@ -115,26 +85,7 @@ import RsButton from "../components/home/RsButton.astro";
-
-
-
4,800+
-
packages built across active distros
-
-
-
6
-
active ROS distros
-
-
-
6
-
- platforms, from linux-64 to Apple Silicon -
-
-
-
0
-
times you'll type sudo
-
-
+
@@ -143,52 +94,21 @@ import RsButton from "../components/home/RsButton.astro";
Channels

Pick your ROS distro

- All distros → -
- - - -
+
- + Every distro has a searchable package list showing exactly which packages are built for which platform - no guessing. @@ -197,20 +117,7 @@ import RsButton from "../components/home/RsButton.astro"; href="/GettingStarted.html" linkLabel="Get started" > - + A getting started guide that walks you from nothing to a running rviz, plus an FAQ for the sharp edges. @@ -219,20 +126,7 @@ import RsButton from "../components/home/RsButton.astro"; href="https://discord.gg/kKV8ZxyzY4" linkLabel="Join the Discord" > - + Stuck on something? The maintainers hang out in the robotics channel on prefix.dev's Discord and on GitHub. @@ -240,17 +134,7 @@ import RsButton from "../components/home/RsButton.astro";
-
-
-

Missing a package? Add it in one line

-

- Most packages join RoboStack as a one-line pull request: put the - name in a - vinca.yaml and CI builds it for every platform. -

-
- Add a package → -
+
@@ -258,19 +142,7 @@ import RsButton from "../components/home/RsButton.astro";
-
-

Your robot doesn't care what OS you run

-

- Four commands from an empty folder to rviz on your machine - whichever - machine that is. -

-
- Get started - Browse the packages -
-
+
@@ -318,21 +190,6 @@ import RsButton from "../components/home/RsButton.astro"; padding-bottom: 72px; } - .eyebrow { - font-size: 12px; - font-weight: 700; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--fg-2); - } - - .card { - background: var(--bg-surface); - border: 1px solid var(--border-1); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-card); - } - /* ---- Hero ---- */ .hero { display: grid; @@ -381,38 +238,6 @@ import RsButton from "../components/home/RsButton.astro"; border-radius: var(--radius-xs); } - /* ---- Stats strip ---- */ - .stats { - display: flex; - padding: 34px 24px; - } - .stat { - flex: 1; - text-align: center; - padding: 0 12px; - } - .stat + .stat { - border-left: 1px solid var(--border-1); - } - /* In dark the default card border tone matches the page background and the - dividers vanish; the stronger border keeps them readable. */ - :global([data-theme="dark"]) .stat + .stat { - border-left-color: var(--border-2); - } - .stat-num { - font-family: var(--font-display); - font-weight: 300; - font-size: 42px; - line-height: 1.1; - color: var(--fg-1); - } - .stat-label { - font-size: 13px; - color: var(--fg-2); - margin-top: 4px; - line-height: 1.4; - } - /* ---- Distros ---- */ .distros-head { display: flex; @@ -432,69 +257,6 @@ import RsButton from "../components/home/RsButton.astro"; gap: 16px; } - /* ---- Contribute banner ---- */ - /* A tinted card rather than a solid slab: the wash keeps the accent - present without a glowing block, and the button carries the CTA. */ - .contribute { - display: flex; - align-items: center; - justify-content: space-between; - gap: 20px 32px; - flex-wrap: wrap; - padding: 30px 34px; - background: rgba(46, 70, 216, 0.08); - border-color: rgba(46, 70, 216, 0.35); - } - :global([data-theme="dark"]) .contribute { - background: rgba(108, 133, 255, 0.1); - border-color: rgba(108, 133, 255, 0.35); - } - .contribute h2 { - font-size: 28px; - color: var(--fg-1); - margin-bottom: 6px; - } - .contribute p { - font-size: 15px; - line-height: 1.55; - color: var(--fg-copy); - margin: 0; - max-width: 620px; - } - .contribute p code { - font-family: var(--font-mono); - font-size: 13px; - background: rgba(0, 0, 0, 0.08); - padding: 1px 6px; - border-radius: var(--radius-xs); - } - :global([data-theme="dark"]) .contribute p code { - background: rgba(255, 255, 255, 0.1); - } - - /* ---- Closing CTA ---- */ - .cta { - text-align: center; - padding: 52px 32px; - } - .cta h2 { - font-size: 34px; - margin-bottom: 12px; - } - .cta-lead { - font-size: 17px; - color: var(--fg-copy); - margin: 0 auto 26px; - max-width: 480px; - line-height: 1.55; - } - .cta-actions { - display: flex; - gap: 12px; - justify-content: center; - flex-wrap: wrap; - } - @media screen and (max-width: 980px) { .hero { grid-template-columns: 1fr; @@ -510,16 +272,6 @@ import RsButton from "../components/home/RsButton.astro"; .grid3 { grid-template-columns: repeat(2, 1fr); } - .stats { - flex-wrap: wrap; - gap: 20px 0; - } - .stat { - flex-basis: 50%; - } - .stat:nth-child(3) { - border-left: none; - } } @media screen and (max-width: 720px) { .section { @@ -541,25 +293,5 @@ import RsButton from "../components/home/RsButton.astro"; .benefits-head h2 { font-size: 25px; } - .stats { - flex-direction: column; - gap: 20px; - padding: 28px 20px; - } - .stat + .stat { - border-left: none; - } - .contribute { - padding: 26px 20px; - } - .contribute h2 { - font-size: 24px; - } - .cta { - padding: 40px 20px; - } - .cta h2 { - font-size: 27px; - } } diff --git a/src/styles/custom.css b/src/styles/custom.css index 740b79d0..ddc09c0b 100644 --- a/src/styles/custom.css +++ b/src/styles/custom.css @@ -140,3 +140,21 @@ --border-1: var(--rs-dark-900); --border-2: var(--rs-dark-600); } + +/* ---------- Shared frontpage primitives ---------- + Two patterns every home component repeats: the card surface and the + small-caps kicker. One definition here; the components add their own + layout on top. */ +.rs-card { + background: var(--bg-surface); + border: 1px solid var(--border-1); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-card); +} +.eyebrow { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--fg-2); +} From 0307c9376036a2e62eb69af39d0e4694cba8adce Mon Sep 17 00:00:00 2001 From: Julian Hofer Date: Mon, 10 Aug 2026 13:09:54 +0000 Subject: [PATCH 3/4] Simplify comments that retold history Comments now describe what is there: the table comment explains the JSON-plus-windowing approach without the old static-Markdown story. --- src/components/PackageTable.svelte | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/components/PackageTable.svelte b/src/components/PackageTable.svelte index a9eee34a..7ff02713 100644 --- a/src/components/PackageTable.svelte +++ b/src/components/PackageTable.svelte @@ -1,9 +1,8 @@