diff --git a/DESIGN.md b/DESIGN.md index 4564d32..69c6c35 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -12,7 +12,7 @@ colors: text-primary: "#E2E8F0" text-heading: "#F1F5F9" text-muted: "#94A3B8" - text-dim: "#64748B" + text-dim: "#6B7C95" border: "#4C5C80" white: "#FFFFFF" typography: @@ -147,7 +147,7 @@ Hypershell is a personal homelab and connected-home environment. Its public webs The visual identity is **dark-only**, spacious and high-contrast. Neon pink, blue and cyan create energy and recognisability, but they are accents rather than background fills for large content areas. Structural clarity, readable content and responsive behaviour take precedence over decorative effects. -Spiny is the mascot and primary visual signature. The normal Spiny asset belongs in branding and the homepage hero; the dead Spiny variant belongs on the 404 page. Mascot artwork must retain its source aspect ratio and must not be recoloured, redrawn or visually distorted without an explicit design decision. +The accepted Hypershell H-core masterbrand is the official product identity and is used for header identity, favicon and installable web-app icon derivatives. Spiny is the mascot and personality-bearing visual signature: normal Spiny belongs in the homepage hero and related expressive artwork, while dead Spiny belongs on the 404 page. Mascot artwork must retain its source aspect ratio and must not be recoloured, redrawn or visually distorted without an explicit design decision. Web derivatives are generated from the accepted brand-workspace assets and record their source hashes in `src/data/brand-assets.json`; the derivatives never become a competing brand authority. This file records the implemented design system. Keep it synchronized with `src/styles.css` whenever normative visual values change. The website must remain static and must not acquire runtime third-party design dependencies merely to reproduce this system. @@ -204,7 +204,7 @@ Glass cards use a partially transparent elevated surface, a faint internal radia The page background combines three low-opacity radial accent fields with a faint technical grid. The grid must recede behind content and fade toward the bottom. It is environmental texture, not foreground decoration. -Use `backdrop-filter` only as progressive enhancement. Content must remain understandable when blur is unsupported. +Use `backdrop-filter` only as progressive enhancement. Content must remain understandable when blur is unsupported. **Accepted product constraint:** the glass blur treatment is intentionally retained at mobile widths on cards and appropriate mobile surfaces; do not propose disabling or reducing it merely as speculative performance polish. Reconsider it only on measured device/runtime evidence or an explicit new product request. ## Shapes @@ -228,25 +228,37 @@ Render both with `object-fit: contain`. The profile portrait uses a square conta ### Header and navigation -The header is transparent at the top and gains a translucent base-surface background, subtle border, shadow and blur after scrolling. Desktop navigation is centered. Mobile navigation uses a native `details` disclosure and appears as an elevated menu beneath the header, so it remains usable without JavaScript. +The header is transparent at the top and gains a translucent base-surface background, subtle border, shadow and blur after scrolling. The accepted H-core masterbrand is the header mark. Desktop navigation is centered. Mobile navigation uses a native `details` disclosure and appears as an elevated menu beneath the header, so it remains usable without JavaScript. -Active and hover navigation states use heading-colour text and a thin brand-gradient indicator. Keyboard focus uses a clearly visible cyan outline with offset. Closing the enhanced mobile menu with Escape returns focus to its summary control. +Active and hover navigation states use heading-colour text and a thin brand-gradient indicator. Keyboard focus uses a clearly visible cyan outline with offset. Closing the enhanced mobile menu with Escape returns focus to its summary control. **Accepted product constraint:** the public `Private dashboard` launcher intentionally remains in the public header as the operator shortcut into the authenticated dashboard. Do not propose removing or hiding it merely as generic security/privacy polish; reconsider only on new exposure evidence, a changed authentication boundary or an explicit product request. ### Hero -The hero centers Spiny, an uppercase eyebrow, the oversized Hypershell wordmark and concise explanatory copy. Keep one dominant visual focus. Decorative orbit rings and glows must remain subdued. +The hero centers Spiny, an uppercase eyebrow, the oversized Hypershell wordmark, one concise explanatory sentence and one short signature line. A compact three-part scope strip may show the build-derived domain, core-initiative and maintained-software counts as navigational context. The scope strip remains subordinate to the wordmark. Keep one dominant visual focus and keep decorative orbit rings and glows subdued. -Spiny floats slowly. On initial page load, two temporary colour-separated glitch layers may animate over the base image and must then stop. Do not run the glitch continuously. Respect `prefers-reduced-motion` by disabling glitch and reveal effects. +On initial page load, Spiny and the wordmark may each receive one temporary colour-separated glitch treatment as a short startup sequence. After startup, brief intermittent glitches may recur on either Spiny or the wordmark while the hero is visible; they must remain short, non-continuous, pause when the page or hero is not visible, and respect `prefers-reduced-motion` by disabling glitch and reveal effects. + +### Rationale section + +The **Why Hypershell** section appears after the public domain overview and before Architecture. It explains why the environment is treated as one evolving system rather than presenting more product inventory. Use one strong statement plus three restrained numbered principles; do not turn it into another card catalogue. ### Glass cards -Cards contain related information and use the shared glass treatment. Domain cards use a bordered icon tile, heading, concise description and optional tags. Project cards use small uppercase metadata, a status dot and a larger title. +Cards contain related information and use the shared glass treatment. Domain cards use a bordered icon tile, heading, concise purpose-oriented description and optional tags. Domain copy explains what each part enables; Architecture explains how responsibilities are organized. + +The public narrative order is **Inside the Lab → Why Hypershell → Architecture → Projects → About**. The Projects section separates **Core initiatives** from **Maintained software** and shows build-derived counts for both plus a combined project total. Core initiatives retain a spacious two-column treatment. Maintained software uses a denser three-column desktop grid, falls back to two columns on medium screens and one column on narrow screens, and may be filtered by safe curated categories. Filter controls are progressive enhancement: without JavaScript every project remains visible. + +Repository cards show Public or Private only as neutral visibility metadata; repository visibility must never reuse lifecycle-status colour semantics. Each card displays a safe curated category and a compact provenance label from the single presentation owner `src/data/project-presentation.json`; raw GitHub topics are not a public presentation source. That presentation file also owns the small explicit-include set, display-name exceptions, presentation order and activity exclusions without copying GitHub-owned descriptions, visibility or URLs. Public repository cards expose an explicit text GitHub action anchored at the lower-right of the card, while private repository URLs remain undisclosed. GitHub remains the source for repository name, description, visibility, URL and public release/update timestamps. + +Core and repository cards have stable fragment IDs and link to generated static `/projects//` detail pages. Those pages provide a shareable public project profile without exposing private repository URLs or private activity. The maintained-software filters remain progressive enhancement; on narrow/mobile layouts their filter bar stays sticky beneath the fixed header and the controls retain at least a 44px practical touch target. + +A compact Recent activity block may follow maintained software. It shows at most three public selected projects, preferring the latest public GitHub Release and falling back to the repository update timestamp when no release exists. Private repository activity is never surfaced, and the website repository itself is intentionally excluded to avoid self-referential activity noise. Hover effects enhance existing borders and glow; they must not move content enough to disturb reading or pointer targeting. ### Architecture diagram -The public architecture view is a simplified responsibility stack, not an infrastructure topology. Layers are rounded, low-contrast surfaces connected by cyan arrows. Selected boundary layers may receive the brand-gradient border treatment. +The public architecture view is a simplified responsibility stack, not an infrastructure topology. Layers are rounded, low-contrast surfaces connected by cyan arrows. Each row carries a restrained layer-purpose label so the hierarchy can be scanned before reading every capability. Selected boundary layers may receive the brand-gradient border treatment. Never expose internal addresses, ports, hostnames, credentials or a complete attack-relevant topology through this component. @@ -256,12 +268,25 @@ Primary-looking links use an elevated dark fill with a gradient border rather th Interactive touch targets should be at least 44px high where layout permits. All interactive elements require visible hover and keyboard-focus states. Avoid interactions that depend exclusively on hover. +### Public metadata + +The web-app manifest uses the Hypershell product name, H-core masterbrand derivatives and the dark `#050816` theme/background. Publish compact PNG/ICO favicon assets rather than wrapping a raster image in a large base64 SVG. `/.well-known/security.txt` provides the canonical public security contact and the build fails when its expiry is less than 90 days away. The homepage publishes a public-only `SoftwareSourceCode` `ItemList`; generated project pages are included in the sitemap with available `lastmod` dates. Public HTML revalidates, versioned CSS/JavaScript may use long-lived immutable caching, and the web-server layer provides zstd/gzip compression. Public usage insight is server-side and privacy-bounded: no client analytics script is required. + ### Mascot and imagery -Use the supplied mascot assets rather than approximations. The normal Spiny is used for the brand icon and homepage hero. Dead Spiny is reserved for the custom 404 page. The portrait is presented in a square crop with a restrained gradient glow. +Use accepted brand-workspace assets rather than approximations. The H-core masterbrand supplies official identity derivatives for the header, favicon and installable web app. Normal Spiny is reserved for the homepage hero and mascot/personality use; Dead Spiny is reserved for the custom 404 page. The hero prefers an optimized WebP derivative with a PNG fallback, and the social preview uses an optimized JPEG derivative while the source PNG remains available. The portrait is presented in a square crop with a restrained gradient glow. Images must declare intrinsic width and height to prevent layout shift. Decorative duplicate glitch layers are hidden from assistive technology. +## Accepted product constraints + +These are human-accepted product decisions, not open optimization suggestions: + +- **Public dashboard launcher stays.** The authenticated `Private dashboard` shortcut remains visible in the public header unless new exposure/authentication evidence or an explicit product request changes that decision. +- **Mobile glass blur stays.** Existing glass/backdrop blur remains part of the mobile visual treatment unless measured device/runtime evidence or an explicit product request justifies a change. + +Generic security or performance reviews must treat these as accepted constraints rather than repeatedly reopening them without new evidence. + ## Do's and Don'ts - **Do** preserve the dark-only navy foundation and the pink-blue-cyan accent relationship. diff --git a/README.md b/README.md index c299932..53a3be1 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Static public website for `hypershell.eu` and `www.hypershell.eu`. -Hypershell is Ronald's personal homelab and connected home environment. The site presents its major domains, selected projects, simplified public architecture and visual identity without exposing internal topology or operational details. +Hypershell is Ronald's personal homelab and connected home environment. The site presents its major domains, design rationale, simplified public architecture, core initiatives and maintained software without exposing internal topology or operational details. ## Design @@ -12,7 +12,7 @@ The canonical, agent-readable design system is documented in [`DESIGN.md`](DESIG - dark-only Hypershell neon branding; - no runtime third-party dependencies; -- responsive one-page layout; +- responsive homepage plus generated static project detail pages; - semantic HTML and keyboard-visible focus states; - reduced-motion support; - custom branded 404 page; @@ -36,7 +36,7 @@ hypershell-website/ ./scripts/build.sh ``` -The build has no package-manager dependency. It fetches current GitHub repository metadata at build time, renders selected Hypershell projects into the static site, creates `dist/`, derives a content-based asset version and validates every required output asset. +The production build has no package-manager dependency. It fetches current GitHub repository metadata and latest public releases at build time, renders the homepage plus static project detail pages, generates the only published sitemap from current project metadata, creates `dist/`, derives a content-based asset version and validates every required output asset and the `security.txt` expiry horizon. Live project discovery requires authenticated GitHub repository metadata access. The build accepts `GH_TOKEN` or `GITHUB_TOKEN`; alternatively it reads a protected token file from `GITHUB_TOKEN_FILE` or, by default, `.runtime-secrets/github-token` when that file exists. `.runtime-secrets/` is ignored by Git. The token is used only for the GitHub API request and is never written to `dist/`. A build fails if authenticated repository metadata cannot be retrieved. @@ -46,9 +46,21 @@ A repository is included automatically when it is active and its GitHub **Websit https://www.hypershell.eu/#projects ``` -Public repositories receive a GitHub link. Private repositories may expose their selected name, description and `PRIVATE` visibility label but never their repository URL. Selected repositories must have a non-empty GitHub description or the build fails. GitHub-backed project cards are sorted alphabetically by their display name; display-name exceptions live in `src/data/project-display-names.json`, otherwise the repository name is humanized. Non-GitHub projects remain supported through `src/data/manual-projects.json` and keep their declared order. +The homepage flows from the lab domains through **Why Hypershell** and the simplified public architecture before reaching Projects. The Projects section renders non-GitHub work from `src/data/manual-projects.json` as **Core initiatives** and selected repositories as **Maintained software**. Dynamic counts are derived at build time for the six public domains, core initiatives, maintained repositories and combined project total. Public repositories receive an explicit GitHub link. Private repositories may expose their selected name, description and neutral `PRIVATE` visibility label but never their repository URL. Repository visibility is metadata, not lifecycle status. Selected repositories must have a non-empty GitHub description or the build fails. -For deterministic tests or an explicitly pre-fetched metadata input, set `GITHUB_REPOSITORIES_FILE` to a JSON file with GitHub repository objects. +GitHub remains the source for repository name, description, visibility, URL and public release/update timestamps. `src/data/project-presentation.json` is the single local presentation owner for safe categories, display-name exceptions, explicit includes, presentation order, provenance labels and activity exclusions; it does not duplicate GitHub descriptions or URLs. Every core initiative and maintained repository receives a stable homepage fragment and a generated `/projects//` detail page. Private repository URLs and private activity are never emitted. Recent activity prefers the latest public GitHub Release and falls back to repository update time; the website repository is excluded from this block to avoid self-referential noise. + +For deterministic tests or explicitly pre-fetched metadata, set `GITHUB_REPOSITORIES_FILE` to a JSON file with GitHub repository objects and optionally `GITHUB_RELEASES_FILE` to a mapping of repository names to release objects. + +### Brand derivatives + +The accepted H-core masterbrand is used for official website identity; Spiny remains the mascot and homepage/404 personality layer. Web-specific PNG/ICO/WebP/JPEG derivatives are generated from accepted sources without modifying the canonical brand workspace: + +```sh +BRAND_ROOT=/path/to/hypershell-brand ./scripts/sync-brand-assets.sh +``` + +`src/data/brand-assets.json` records the accepted source paths and hashes used for those derivatives. This maintenance step uses the repository's digest-pinned Playwright image, but the resulting production website has no Node.js or browser runtime dependency. ## Feedback and contributions @@ -70,7 +82,9 @@ Tests are project-specific and run in an ephemeral digest-pinned Playwright cont GitHub CI runs the same repository test entry point. Browser-test dependencies are locked, Dependabot tracks npm and GitHub Actions updates, external Actions are pinned to full commit SHAs, GitHub CodeQL default setup scans the maintained JavaScript/Python/workflow source, and OpenSSF Scorecard publishes an independent repository-security signal. -The suite covers project selection/rendering, responsive layout, overflow, mobile navigation with and without JavaScript, keyboard focus restoration, mascot proportions, glitch lifecycle, project-card consistency, social metadata, the custom 404 response and WCAG A/AA checks through Axe. +The suite covers project selection/rendering, consolidated presentation metadata, provenance, generated detail pages and sitemap, release-first public activity, dynamic counts/categories/filtering, responsive layout and sticky mobile filters, overflow, mobile navigation with and without JavaScript, retained dashboard/mobile-blur contracts, keyboard focus restoration, brand/mascot delivery, intermittent hero glitch lifecycle, manifest/security/structured metadata, project-card consistency, social metadata, the custom 404 response and WCAG A/AA checks through Axe. + +The public asset set includes a dark Hypershell web-app manifest and `/.well-known/security.txt`. The favicon and installable-app icon set is derived from the accepted H-core masterbrand; the previous SVG wrapper around an embedded raster image is intentionally not published. The hero prefers an optimized Spiny WebP derivative with PNG fallback, and social metadata uses an optimized 1200×630 JPEG derivative. ## Deploy diff --git a/public/.well-known/security.txt b/public/.well-known/security.txt new file mode 100644 index 0000000..245ed6b --- /dev/null +++ b/public/.well-known/security.txt @@ -0,0 +1,5 @@ +Contact: mailto:info@hypershell.eu +Expires: 2027-09-08T00:00:00Z +Preferred-Languages: en, nl +Canonical: https://www.hypershell.eu/.well-known/security.txt +Policy: https://github.com/X1pheR/hypershell-website/security/policy diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png index f99008c..8151c8b 100644 Binary files a/public/apple-touch-icon.png and b/public/apple-touch-icon.png differ diff --git a/public/favicon-96x96.png b/public/favicon-96x96.png index a6fa8a0..ff102b3 100644 Binary files a/public/favicon-96x96.png and b/public/favicon-96x96.png differ diff --git a/public/favicon.ico b/public/favicon.ico index a229a6c..984614c 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index b2c589a..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/public/masterbrand-96.png b/public/masterbrand-96.png new file mode 100644 index 0000000..ff102b3 Binary files /dev/null and b/public/masterbrand-96.png differ diff --git a/public/site.webmanifest b/public/site.webmanifest index cfce9d5..e63484f 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -1,21 +1,25 @@ { - "name": "Hypershell Dashboard", - "short_name": "Dashboard", + "id": "/", + "name": "Hypershell", + "short_name": "Hypershell", + "description": "Ronald's connected home environment for automation, self-hosting, infrastructure and intelligent agents.", + "start_url": "/", + "scope": "/", + "display": "standalone", + "theme_color": "#050816", + "background_color": "#050816", "icons": [ { "src": "/web-app-manifest-192x192.png", "sizes": "192x192", "type": "image/png", - "purpose": "maskable" + "purpose": "any" }, { "src": "/web-app-manifest-512x512.png", "sizes": "512x512", "type": "image/png", - "purpose": "maskable" + "purpose": "any" } - ], - "theme_color": "#ffffff", - "background_color": "#ffffff", - "display": "standalone" -} \ No newline at end of file + ] +} diff --git a/public/sitemap.xml b/public/sitemap.xml deleted file mode 100644 index e2fade1..0000000 --- a/public/sitemap.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - https://www.hypershell.eu/ - monthly - 1.0 - - diff --git a/public/social-card.jpg b/public/social-card.jpg new file mode 100644 index 0000000..adfcdca Binary files /dev/null and b/public/social-card.jpg differ diff --git a/public/spiny.png b/public/spiny.png index 5cc7849..9bd3208 100644 Binary files a/public/spiny.png and b/public/spiny.png differ diff --git a/public/spiny.webp b/public/spiny.webp new file mode 100644 index 0000000..3ccaf64 Binary files /dev/null and b/public/spiny.webp differ diff --git a/public/web-app-manifest-192x192.png b/public/web-app-manifest-192x192.png index c046761..25e0ecf 100644 Binary files a/public/web-app-manifest-192x192.png and b/public/web-app-manifest-192x192.png differ diff --git a/public/web-app-manifest-512x512.png b/public/web-app-manifest-512x512.png index ee37868..149b768 100644 Binary files a/public/web-app-manifest-512x512.png and b/public/web-app-manifest-512x512.png differ diff --git a/scripts/build.sh b/scripts/build.sh index aff6ba9..903f04d 100755 --- a/scripts/build.sh +++ b/scripts/build.sh @@ -1,86 +1,26 @@ #!/usr/bin/env sh set -eu - ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" DIST_DIR="$ROOT_DIR/dist" - -rm -rf "$DIST_DIR" -mkdir -p "$DIST_DIR" - -if [ -n "${GITHUB_REPOSITORIES_FILE:-}" ]; then - python3 "$ROOT_DIR/scripts/render_projects.py" \ - --template "$ROOT_DIR/src/index.html" \ - --output "$DIST_DIR/index.html" \ - --manual "$ROOT_DIR/src/data/manual-projects.json" \ - --overrides "$ROOT_DIR/src/data/project-display-names.json" \ - --repositories-file "$GITHUB_REPOSITORIES_FILE" -else - TOKEN_FILE="${GITHUB_TOKEN_FILE:-$ROOT_DIR/.runtime-secrets/github-token}" - if [ -r "$TOKEN_FILE" ]; then - python3 "$ROOT_DIR/scripts/render_projects.py" \ - --template "$ROOT_DIR/src/index.html" \ - --output "$DIST_DIR/index.html" \ - --manual "$ROOT_DIR/src/data/manual-projects.json" \ - --overrides "$ROOT_DIR/src/data/project-display-names.json" \ - --token-file "$TOKEN_FILE" - else - python3 "$ROOT_DIR/scripts/render_projects.py" \ - --template "$ROOT_DIR/src/index.html" \ - --output "$DIST_DIR/index.html" \ - --manual "$ROOT_DIR/src/data/manual-projects.json" \ - --overrides "$ROOT_DIR/src/data/project-display-names.json" - fi -fi +rm -rf "$DIST_DIR"; mkdir -p "$DIST_DIR" cp "$ROOT_DIR/src/404.html" "$DIST_DIR/404.html" cp "$ROOT_DIR/src/styles.css" "$DIST_DIR/styles.css" cp "$ROOT_DIR/src/site.js" "$DIST_DIR/site.js" cp -a "$ROOT_DIR/public/." "$DIST_DIR/" - -ASSET_VERSION="$(cat "$ROOT_DIR/src/styles.css" "$ROOT_DIR/src/site.js" | sha256sum | awk '{print substr($1, 1, 12)}')" - -for html_file in "$DIST_DIR/index.html" "$DIST_DIR/404.html"; do - temporary_file="${html_file}.tmp" - sed "s/__ASSET_VERSION__/$ASSET_VERSION/g" "$html_file" > "$temporary_file" - mv "$temporary_file" "$html_file" -done - -required_files=" -index.html -404.html -styles.css -site.js -spiny.png -spiny-dead.png -homesight.svg -me.jpg -favicon.ico -favicon.svg -favicon-96x96.png -apple-touch-icon.png -site.webmanifest -web-app-manifest-192x192.png -web-app-manifest-512x512.png -robots.txt -sitemap.xml -social-card.png -vendor/powerglitch-2.5.0.min.js -vendor/powerglitch-LICENSE.txt -fonts/oxanium-700-latin-v21.woff2 -fonts/OFL-Oxanium.txt -" - -for relative_path in $required_files; do - test -s "$DIST_DIR/$relative_path" || { - echo "Missing required build output: $relative_path" >&2 - exit 1 - } -done - -if grep -R "__ASSET_VERSION__" "$DIST_DIR"/*.html >/dev/null 2>&1; then - echo "Unresolved asset version placeholder in build output" >&2 - exit 1 +ARGS="--template $ROOT_DIR/src/index.html --project-template $ROOT_DIR/src/project.html --output $DIST_DIR/index.html --output-dir $DIST_DIR --manual $ROOT_DIR/src/data/manual-projects.json --presentation $ROOT_DIR/src/data/project-presentation.json" +if [ -n "${GITHUB_REPOSITORIES_FILE:-}" ]; then + set -- $ARGS --repositories-file "$GITHUB_REPOSITORIES_FILE" + [ -z "${GITHUB_RELEASES_FILE:-}" ] || set -- "$@" --releases-file "$GITHUB_RELEASES_FILE" + python3 "$ROOT_DIR/scripts/render_projects.py" "$@" +else + TOKEN_FILE="${GITHUB_TOKEN_FILE:-$ROOT_DIR/.runtime-secrets/github-token}" + if [ -r "$TOKEN_FILE" ]; then python3 "$ROOT_DIR/scripts/render_projects.py" $ARGS --token-file "$TOKEN_FILE"; else python3 "$ROOT_DIR/scripts/render_projects.py" $ARGS; fi fi - +ASSET_VERSION="$(cat "$ROOT_DIR/src/styles.css" "$ROOT_DIR/src/site.js" | sha256sum | awk '{print substr($1,1,12)}')" +find "$DIST_DIR" -name '*.html' -type f -exec sh -c 'for html_file do temporary_file="${html_file}.tmp"; sed "s/__ASSET_VERSION__/'"$ASSET_VERSION"'/g" "$html_file" > "$temporary_file"; mv "$temporary_file" "$html_file"; done' sh {} + +required_files="index.html 404.html styles.css site.js spiny.png spiny.webp masterbrand-96.png homesight.svg me.jpg favicon.ico favicon-96x96.png apple-touch-icon.png site.webmanifest .well-known/security.txt web-app-manifest-192x192.png web-app-manifest-512x512.png robots.txt sitemap.xml social-card.png social-card.jpg vendor/powerglitch-2.5.0.min.js vendor/powerglitch-LICENSE.txt fonts/oxanium-700-latin-v21.woff2 fonts/OFL-Oxanium.txt" +for relative_path in $required_files; do test -s "$DIST_DIR/$relative_path" || { echo "Missing required build output: $relative_path" >&2; exit 1; }; done +if grep -R "__ASSET_VERSION__\|__PROJECT_\|__REPOSITORY_\|__CORE_PROJECT_\|__TOTAL_PROJECT_\|__SOFTWARE_JSON_LD__" "$DIST_DIR" --include='*.html' >/dev/null 2>&1; then echo "Unresolved build placeholder in HTML output" >&2; exit 1; fi +python3 "$ROOT_DIR/scripts/check_security_metadata.py" "$DIST_DIR/.well-known/security.txt" >/dev/null find "$DIST_DIR" -type f -exec chmod 0644 {} + - printf 'Built %s with asset version %s\n' "$DIST_DIR" "$ASSET_VERSION" diff --git a/scripts/check_security_metadata.py b/scripts/check_security_metadata.py new file mode 100755 index 0000000..c11baa4 --- /dev/null +++ b/scripts/check_security_metadata.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from datetime import datetime, timezone, timedelta +from pathlib import Path +import sys +path=Path(sys.argv[1]) if len(sys.argv)>1 else Path('public/.well-known/security.txt') +fields={} +for raw in path.read_text(encoding='utf-8').splitlines(): + if ':' not in raw: continue + key,value=raw.split(':',1); fields[key.strip()]=value.strip() +required={'Contact','Expires','Canonical','Policy'} +missing=required-fields.keys() +if missing: raise SystemExit(f"security.txt missing required fields: {', '.join(sorted(missing))}") +try: expires=datetime.fromisoformat(fields['Expires'].replace('Z','+00:00')) +except ValueError as exc: raise SystemExit(f"security.txt Expires is invalid: {exc}") +if expires - datetime.now(timezone.utc) < timedelta(days=90): raise SystemExit('security.txt expires in less than 90 days') +print(f"security.txt valid through {expires.date().isoformat()}") diff --git a/scripts/derive_brand_assets.cjs b/scripts/derive_brand_assets.cjs new file mode 100644 index 0000000..f314cab --- /dev/null +++ b/scripts/derive_brand_assets.cjs @@ -0,0 +1,54 @@ +const fs = require('node:fs'); +const crypto = require('node:crypto'); +const path = require('node:path'); +const { chromium } = require('@playwright/test'); + +const brandRoot = process.env.BRAND_ROOT; +const repoRoot = process.env.REPO_ROOT || '/work'; +if (!brandRoot) throw new Error('BRAND_ROOT is required'); +const masterRel = 'assets/masterbrand/hypershell-masterbrand.png'; +const spinyRel = 'assets/mascot/spiny.png'; +const master = path.join(brandRoot, masterRel); +const spiny = path.join(brandRoot, spinyRel); +const publicDir = path.join(repoRoot, 'public'); +const dataDir = path.join(repoRoot, 'src', 'data'); +const sha = (file) => crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); + +function pngIco(png) { + const header = Buffer.alloc(6); header.writeUInt16LE(0, 0); header.writeUInt16LE(1, 2); header.writeUInt16LE(1, 4); + const entry = Buffer.alloc(16); entry[0] = 96; entry[1] = 96; entry[2] = 0; entry[3] = 0; entry.writeUInt16LE(1, 4); entry.writeUInt16LE(32, 6); entry.writeUInt32LE(png.length, 8); entry.writeUInt32LE(22, 12); + return Buffer.concat([header, entry, png]); +} + +(async () => { + const browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + async function encode(input, width, height, mime, quality) { + const data = fs.readFileSync(input).toString('base64'); + const result = await page.evaluate(async ({data, width, height, mime, quality}) => { + const image = new Image(); image.src = `data:image/png;base64,${data}`; await image.decode(); + const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; + const ctx = canvas.getContext('2d', { alpha: true }); ctx.clearRect(0, 0, width, height); ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high'; ctx.drawImage(image, 0, 0, width, height); + return canvas.toDataURL(mime, quality).split(',')[1]; + }, {data, width, height, mime, quality}); + return Buffer.from(result, 'base64'); + } + const master96 = await encode(master, 96, 96, 'image/png'); + fs.writeFileSync(path.join(publicDir, 'masterbrand-96.png'), master96); + fs.writeFileSync(path.join(publicDir, 'favicon-96x96.png'), master96); + fs.writeFileSync(path.join(publicDir, 'favicon.ico'), pngIco(master96)); + fs.writeFileSync(path.join(publicDir, 'apple-touch-icon.png'), await encode(master, 180, 180, 'image/png')); + fs.writeFileSync(path.join(publicDir, 'web-app-manifest-192x192.png'), await encode(master, 192, 192, 'image/png')); + fs.writeFileSync(path.join(publicDir, 'web-app-manifest-512x512.png'), await encode(master, 512, 512, 'image/png')); + fs.writeFileSync(path.join(publicDir, 'spiny.png'), await encode(spiny, 512, 512, 'image/png')); + fs.writeFileSync(path.join(publicDir, 'spiny.webp'), await encode(spiny, 512, 512, 'image/webp', 0.88)); + const social = path.join(publicDir, 'social-card.png'); + if (fs.existsSync(social)) fs.writeFileSync(path.join(publicDir, 'social-card.jpg'), await encode(social, 1200, 630, 'image/jpeg', 0.90)); + await browser.close(); + const provenance = { + masterbrand: { source: masterRel, sha256: sha(master) }, + spiny: { source: spinyRel, sha256: sha(spiny) }, + derivatives: ['masterbrand-96.png','favicon-96x96.png','favicon.ico','apple-touch-icon.png','web-app-manifest-192x192.png','web-app-manifest-512x512.png','spiny.png','spiny.webp','social-card.jpg'] + }; + fs.writeFileSync(path.join(dataDir, 'brand-assets.json'), JSON.stringify(provenance, null, 2) + '\n'); +})().catch((error) => { console.error(error); process.exit(1); }); diff --git a/scripts/render_projects.py b/scripts/render_projects.py index af7373d..1e7efd4 100755 --- a/scripts/render_projects.py +++ b/scripts/render_projects.py @@ -1,153 +1,189 @@ #!/usr/bin/env python3 -import argparse -import html -import json -import os -import sys -import urllib.error -import urllib.request +import argparse, html, json, os, re, sys, urllib.error, urllib.request +from collections import Counter +from datetime import datetime from pathlib import Path +from urllib.parse import quote PROJECT_HOMEPAGE = "https://www.hypershell.eu/#projects" API_URL = "https://api.github.com/user/repos?affiliation=owner&per_page=100&sort=full_name" ACRONYMS = {"api", "bws", "hats", "id", "mcp", "oidc", "qmd", "ui"} +CATEGORY_ORDER = ["Agentic AI", "Infrastructure", "Applications", "Knowledge", "Operations", "Identity & resilience", "Other"] +BASE_URL = "https://www.hypershell.eu" -def display_name(repo_name, overrides): - if repo_name in overrides: - return overrides[repo_name] - words = [] - for part in repo_name.split("-"): - words.append(part.upper() if part.lower() in ACRONYMS else part.capitalize()) - return " ".join(words) +def load_json(path): + with open(path, encoding="utf-8") as handle: return json.load(handle) + +def slugify(value): + return re.sub(r"[^a-z0-9]+", "-", value.casefold()).strip("-") or "project" + +def config_for(name, presentation): return presentation.get(name, {}) +def display_name(repo_name, presentation): + configured = config_for(repo_name, presentation).get("display_name") + if configured: return configured + return " ".join(part.upper() if part.lower() in ACRONYMS else part.capitalize() for part in repo_name.split("-")) -def select_repositories(repositories, overrides): - selected = [ - repo - for repo in repositories - if not repo.get("archived") and repo.get("homepage") == PROJECT_HOMEPAGE - ] - for repo in selected: - if not str(repo.get("description") or "").strip(): - raise ValueError(f"Selected GitHub repository '{repo.get('name', '')}' is missing a GitHub description") - return sorted( - selected, - key=lambda repo: display_name(repo.get("name", ""), overrides).casefold(), - ) +def category_name(repo_name, presentation): return str(config_for(repo_name, presentation).get("category") or "Other").strip() or "Other" +def category_slug(value): return slugify(value) +def provenance_name(repo_name, presentation): return str(config_for(repo_name, presentation).get("provenance") or "Hypershell-maintained") +def select_repositories(repositories, presentation): + selected=[] + for repo in repositories: + cfg=config_for(repo.get("name",""), presentation) + if repo.get("archived"): continue + if repo.get("homepage") != PROJECT_HOMEPAGE and not cfg.get("include"): continue + if not str(repo.get("description") or "").strip(): raise ValueError(f"Selected GitHub repository '{repo.get('name','')}' is missing a GitHub description") + selected.append(repo) + return sorted(selected, key=lambda repo:(int(config_for(repo["name"],presentation).get("order",1000)), display_name(repo["name"],presentation).casefold())) def status_class(status): - normalized = status.strip().lower().replace("_", "-").replace(" ", "-") - if normalized in {"operational", "public"}: - return "operational" - if normalized in {"in-progress", "private"}: - return "in-progress" - return "exploratory" + normalized=status.strip().lower().replace("_","-").replace(" ","-") + return normalized if normalized in {"operational","in-progress"} else "exploratory" +def manual_slug(project): return project.get("slug") or slugify(project["name"]) +def repo_slug(repo): return repo["name"] def render_manual_card(project): - name = html.escape(project["name"]) - description = html.escape(project["description"]) - status = html.escape(project.get("status", "PROJECT")) - meta = html.escape(project.get("meta", "Hypershell project")) - classes = "glass-card project-card" - if project.get("featured"): - classes += " project-featured" - logo = "" - if project.get("logo"): - logo = f'\n ' - return f'''
+ name=html.escape(project["name"]); desc=html.escape(project["description"]); status=html.escape(project.get("status","PROJECT")); meta=html.escape(project.get("meta","Hypershell project")); slug=html.escape(manual_slug(project),quote=True) + classes="glass-card project-card" + (" project-featured" if project.get("featured") else "") + logo="" + if project.get("logo"): logo=f'\n ' + return f'''
{status}{meta}
-

{name}

-

{description}

{logo} +

{name}

+

{desc}

{logo}
''' - -def render_repository_card(repo, overrides): - name = html.escape(display_name(repo["name"], overrides)) - description = html.escape(repo["description"].strip()) - private = bool(repo.get("private")) - visibility = "PRIVATE" if private else "PUBLIC" - link = "" +def render_repository_card(repo,presentation): + display=display_name(repo["name"],presentation); name=html.escape(display); desc=html.escape(repo["description"].strip()); private=bool(repo.get("private")); vis="PRIVATE" if private else "PUBLIC"; vis_name="private" if private else "public"; category=category_name(repo["name"],presentation); provenance=provenance_name(repo["name"],presentation); slug=html.escape(repo_slug(repo),quote=True) + link="" if not private and repo.get("html_url"): - url = html.escape(repo["html_url"], quote=True) - link = f'''\n - - GitHub - ''' - return f'''
-
{visibility}GitHub project
-

{name}

-

{description}

{link} + url=html.escape(repo["html_url"],quote=True); link=f'\n View on GitHub' + return f'''
+
{vis}{html.escape(category)}{html.escape(provenance)}
+

{name}

+

{desc}

{link}
''' +def render_filter_buttons(repositories,presentation): + counts=Counter(category_name(repo["name"],presentation) for repo in repositories); labels=[c for c in CATEGORY_ORDER if counts.get(c)]; labels += sorted((c for c in counts if c not in CATEGORY_ORDER), key=str.casefold) + buttons=[f''] + for label in labels: buttons.append(f'') + return "\n ".join(buttons) -def render_project_cards(manual_projects, repositories, overrides): - cards = [render_manual_card(project) for project in manual_projects] - cards.extend(render_repository_card(repo, overrides) for repo in select_repositories(repositories, overrides)) - return "\n\n".join(cards) - +def parse_date(value): + if not value: return None + try: return datetime.fromisoformat(value.replace("Z","+00:00")) + except (TypeError,ValueError): return None -def load_json(path): - with open(path, encoding="utf-8") as handle: - return json.load(handle) - - -def resolve_token(environ, token_file=None): - token = environ.get("GH_TOKEN") or environ.get("GITHUB_TOKEN") - if token: - return token.strip() +def resolve_token(environ,token_file=None): + token=environ.get("GH_TOKEN") or environ.get("GITHUB_TOKEN") + if token: return token.strip() if token_file: - token = Path(token_file).read_text(encoding="utf-8").strip() - if token: - return token + token=Path(token_file).read_text(encoding="utf-8").strip() + if token: return token raise RuntimeError("GH_TOKEN, GITHUB_TOKEN, or a readable GitHub token file is required to fetch private and public GitHub project metadata") - -def fetch_repositories(token_file=None): - token = resolve_token(os.environ, token_file) - request = urllib.request.Request( - API_URL, - headers={ - "Accept": "application/vnd.github+json", - "Authorization": f"Bearer {token}", - "X-GitHub-Api-Version": "2022-11-28", - "User-Agent": "hypershell-website-build", - }, - ) +def github_json(url,token,allow_404=False): + req=urllib.request.Request(url,headers={"Accept":"application/vnd.github+json","Authorization":f"Bearer {token}","X-GitHub-Api-Version":"2022-11-28","User-Agent":"hypershell-website-build"}) try: - with urllib.request.urlopen(request, timeout=20) as response: - return json.load(response) - except (urllib.error.URLError, json.JSONDecodeError) as exc: - raise RuntimeError(f"Unable to fetch GitHub repository metadata: {exc}") from exc - + with urllib.request.urlopen(req,timeout=20) as response: return json.load(response) + except urllib.error.HTTPError as exc: + if allow_404 and exc.code==404: return None + raise + +def fetch_repositories(token): return github_json(API_URL,token) +def fetch_latest_releases(repositories,token): + releases={} + for repo in repositories: + if repo.get("private"): continue + item=github_json(f'https://api.github.com/repos/X1pheR/{quote(repo["name"])}/releases/latest',token,allow_404=True) + if item: releases[repo["name"]]=item + return releases + +def activity_for(repo,releases,presentation): + if repo.get("private") or not repo.get("html_url") or config_for(repo["name"],presentation).get("exclude_from_activity"): return None + release=releases.get(repo["name"]) + if release: + stamp=parse_date(release.get("published_at") or release.get("created_at")) + if stamp: return {"timestamp":stamp,"repo":repo,"kind":"release","label":release.get("tag_name") or "Release","url":release.get("html_url") or repo["html_url"]} + stamp=parse_date(repo.get("pushed_at") or repo.get("updated_at")) + return {"timestamp":stamp,"repo":repo,"kind":"push","label":"Recently updated","url":repo["html_url"]} if stamp else None + +def render_recent_activity(repositories,releases,presentation): + candidates=[a for a in (activity_for(repo,releases,presentation) for repo in repositories) if a]; candidates.sort(key=lambda a:a["timestamp"],reverse=True); items=[] + for activity in candidates[:3]: + repo=activity["repo"]; name=html.escape(display_name(repo["name"],presentation)); url=html.escape(activity["url"],quote=True); ts=activity["timestamp"]; kicker="Latest release" if activity["kind"]=="release" else "Recently updated"; detail=html.escape(activity["label"]) + items.append(f''' + {kicker}{name}{detail} + + ''') + return "\n".join(items) if items else '

Recent public update data is unavailable for this build.

' + +def render_software_json_ld(repositories,presentation): + items=[] + for position,repo in enumerate((r for r in repositories if not r.get("private") and r.get("html_url")),1): + items.append({"@type":"ListItem","position":position,"item":{"@type":"SoftwareSourceCode","name":display_name(repo["name"],presentation),"description":repo["description"].strip(),"url":f"{BASE_URL}/projects/{repo_slug(repo)}/","codeRepository":repo["html_url"],"applicationCategory":category_name(repo["name"],presentation)}}) + return json.dumps({"@context":"https://schema.org","@type":"ItemList","name":"Hypershell maintained software","itemListElement":items},separators=(",",":")).replace("{vis}{html.escape(category_name(repo["name"],presentation))}{html.escape(provenance_name(repo["name"],presentation))}' + +def fact(label,value): return f'
{html.escape(label)}
{value}
' +def render_detail_page(template,title,description,slug,meta,action,facts): + canonical=f"{BASE_URL}/projects/{slug}/"; values={"__PROJECT_TITLE__":html.escape(title),"__PROJECT_META_DESCRIPTION__":html.escape(description,quote=True),"__PROJECT_CANONICAL__":canonical,"__PROJECT_META__":meta,"__PROJECT_DESCRIPTION__":html.escape(description),"__PROJECT_PRIMARY_ACTION__":action,"__PROJECT_FACTS__":facts} + out=template + for marker,value in values.items(): out=out.replace(marker,value) + return out + +def write_detail_pages(output_dir,project_template,manual_projects,repositories,releases,presentation): + projects_root=Path(output_dir)/"projects"; projects_root.mkdir(parents=True,exist_ok=True) + pages=[] + for project in manual_projects: + slug=manual_slug(project); title=project["name"]; desc=project["description"]; meta=f'{html.escape(project.get("status","Project"))}{html.escape(project.get("meta","Core initiative"))}'; facts=fact("Project type","Core initiative")+fact("Lifecycle",html.escape(project.get("status","Project")))+fact("Scope",html.escape(project.get("meta","Hypershell"))); page=render_detail_page(project_template,title,desc,slug,meta,"",facts); dest=projects_root/slug; dest.mkdir(exist_ok=True); (dest/"index.html").write_text(page,encoding="utf-8"); pages.append((slug,None)) + for repo in repositories: + slug=repo_slug(repo); title=display_name(repo["name"],presentation); desc=repo["description"].strip(); meta=detail_meta_repo(repo,presentation); action="" + if not repo.get("private") and repo.get("html_url"): action=f'View on GitHub ' + facts=fact("Category",html.escape(category_name(repo["name"],presentation)))+fact("Provenance",html.escape(provenance_name(repo["name"],presentation)))+fact("Visibility","Private repository" if repo.get("private") else "Public repository") + release=releases.get(repo["name"]) + if not repo.get("private") and release and parse_date(release.get("published_at") or release.get("created_at")): facts += fact("Latest release",html.escape(release.get("tag_name") or "Release"))+fact("Released",parse_date(release.get("published_at") or release.get("created_at")).date().isoformat()) + elif not repo.get("private") and parse_date(repo.get("pushed_at") or repo.get("updated_at")): facts += fact("Latest public update",parse_date(repo.get("pushed_at") or repo.get("updated_at")).date().isoformat()) + page=render_detail_page(project_template,title,desc,slug,meta,action,facts); dest=projects_root/slug; dest.mkdir(exist_ok=True); (dest/"index.html").write_text(page,encoding="utf-8"); pages.append((slug,parse_date(repo.get("pushed_at") or repo.get("updated_at")))) + return pages + +def render_sitemap(path,pages,repositories): + dates=[parse_date(repo.get("pushed_at") or repo.get("updated_at")) for repo in repositories]; dates=[d for d in dates if d]; root_date=max(dates).date().isoformat() if dates else None + lines=['','',' ',f' {BASE_URL}/'] + if root_date: lines.append(f' {root_date}') + lines += [' monthly',' 1.0',' '] + for slug,stamp in pages: + lines += [' ',f' {BASE_URL}/projects/{html.escape(slug)}/'] + if stamp: lines.append(f' {stamp.date().isoformat()}') + lines += [' monthly',' 0.7',' '] + lines.append(''); Path(path).write_text("\n".join(lines)+"\n",encoding="utf-8") def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--template", required=True) - parser.add_argument("--output", required=True) - parser.add_argument("--manual", required=True) - parser.add_argument("--overrides", required=True) - parser.add_argument("--repositories-file") - parser.add_argument("--token-file") - args = parser.parse_args() - - repositories = load_json(args.repositories_file) if args.repositories_file else fetch_repositories(args.token_file) - manual_projects = load_json(args.manual) - overrides = load_json(args.overrides) - project_html = render_project_cards(manual_projects, repositories, overrides) - - template = Path(args.template).read_text(encoding="utf-8") - marker = "__PROJECT_CARDS__" - if template.count(marker) != 1: - raise RuntimeError(f"Expected exactly one {marker} marker in {args.template}") - Path(args.output).write_text(template.replace(marker, project_html), encoding="utf-8") - - -if __name__ == "__main__": - try: - main() - except Exception as exc: - print(f"Project rendering failed: {exc}", file=sys.stderr) - sys.exit(1) + p=argparse.ArgumentParser(); p.add_argument("--template",required=True); p.add_argument("--project-template",required=True); p.add_argument("--output",required=True); p.add_argument("--output-dir",required=True); p.add_argument("--manual",required=True); p.add_argument("--presentation",required=True); p.add_argument("--repositories-file"); p.add_argument("--releases-file"); p.add_argument("--token-file"); args=p.parse_args() + token=None + if args.repositories_file: repositories=load_json(args.repositories_file) + else: token=resolve_token(os.environ,args.token_file); repositories=fetch_repositories(token) + manual=load_json(args.manual); presentation=load_json(args.presentation); selected=select_repositories(repositories,presentation) + if args.releases_file: releases=load_json(args.releases_file) + elif token: releases=fetch_latest_releases(selected,token) + else: releases={} + template=Path(args.template).read_text(encoding="utf-8"); domain_count=template.count('class="glass-card domain-card"') + if domain_count<1: raise RuntimeError("Unable to derive the public domain count from the homepage template") + replacements={"__CORE_PROJECT_CARDS__":"\n\n".join(render_manual_card(p) for p in manual),"__REPOSITORY_PROJECT_CARDS__":"\n\n".join(render_repository_card(r,presentation) for r in selected),"__REPOSITORY_FILTERS__":render_filter_buttons(selected,presentation),"__RECENT_ACTIVITY_ITEMS__":render_recent_activity(selected,releases,presentation),"__SOFTWARE_JSON_LD__":render_software_json_ld(selected,presentation),"__DOMAIN_COUNT__":str(domain_count),"__CORE_PROJECT_COUNT__":str(len(manual)),"__REPOSITORY_PROJECT_COUNT__":str(len(selected)),"__TOTAL_PROJECT_COUNT__":str(len(manual)+len(selected))} + for marker,value in replacements.items(): + if template.count(marker)<1: raise RuntimeError(f"Expected at least one {marker} marker in {args.template}") + template=template.replace(marker,value) + Path(args.output).write_text(template,encoding="utf-8") + project_template=Path(args.project_template).read_text(encoding="utf-8"); pages=write_detail_pages(args.output_dir,project_template,manual,selected,releases,presentation); render_sitemap(Path(args.output_dir)/"sitemap.xml",pages,selected) + +if __name__=="__main__": + try: main() + except Exception as exc: print(f"Project rendering failed: {exc}",file=sys.stderr); sys.exit(1) diff --git a/scripts/sync-brand-assets.sh b/scripts/sync-brand-assets.sh new file mode 100755 index 0000000..c3f4485 --- /dev/null +++ b/scripts/sync-brand-assets.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env sh +set -eu +: "${BRAND_ROOT:?Set BRAND_ROOT to the accepted Hypershell brand workspace root}" +ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +IMAGE="mcr.microsoft.com/playwright@sha256:5b8f294aff9041b7191c34a4bab3ac270157a28774d4b0660e9743297b697e48" +docker run --rm --init --ipc=host \ + -e BRAND_ROOT=/brand -e REPO_ROOT=/work \ + -v "$ROOT_DIR:/work" -v "$BRAND_ROOT:/brand:ro" \ + -w /tmp/brand-assets "$IMAGE" /bin/bash -lc 'set -euo pipefail; cp /work/package.json /work/package-lock.json .; npm ci --silent --no-audit --no-fund; NODE_PATH=/tmp/brand-assets/node_modules node /work/scripts/derive_brand_assets.cjs' diff --git a/scripts/test.sh b/scripts/test.sh index 8d4c22c..36c7c26 100755 --- a/scripts/test.sh +++ b/scripts/test.sh @@ -8,6 +8,7 @@ PLAYWRIGHT_RESULTS_DIR="${PLAYWRIGHT_RESULTS_DIR:-$ROOT_DIR/test-results/browser python3 -m unittest discover -s "$ROOT_DIR/tests" -p 'test_*.py' -v GITHUB_REPOSITORIES_FILE="$ROOT_DIR/tests/github-repositories.fixture.json" \ + GITHUB_RELEASES_FILE="$ROOT_DIR/tests/github-releases.fixture.json" \ "$ROOT_DIR/scripts/build.sh" command -v docker >/dev/null 2>&1 || { diff --git a/src/404.html b/src/404.html index ddc8817..2f88abd 100644 --- a/src/404.html +++ b/src/404.html @@ -9,7 +9,7 @@ - + @@ -20,7 +20,7 @@
@@ -126,7 +132,7 @@

Smart Home & Automation

Agentic AI & Knowledge

-

Local and hybrid agents that can reason over tools, infrastructure and governed knowledge.

+

Lets agents use governed tools and shared knowledge to help operate the environment instead of existing as isolated chatbots.

  • Hermes
  • MCP
  • Obsidian
@@ -135,7 +141,7 @@

Agentic AI & Knowledge

Infrastructure & Networking

-

The compute, network and storage foundation underneath every part of the environment.

+

Keeps compute, storage and networking dependable enough that everything above the platform can keep evolving.

  • Proxmox
  • Docker
  • Networking
@@ -144,7 +150,7 @@

Infrastructure & Networking

Applications & Digital Life

-

Self-hosted applications supporting daily life, files, entertainment, finance and personal data.

+

Brings the services used every day under one environment without making self-hosting the goal by itself.

  • Nextcloud
  • RomM
  • Utilities
@@ -153,7 +159,7 @@

Applications & Digital Life

Operations & Observability

-

The dashboards and tools used to understand, operate and continuously improve the environment.

+

Turns inventory, telemetry and drift into enough context to know what exists, what changed and what needs attention.

  • HomeSight
  • Grafana
  • Telemetry
@@ -162,23 +168,33 @@

Operations & Observability

Identity, Access & Resilience

-

Secure access, recoverability and protection without turning the house into an enterprise datacenter.

+

Makes secure access and recovery part of the platform so experimentation does not quietly become fragility.

  • Pocket ID
  • Caddy
  • Backups
-
-
-
-

Projects

-

Things built around the lab.

+
+
+
+

Why Hypershell

+

One environment, not a pile of services.

+

The interesting part is not how many things are self-hosted. It is treating the house, infrastructure, applications, agents and knowledge as parts of one evolving system.

+
+
+
+ 01 +

Connected by context

Automation and agents can act with knowledge of the environment instead of through isolated one-off integrations.

+
+
+ 02 +

Operable by design

Inventory, identity, observability and recovery are treated as parts of the system, not cleanup work after experimentation.

+
+
+ 03 +

Built to evolve

Local-first where it helps, hybrid where it adds value, and transparent enough to understand what changed.

+
-

Selected initiatives that turn individual services into a coherent, understandable and maintainable home environment.

-
- -
-__PROJECT_CARDS__
@@ -191,27 +207,27 @@

The house is the top layer.

+
+
+
+

Projects

+

Things built around the lab.

+
+
+ __TOTAL_PROJECT_COUNT__projects +

Core initiatives shape the environment itself; maintained software turns recurring needs into reusable tooling.

+
+
+ +
+
+
+
+

Core initiatives

+ __CORE_PROJECT_COUNT__ core initiatives +
+

Long-running work that changes how Hypershell is operated, understood or protected.

+
+
+__CORE_PROJECT_CARDS__ +
+
+ +
+
+
+

Maintained software

+ __REPOSITORY_PROJECT_COUNT__ maintained projects +
+

Software and tooling actively maintained around the lab. Visibility is metadata; lifecycle status is shown only when it is explicitly known.

+
+ +
+__REPOSITORY_PROJECT_CARDS__ +
+ +
+
+
+

Recent activity

+

Latest public updates.

+
+

Latest public releases where available, otherwise the most recent repository update. Private activity is never shown.

+
+
+__RECENT_ACTIVITY_ITEMS__ +
+
+
+
+
+
diff --git a/src/project.html b/src/project.html new file mode 100644 index 0000000..1238aea --- /dev/null +++ b/src/project.html @@ -0,0 +1,63 @@ + + + + + + __PROJECT_TITLE__ — Hypershell + + + + + + + + + + + + + + + + + + + + +
+
+ ← Back to projects +
+
__PROJECT_META__
+

__PROJECT_TITLE__

+

__PROJECT_DESCRIPTION__

+ __PROJECT_PRIMARY_ACTION__ +
+
+
+

Project profile

Where it fits.

+
__PROJECT_FACTS__
+
+
+
HYPERSHELL

Built locally. Questionable decisions included.

+ + diff --git a/src/site.js b/src/site.js index c7e56a1..5abfba7 100644 --- a/src/site.js +++ b/src/site.js @@ -185,10 +185,44 @@ if (!reducedMotion && heroSpinyGlitch instanceof HTMLElement && heroTitleGlitch heroGlitchTimer = window.setTimeout(runStartupSequence, 180); } +const projectFilterBar = document.querySelector('[data-project-filters]'); +const projectFilterStatus = document.querySelector('[data-project-filter-status]'); +const projectFilterButtons = Array.from(document.querySelectorAll('[data-project-filter]')); +const repositoryCards = Array.from(document.querySelectorAll('.repository-card[data-project-category]')); + +if (projectFilterBar instanceof HTMLElement && projectFilterButtons.length && repositoryCards.length) { + projectFilterBar.hidden = false; + + function applyProjectFilter(category) { + let visibleCount = 0; + repositoryCards.forEach((card) => { + const visible = category === 'all' || card.dataset.projectCategory === category; + card.hidden = !visible; + if (visible) visibleCount += 1; + }); + + projectFilterButtons.forEach((button) => { + const active = button.dataset.projectFilter === category; + button.classList.toggle('is-active', active); + button.setAttribute('aria-pressed', String(active)); + }); + + if (projectFilterStatus) { + projectFilterStatus.textContent = `Showing ${visibleCount} of ${repositoryCards.length} projects`; + } + } + + projectFilterButtons.forEach((button) => { + button.addEventListener('click', () => applyProjectFilter(button.dataset.projectFilter || 'all')); + }); +} + const revealGroups = [ ['.section-heading', 0], ['.domain-card', 90], + ['.why-copy, .why-point', 100], ['.project-card', 110], + ['.project-activity', 110], ['.architecture-stack, .principles', 130], ['.about-copy, .profile-card', 140], ]; diff --git a/src/styles.css b/src/styles.css index 7a1b33d..0698626 100644 --- a/src/styles.css +++ b/src/styles.css @@ -16,7 +16,7 @@ --text: 226 232 240; --heading: 241 245 249; --muted: 148 163 184; - --dim: 100 116 139; + --dim: 107 124 149; --border: 76 92 128; --radius: 1rem; --max: 1180px; @@ -157,12 +157,15 @@ a, button { -webkit-tap-highlight-color: transparent; } .hero h1 { margin: 0; font-family: "Oxanium", Inter, ui-sans-serif, system-ui, sans-serif; font-size: clamp(3.15rem, 10.5vw, 7.9rem); font-weight: 700; letter-spacing: .035em; line-height: .92; } .hero-title-glitch { display: grid; width: max-content; max-width: 100%; margin-inline: auto; overflow: clip; } .hero-title-text { display: block; margin-inline: .02em; color: transparent; background: var(--gradient); background-clip: text; -webkit-background-clip: text; text-shadow: 0 0 36px rgb(var(--blue) / .12); } -.hero-copy { width: min(100%, 720px); margin: clamp(1.5rem, 3vw, 2.2rem) auto 0; color: rgb(var(--text)); font-size: clamp(1.05rem, 2.2vw, 1.38rem); line-height: 1.65; } -.hero-copy p { margin: 0; overflow-wrap: anywhere; text-wrap: balance; } -.hero-copy p:first-child { display: grid; gap: .05rem; } -.hero-copy p:first-child span { display: block; } -.hero-copy .danger-line { margin-top: .65rem; color: rgb(var(--muted)); font-size: .94rem; font-style: italic; } -.hero-context { width: min(100%, 650px); margin: 1.25rem auto 0; color: rgb(var(--muted)); font-size: .96rem; overflow-wrap: anywhere; text-wrap: balance; } +.hero-context { width: min(100%, 720px); margin: clamp(1.5rem, 3vw, 2.1rem) auto 0; color: rgb(var(--text)); font-size: clamp(1.05rem, 2vw, 1.28rem); line-height: 1.65; overflow-wrap: anywhere; text-wrap: balance; } +.hero-signature { width: min(100%, 620px); margin: .7rem auto 0; color: rgb(var(--muted)); font-size: .9rem; font-weight: 650; letter-spacing: .01em; overflow-wrap: anywhere; text-wrap: balance; } + +.hero-scope { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); width: min(100%, 610px); margin: 1.35rem auto 0; border: 1px solid rgb(var(--border) / .34); border-radius: 1rem; background: rgb(var(--surface) / .32); overflow: hidden; } +.hero-scope a { display: grid; min-height: 68px; place-content: center; gap: .1rem; padding: .75rem; color: rgb(var(--muted)); text-align: center; transition: color 150ms ease, background-color 150ms ease; } +.hero-scope a + a { border-left: 1px solid rgb(var(--border) / .30); } +.hero-scope a:hover, .hero-scope a:focus-visible { color: rgb(var(--heading)); background: rgb(var(--soft) / .55); } +.hero-scope strong { color: rgb(var(--pink)); font-family: "Oxanium", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.2rem; line-height: 1; } +.hero-scope span { font-size: .67rem; font-weight: 750; letter-spacing: .06em; text-transform: uppercase; } .scroll-cue { display: inline-flex; align-items: center; min-height: 44px; gap: .55rem; margin-top: 2.6rem; padding-inline: .25rem; color: rgb(var(--muted)); font-size: .78rem; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; transition: color 150ms ease; } .scroll-cue:hover { color: rgb(var(--heading)); } .scroll-cue svg { width: 19px; height: 19px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.8; animation: cue-bounce 1.8s ease-in-out infinite; } @@ -177,6 +180,21 @@ a, button { -webkit-tap-highlight-color: transparent; } .split-heading > p { margin: 0 0 .4rem; } .domain-grid, .project-grid { display: grid; gap: 1.25rem; } .domain-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + +.project-section-summary { display: grid; justify-items: end; gap: .7rem; margin-bottom: .4rem; } +.project-section-summary > p { max-width: 36rem; margin: 0; color: rgb(var(--muted)); font-size: clamp(1rem, 1.5vw, 1.12rem); text-align: right; } +.project-total { display: inline-flex; align-items: baseline; gap: .45rem; padding: .55rem .7rem; border: 1px solid rgb(var(--pink) / .28); border-radius: .75rem; background: rgb(var(--pink) / .07); } +.project-total strong { color: rgb(var(--pink)); font-family: "Oxanium", Inter, ui-sans-serif, system-ui, sans-serif; font-size: 1.2rem; } +.project-total span { color: rgb(var(--muted)); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.why-layout { display: grid; grid-template-columns: minmax(0, .86fr) minmax(0, 1.14fr); gap: clamp(2.5rem, 7vw, 6rem); align-items: start; } +.why-copy { position: sticky; top: 7.5rem; } +.why-copy h2 { margin: 0; color: rgb(var(--heading)); font-size: clamp(2.35rem, 5.5vw, 4.8rem); line-height: 1.04; letter-spacing: -.05em; } +.why-copy > p:last-child { color: rgb(var(--muted)); font-size: clamp(1rem, 1.5vw, 1.12rem); line-height: 1.7; } +.why-points { display: grid; border-top: 1px solid rgb(var(--border) / .34); } +.why-point { display: grid; grid-template-columns: 2.4rem 1fr; gap: 1rem; padding: 1.45rem 0; border-bottom: 1px solid rgb(var(--border) / .34); } +.why-point > span { color: rgb(var(--pink)); font-size: .7rem; font-weight: 850; letter-spacing: .08em; } +.why-point h3 { margin: 0; color: rgb(var(--heading)); font-size: 1.15rem; } +.why-point p { margin: .45rem 0 0; color: rgb(var(--muted)); font-size: .92rem; line-height: 1.65; } .glass-card { position: relative; isolation: isolate; overflow: hidden; border: 1px solid transparent; border-radius: var(--radius); background: radial-gradient(circle at -18% -35%, rgb(var(--pink) / .07), transparent 34%), rgb(var(--elevated) / .48); box-shadow: 0 20px 60px rgb(0 0 0 / .20), -5px 0 18px rgb(var(--pink) / .045), 5px 0 18px rgb(var(--cyan) / .04), 0 -4px 18px rgb(var(--blue) / .035); backdrop-filter: blur(8px) saturate(112%); } .glass-card::before { content: ""; position: absolute; inset: 0 0 auto; z-index: 3; height: 3px; background: var(--gradient); opacity: .95; pointer-events: none; } .glass-card::after { content: ""; position: absolute; inset: 0; z-index: 2; padding: 1px; border-radius: inherit; background: var(--gradient-soft); pointer-events: none; -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; } @@ -185,14 +203,23 @@ a, button { -webkit-tap-highlight-color: transparent; } .domain-card:hover::after, .project-card:hover::after { background: var(--gradient); } .card-icon { display: inline-flex; align-items: center; justify-content: center; width: 46px; height: 46px; margin-bottom: 1.1rem; color: rgb(var(--heading)); border: 1px solid transparent; border-radius: .8rem; background: linear-gradient(rgb(var(--soft) / .86), rgb(var(--soft) / .86)) padding-box, var(--gradient) border-box; box-shadow: 0 0 18px rgb(var(--blue) / .12); } .card-icon svg { width: 23px; height: 23px; fill: none; stroke: currentColor; stroke-linecap: round; stroke-linejoin: round; stroke-width: 1.65; } -.domain-card h3, .project-card h3, .principles h3, .profile-card h3 { margin: 0; color: rgb(var(--heading)); letter-spacing: -.025em; } +.domain-card h3, .project-card h4, .principles h3, .profile-card h3 { margin: 0; color: rgb(var(--heading)); letter-spacing: -.025em; } .domain-card h3 { font-size: 1.16rem; } .domain-card > p { margin: .7rem 0 1.25rem; color: rgb(var(--muted)); font-size: .92rem; } .tag-list { display: flex; flex-wrap: wrap; gap: .45rem; padding: 0; margin: auto 0 0; list-style: none; } .tag-list li { padding: .32rem .55rem; color: rgb(var(--muted)); border: 1px solid rgb(var(--border) / .40); border-radius: 999px; background: rgb(var(--surface) / .42); font-size: .7rem; font-weight: 700; } -.project-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; } -.project-card { min-height: 280px; padding: clamp(1.5rem, 3vw, 2.2rem); transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease; } +.project-groups { display: grid; gap: clamp(3rem, 7vw, 5.5rem); } +.project-group { display: grid; min-width: 0; gap: 1.25rem; } +.project-group-heading { display: flex; min-width: 0; align-items: end; justify-content: space-between; gap: 1.5rem; padding-bottom: .85rem; border-bottom: 1px solid rgb(var(--border) / .28); } +.project-group-heading p, .project-group-heading h3 { margin: 0; } +.project-group-title-row { display: flex; align-items: center; gap: .55rem; } +.project-group-count { display: inline-flex; align-items: center; justify-content: center; min-width: 1.75rem; height: 1.75rem; padding-inline: .42rem; color: rgb(var(--pink)); border: 1px solid rgb(var(--pink) / .32); border-radius: 999px; background: rgb(var(--pink) / .08); font-size: .68rem; font-weight: 850; letter-spacing: 0; } +.project-group-heading > p:last-child { max-width: 39rem; color: rgb(var(--muted)); font-size: .88rem; text-align: right; } +.project-group-title { color: rgb(var(--pink)); font-size: .82rem; font-weight: 850; letter-spacing: .14em; text-transform: uppercase; } +.project-grid { min-width: 0; grid-template-columns: repeat(2, minmax(0, 1fr)); grid-auto-rows: 1fr; } +.repository-project-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.project-card { min-height: 250px; padding: clamp(1.5rem, 3vw, 2.2rem); transition: border-color 170ms ease, background-color 170ms ease, box-shadow 170ms ease; } .project-featured { background: radial-gradient(circle at 82% 82%, rgb(var(--blue) / .10), transparent 34%), rgb(var(--elevated) / .54); } .project-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .75rem; color: rgb(var(--dim)); font-size: .72rem; font-weight: 700; letter-spacing: .05em; text-transform: uppercase; } .status { display: inline-flex; align-items: center; gap: .35rem; color: rgb(var(--text)); } @@ -200,15 +227,44 @@ a, button { -webkit-tap-highlight-color: transparent; } .status.operational::before { color: rgb(var(--cyan)); background: rgb(var(--cyan)); } .status.in-progress::before { color: rgb(var(--blue)); background: rgb(var(--blue)); } .status.exploratory::before { color: rgb(var(--pink)); background: rgb(var(--pink)); } -.project-card h3 { margin-top: 1.5rem; font-size: clamp(1.7rem, 3vw, 2.5rem); } +.visibility-badge { display: inline-flex; align-items: center; padding: .3rem .5rem; color: rgb(var(--muted)); border: 1px solid rgb(var(--border) / .46); border-radius: 999px; background: rgb(var(--surface) / .44); font-size: .68rem; font-weight: 800; letter-spacing: .08em; } +.visibility-private { border-style: dashed; } +.project-category { display: inline-flex; align-items: center; padding: .3rem .5rem; color: rgb(var(--cyan) / .78); border: 1px solid rgb(var(--cyan) / .18); border-radius: 999px; background: rgb(var(--cyan) / .045); font-size: .66rem; font-weight: 750; letter-spacing: .04em; text-transform: none; } +.project-filter-bar { display: flex; width: 100%; min-width: 0; align-items: center; justify-content: space-between; gap: 1rem; } +.project-filters { display: flex; min-width: 0; max-width: 100%; flex-wrap: wrap; gap: .5rem; } +.project-filter { min-height: 44px; padding: .48rem .7rem; color: rgb(var(--muted)); border: 1px solid rgb(var(--border) / .42); border-radius: 999px; background: rgb(var(--surface) / .38); font: inherit; font-size: .72rem; font-weight: 750; cursor: pointer; transition: color 150ms ease, border-color 150ms ease, background-color 150ms ease; } +.project-filter span { margin-left: .22rem; color: rgb(var(--dim)); } +.project-filter:hover, .project-filter:focus-visible, .project-filter.is-active { color: rgb(var(--heading)); border-color: rgb(var(--pink) / .46); background: rgb(var(--pink) / .09); } +.project-filter.is-active span { color: rgb(var(--pink)); } +.project-filter-status { flex: 0 0 auto; margin: 0; color: rgb(var(--dim)); font-size: .72rem; } +.project-activity { display: grid; min-width: 0; gap: 1.15rem; margin-top: clamp(1.5rem, 4vw, 2.5rem); padding-top: clamp(1.5rem, 4vw, 2.5rem); border-top: 1px solid rgb(var(--border) / .28); } +.project-activity-heading { display: flex; align-items: end; justify-content: space-between; gap: 1.5rem; } +.project-activity-heading .eyebrow { margin-bottom: .35rem; } +.project-activity-heading h3 { margin: 0; color: rgb(var(--heading)); font-size: clamp(1.5rem, 3vw, 2.1rem); letter-spacing: -.035em; } +.project-activity-heading > p { max-width: 34rem; margin: 0; color: rgb(var(--muted)); font-size: .84rem; text-align: right; } +.activity-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .8rem; } +.activity-item { display: grid; min-height: 132px; gap: .38rem; padding: 1rem; border: 1px solid rgb(var(--border) / .38); border-radius: .9rem; background: rgb(var(--surface) / .32); transition: border-color 150ms ease, background-color 150ms ease; } +.activity-item:hover, .activity-item:focus-visible { border-color: rgb(var(--cyan) / .45); background: rgb(var(--soft) / .58); } +.activity-item strong { color: rgb(var(--heading)); font-size: 1rem; line-height: 1.25; } +.activity-kicker { color: rgb(var(--pink)); font-size: .62rem; font-weight: 850; letter-spacing: .1em; text-transform: uppercase; } +.activity-meta { display: flex; align-items: end; justify-content: space-between; gap: .75rem; margin-top: auto; color: rgb(var(--dim)); font-size: .68rem; } +.activity-meta > span { color: rgb(var(--cyan) / .78); font-weight: 750; } +.activity-empty { grid-column: 1 / -1; margin: 0; color: rgb(var(--muted)); } +.project-card h4 { margin-top: 1.35rem; font-size: clamp(1.7rem, 3vw, 2.5rem); } .project-card > p { width: min(100%, 560px); margin: .85rem 0 0; color: rgb(var(--muted)); } +.repository-card { display: flex; min-height: 235px; flex-direction: column; padding: 1.45rem; } +.repository-card[hidden] { display: none; } +.repository-card h4 { margin-top: 1.05rem; font-size: clamp(1.35rem, 2vw, 1.85rem); line-height: 1.08; } +.repository-card > p { margin-top: .65rem; font-size: .88rem; line-height: 1.55; } .project-logo { position: absolute; right: 1.5rem; bottom: 1.35rem; width: clamp(72px, 9vw, 108px); height: auto; opacity: .18; filter: saturate(.9) drop-shadow(0 0 18px rgb(var(--blue) / .24)); pointer-events: none; } .architecture-layout { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(290px, .7fr); gap: clamp(1.5rem, 4vw, 3rem); align-items: stretch; } .architecture-stack { padding: clamp(1.3rem, 3vw, 2rem); border: 1px solid rgb(var(--border) / .42); border-radius: calc(var(--radius) + .25rem); background: rgb(var(--elevated) / .34); box-shadow: inset 0 1px 0 rgb(255 255 255 / .025); } -.architecture-layer { display: flex; align-items: center; justify-content: center; min-height: 64px; padding: .85rem 1rem; color: rgb(var(--text)); border: 1px solid rgb(var(--border) / .52); border-radius: .8rem; background: rgb(var(--soft) / .66); text-align: center; font-size: .9rem; font-weight: 750; } +.architecture-layer { position: relative; display: flex; align-items: center; justify-content: center; min-height: 78px; padding: 1.7rem 1rem .85rem; color: rgb(var(--text)); border: 1px solid rgb(var(--border) / .52); border-radius: .8rem; background: rgb(var(--soft) / .66); text-align: center; font-size: .9rem; font-weight: 750; } +.architecture-layer::before { content: attr(data-layer); position: absolute; top: .45rem; left: .7rem; color: rgb(var(--dim)); font-size: .61rem; font-weight: 850; letter-spacing: .12em; text-transform: uppercase; } .layer-home, .layer-foundation { border-color: transparent; background: linear-gradient(rgb(var(--soft) / .88), rgb(var(--soft) / .88)) padding-box, var(--gradient) border-box; box-shadow: 0 0 24px rgb(var(--blue) / .08); } -.layer-multi { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; border: 0; background: transparent; padding: 0; } +.layer-multi { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: .6rem; min-height: 0; border: 0; background: transparent; padding: 1.75rem 0 0; } +.layer-multi::before { top: .2rem; left: 0; } .layer-multi span { display: grid; place-items: center; min-height: 64px; padding: .75rem; border: 1px solid rgb(var(--border) / .48); border-radius: .75rem; background: rgb(var(--soft) / .62); } .architecture-arrow { padding-block: .2rem; color: rgb(var(--cyan) / .68); text-align: center; font-size: 1.15rem; } .principles { padding: clamp(1.5rem, 3vw, 2.2rem); border: 1px solid rgb(var(--border) / .48); border-radius: calc(var(--radius) + .25rem); background: radial-gradient(circle at 90% 8%, rgb(var(--pink) / .10), transparent 36%), rgb(var(--elevated) / .44); } @@ -298,6 +354,10 @@ a, button { -webkit-tap-highlight-color: transparent; } .home-link:hover svg, .home-link:focus-visible svg { transform: translateX(3px); } @media (max-width: 980px) { + .repository-project-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .why-layout { grid-template-columns: 1fr; gap: 2.2rem; } + .why-copy { position: static; } + .activity-grid { grid-template-columns: 1fr; } .domain-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } .architecture-layout, .about-grid { grid-template-columns: 1fr; } .profile-card { max-width: 680px; } @@ -318,16 +378,24 @@ a, button { -webkit-tap-highlight-color: transparent; } .nav-menu { display: block; grid-column: 3; justify-self: end; } .nav-toggle { display: inline-flex; } .dashboard-link { grid-column: 4; } - .mobile-nav { position: absolute; top: calc(100% + .5rem); right: 1rem; left: 1rem; display: grid; justify-content: stretch; gap: .1rem; padding: .55rem; border: 1px solid rgb(var(--border) / .55); border-radius: .9rem; background: rgb(var(--elevated) / .96); box-shadow: 0 20px 50px rgb(0 0 0 / .38); backdrop-filter: blur(18px); opacity: 0; pointer-events: none; transform: translateY(-.4rem) scale(.98); transform-origin: top; transition: opacity 150ms ease, transform 150ms ease; } - .nav-menu[open] .mobile-nav { opacity: 1; pointer-events: auto; transform: translateY(0) scale(1); } + .mobile-nav { position: absolute; top: calc(100% + .5rem); right: 1rem; left: 1rem; display: none; justify-content: stretch; gap: .1rem; padding: .55rem; border: 1px solid rgb(var(--border) / .55); border-radius: .9rem; background: rgb(var(--elevated) / .96); box-shadow: 0 20px 50px rgb(0 0 0 / .38); backdrop-filter: blur(18px); opacity: 0; pointer-events: none; transform: translateY(-.4rem) scale(.98); transform-origin: top; transition: opacity 150ms ease, transform 150ms ease; } + .nav-menu[open] .mobile-nav { display: grid; opacity: 1; pointer-events: auto; transform: translateY(0) scale(1); } .primary-nav a { padding: .75rem .8rem; border-radius: .55rem; } .primary-nav a:hover, .primary-nav a.is-active { background: linear-gradient(90deg, rgb(var(--pink) / .10), rgb(var(--blue) / .13), rgb(var(--cyan) / .08)); } .primary-nav a::after { display: none; } .hero { min-height: 92svh; padding-top: 7.2rem; } - .hero-orbit { width: 100vw; } + .hero-orbit { width: 88vw; } .section { padding-block: 5.3rem; } .split-heading, .domain-grid, .project-grid { grid-template-columns: 1fr; } .split-heading { gap: 1rem; } + .project-section-summary { justify-items: start; } + .project-section-summary > p { text-align: left; } + .project-filter-bar, .project-activity-heading { align-items: flex-start; flex-direction: column; } + .project-filter-status, .project-activity-heading > p { text-align: left; } + .project-filters { flex-wrap: nowrap; width: 100%; padding-bottom: .25rem; overflow-x: auto; scrollbar-width: thin; } + .project-filter { flex: 0 0 auto; } + .project-group-heading { align-items: flex-start; flex-direction: column; gap: .35rem; } + .project-group-heading > p:last-child { text-align: left; } .project-featured { min-height: 280px; } .layer-multi { grid-template-columns: 1fr; } .profile-card { grid-template-columns: 110px 1fr; } @@ -347,9 +415,12 @@ a, button { -webkit-tap-highlight-color: transparent; } .hero { padding-inline: 1rem; } .hero .eyebrow { width: min(100%, 21rem); margin-inline: auto; font-size: .68rem; letter-spacing: .14em; overflow-wrap: anywhere; } .hero h1 { max-width: 100%; font-size: clamp(2.7rem, 13.2vw, 4.6rem); letter-spacing: .02em; white-space: nowrap; } - .hero-copy { width: min(100%, 21rem); font-size: .98rem; line-height: 1.5; } - .hero-copy .danger-line { width: min(100%, 20rem); margin-inline: auto; font-size: .88rem; } - .hero-context { width: min(100%, 20rem); font-size: .9rem; line-height: 1.55; } + .hero-context { width: min(100%, 21rem); font-size: 1rem; line-height: 1.55; } + .hero-signature { width: min(100%, 20rem); font-size: .84rem; line-height: 1.5; } + .hero-scope { width: min(100%, 22rem); } + .hero-scope a { min-height: 62px; padding: .55rem .35rem; } + .hero-scope strong { font-size: 1.05rem; } + .hero-scope span { font-size: .58rem; letter-spacing: .04em; } .domain-card { min-height: auto; } .architecture-stack, .principles { padding: 1rem; } .profile-card { grid-template-columns: 1fr; text-align: center; } @@ -367,20 +438,27 @@ a, button { -webkit-tap-highlight-color: transparent; } .glitch-layer { display: none !important; } } -.project-repo-link { - position: absolute; - right: 1.5rem; - bottom: 1.35rem; - display: inline-grid; - width: 44px; - height: 44px; - place-items: center; - color: rgb(var(--muted)); - border: 1px solid rgb(var(--border) / .42); - border-radius: 9999px; - background: rgb(var(--base) / .45); - transition: color 170ms ease, border-color 170ms ease, background-color 170ms ease; +.project-repo-link { display: inline-flex; align-items: center; align-self: flex-end; min-height: 45px; gap: .35rem; margin-top: auto; padding-top: 1rem; color: rgb(var(--cyan) / .82); font-size: .73rem; font-weight: 800; letter-spacing: .02em; transition: color 150ms ease; } +.project-repo-link:hover, .project-repo-link:focus-visible { color: rgb(var(--heading)); } + +/* Project detail pages and public project deep links. */ +.project-detail-link { color: inherit; text-decoration: none; } +.project-detail-link:hover, .project-detail-link:focus-visible { color: rgb(var(--cyan)); } +.project-provenance { color: rgb(var(--dim)); font-size: .64rem; font-weight: 750; letter-spacing: .03em; text-transform: none; } +.activity-release { color: rgb(var(--muted)); font-size: .78rem; } +.project-detail-page { min-height: 100svh; padding-top: 76px; } +.project-detail-hero { width: min(calc(100% - 2rem), var(--max)); margin-inline: auto; padding: clamp(5rem, 9vw, 8rem) 0 clamp(3rem, 6vw, 5rem); } +.project-back-link { display: inline-flex; min-height: 44px; align-items: center; color: rgb(var(--cyan)); font-size: .8rem; font-weight: 800; } +.project-detail-heading { max-width: 850px; margin-top: 2rem; } +.project-detail-heading h1 { margin: .8rem 0 0; color: rgb(var(--heading)); font-family: "Oxanium", Inter, ui-sans-serif, system-ui, sans-serif; font-size: clamp(2.8rem, 7vw, 6rem); line-height: 1; letter-spacing: -.045em; } +.project-detail-heading > p { max-width: 720px; margin: 1.25rem 0 0; color: rgb(var(--muted)); font-size: clamp(1rem, 1.8vw, 1.2rem); line-height: 1.7; } +.project-primary-link { min-height: 44px; } +.project-detail-section { padding-top: 0; } +.project-facts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid rgb(var(--border) / .36); border-radius: 1rem; background: rgb(var(--border) / .36); } +.project-facts > div { padding: 1.2rem; background: rgb(var(--elevated) / .72); backdrop-filter: blur(8px) saturate(112%); } +.project-facts dt { color: rgb(var(--dim)); font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; } +.project-facts dd { margin: .3rem 0 0; color: rgb(var(--heading)); font-weight: 700; } +@media (max-width: 760px) { + .project-filter-bar { position: sticky; top: 68px; z-index: 20; margin-inline: -.25rem; padding: .65rem .25rem; background: rgb(var(--surface) / .82); backdrop-filter: blur(16px) saturate(120%); } + .project-facts { grid-template-columns: 1fr; } } -.project-repo-link:hover { color: rgb(var(--heading)); border-color: rgb(var(--cyan) / .6); background: rgb(var(--soft) / .72); } -.project-repo-link:focus-visible { outline: 2px solid rgb(var(--cyan)); outline-offset: 3px; } -.project-repo-link svg { width: 22px; height: 22px; fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; } diff --git a/tests/github-releases.fixture.json b/tests/github-releases.fixture.json new file mode 100644 index 0000000..1e6de97 --- /dev/null +++ b/tests/github-releases.fixture.json @@ -0,0 +1,12 @@ +{ + "dbackup-mcp": { + "tag_name": "v0.8.0", + "published_at": "2026-09-06T12:00:00Z", + "html_url": "https://github.com/X1pheR/dbackup-mcp/releases/tag/v0.8.0" + }, + "hypershell-reach": { + "tag_name": "v0.14.0", + "published_at": "2026-09-04T11:00:00Z", + "html_url": "https://github.com/X1pheR/hypershell-reach/releases/tag/v0.14.0" + } +} diff --git a/tests/github-repositories.fixture.json b/tests/github-repositories.fixture.json index 9b45ef7..46dd09d 100644 --- a/tests/github-repositories.fixture.json +++ b/tests/github-repositories.fixture.json @@ -5,7 +5,8 @@ "archived": false, "private": false, "description": "Secure, value-blind Model Context Protocol server for Bitwarden Secrets Manager using the official Python SDK.", - "html_url": "https://github.com/X1pheR/bitwarden-secrets-manager-mcp" + "html_url": "https://github.com/X1pheR/bitwarden-secrets-manager-mcp", + "pushed_at": "2026-09-05T00:30:33Z" }, { "name": "dbackup-mcp", @@ -13,7 +14,8 @@ "archived": false, "private": false, "description": "Backup management MCP server.", - "html_url": "https://github.com/X1pheR/dbackup-mcp" + "html_url": "https://github.com/X1pheR/dbackup-mcp", + "pushed_at": "2026-08-20T10:06:01Z" }, { "name": "hypershell-reach", @@ -21,7 +23,8 @@ "archived": false, "private": false, "description": "Homelab Agent Tooling & Skills (HATS): managed agent tooling, bounded remote execution, task continuity, and shared skill discovery.", - "html_url": "https://github.com/X1pheR/hypershell-reach" + "html_url": "https://github.com/X1pheR/hypershell-reach", + "pushed_at": "2026-09-05T13:57:19Z" }, { "name": "resources", @@ -29,7 +32,8 @@ "archived": false, "private": false, "description": "Not a project card.", - "html_url": "https://github.com/X1pheR/resources" + "html_url": "https://github.com/X1pheR/resources", + "pushed_at": "2026-08-26T14:48:35Z" }, { "name": "firefly-iii-mcp", @@ -37,6 +41,16 @@ "archived": false, "private": true, "description": "Strictly read-only, bounded MCP server for Firefly III financial analysis.", - "html_url": "https://github.com/X1pheR/firefly-iii-mcp" + "html_url": "https://github.com/X1pheR/firefly-iii-mcp", + "pushed_at": "2026-08-31T08:05:46Z" + }, + { + "name": "hypershell-website", + "homepage": "https://www.hypershell.eu/#projects", + "archived": false, + "private": false, + "description": "Static public website for the Hypershell homelab and connected home environment.", + "html_url": "https://github.com/X1pheR/hypershell-website", + "pushed_at": "2026-09-08T20:00:00Z" } ] diff --git a/tests/server.cjs b/tests/server.cjs index d52533d..78e511b 100644 --- a/tests/server.cjs +++ b/tests/server.cjs @@ -49,6 +49,14 @@ const server = http.createServer((request, response) => { sendFile(response, requestedFile, 200); return; } + if (!error && stats.isDirectory()) { + const indexFile = path.join(requestedFile, 'index.html'); + fs.stat(indexFile, (indexError, indexStats) => { + if (!indexError && indexStats.isFile()) sendFile(response, indexFile, 200); + else sendFile(response, path.join(root, '404.html'), 404); + }); + return; + } sendFile(response, path.join(root, '404.html'), 404); }); diff --git a/tests/site.spec.cjs b/tests/site.spec.cjs index 5e80486..40ab55a 100644 --- a/tests/site.spec.cjs +++ b/tests/site.spec.cjs @@ -58,19 +58,24 @@ for (const viewport of viewports) { await expectElementsWithinViewport(page, [ '.hero .eyebrow', '.hero h1', - '.hero-copy', '.hero-context', + '.hero-signature', + '.hero-scope', '.section-heading', ]); - const spiny = await page.locator('.hero .spiny-image-base:not([data-islayer])').evaluate((image) => ({ + const spiny = await page.locator('[data-hero-spiny-glitch] > picture:not([data-islayer]) > .spiny-image-base').evaluate((image) => ({ naturalWidth: image.naturalWidth, naturalHeight: image.naturalHeight, renderedRatio: image.getBoundingClientRect().width / image.getBoundingClientRect().height, + currentSrc: image.currentSrc, })); expect(spiny.naturalWidth).toBe(512); expect(spiny.naturalHeight).toBe(512); expect(spiny.renderedRatio).toBeCloseTo(1, 2); + expect(spiny.currentSrc).toContain('/spiny.webp'); + await expect(page.locator('.brand img')).toHaveAttribute('src', '/masterbrand-96.png'); + await expect(page.locator('.dashboard-link')).toBeVisible(); const missingAnchors = await page.evaluate(() => { return Array.from(document.querySelectorAll('a[href^="#"]')) @@ -79,6 +84,9 @@ for (const viewport of viewports) { }); expect(missingAnchors).toEqual([]); + const navOrders = await page.locator('.primary-nav').evaluateAll((navs) => navs.map((nav) => Array.from(nav.querySelectorAll('a')).map((link) => link.getAttribute('href')))); + for (const order of navOrders) expect(order).toEqual(['#inside', '#architecture', '#projects', '#about']); + expect(await page.locator('.profile-links svg').count()).toBe(3); expect(await page.locator('.footer-socials svg').count()).toBe(3); @@ -115,22 +123,52 @@ for (const viewport of viewports) { } if (viewport.width >= 761) { - const heights = await page.locator('.project-card').evaluateAll((cards) => cards.map((card) => card.getBoundingClientRect().height)); - expect(Math.max(...heights) - Math.min(...heights)).toBeLessThanOrEqual(1); + const heightRanges = await page.locator('.project-grid').evaluateAll((grids) => grids.map((grid) => { + const heights = Array.from(grid.querySelectorAll('.project-card')).map((card) => card.getBoundingClientRect().height); + return heights.length > 1 ? Math.max(...heights) - Math.min(...heights) : 0; + })); + for (const range of heightRanges) expect(range).toBeLessThanOrEqual(1); } - await expect(page.getByRole('heading', { name: 'DBackup MCP', level: 3 })).toBeVisible(); + await expect(page.getByText('One environment, not a pile of services.', { exact: true })).toBeVisible(); + const sectionOrder = await page.locator('main > section[id]').evaluateAll((sections) => sections.map((section) => section.id)); + expect(sectionOrder).toEqual(['top', 'inside', 'why', 'architecture', 'projects', 'about']); + + await expect(page.getByText('Core initiatives', { exact: true })).toBeVisible(); + await expect(page.getByText('Maintained software', { exact: true })).toBeVisible(); + const coreGroup = page.locator('.project-group', { has: page.getByText('Core initiatives', { exact: true }) }); + const maintainedGroup = page.locator('.project-group', { has: page.getByText('Maintained software', { exact: true }) }); + const coreCount = await coreGroup.locator('.project-card').count(); + const repositoryCount = await maintainedGroup.locator('.repository-card').count(); + await expect(coreGroup.locator('.project-group-count')).toContainText(String(coreCount)); + await expect(maintainedGroup.locator('.project-group-count')).toContainText(String(repositoryCount)); + await expect(page.locator('.project-total strong')).toHaveText(String(coreCount + repositoryCount)); + await expect(page.locator('.hero-scope a').nth(0).locator('strong')).toHaveText('6'); + await expect(page.locator('.hero-scope a').nth(1).locator('strong')).toHaveText(String(coreCount)); + await expect(page.locator('.hero-scope a').nth(2).locator('strong')).toHaveText(String(repositoryCount)); + await expect(maintainedGroup.locator('.project-category')).toHaveCount(repositoryCount); + await expect(maintainedGroup.locator('.project-provenance')).toHaveCount(repositoryCount); + await expect(page.locator('#core-initiatives')).toHaveCount(1); + await expect(maintainedGroup.locator('[data-project-filters]')).toBeVisible(); + await expect(page.getByText('GitHub project', { exact: true })).toHaveCount(0); + + await expect(page.getByRole('heading', { name: 'DBackup MCP', level: 4 })).toBeVisible(); await expect(page.getByRole('link', { name: 'Open DBackup MCP on GitHub' })).toHaveAttribute('href', 'https://github.com/X1pheR/dbackup-mcp'); - const fireflyHeading = page.getByRole('heading', { name: 'Firefly III MCP', level: 3 }); - await expect(fireflyHeading).toBeVisible(); - await expect(fireflyHeading.locator('xpath=ancestor::article').getByText('PRIVATE')).toBeVisible(); - await expect(page.getByRole('link', { name: 'Open Firefly III MCP on GitHub' })).toHaveCount(0); - await expect(page.getByRole('heading', { name: 'Hypershell Reach', level: 3 })).toBeVisible(); + const privateCards = maintainedGroup.locator('.repository-card:has(.visibility-private)'); + expect(await privateCards.count()).toBeGreaterThan(0); + const privateCard = privateCards.first(); + await expect(privateCard.getByText('PRIVATE', { exact: true })).toBeVisible(); + await expect(privateCard.locator('.visibility-badge')).toHaveClass(/visibility-private/); + await expect(privateCard.locator('.status')).toHaveCount(0); + await expect(privateCard.locator('.project-repo-link')).toHaveCount(0); + await expect(page.getByRole('heading', { name: 'Hypershell Reach', level: 4 })).toBeVisible(); await expect(page.getByRole('link', { name: 'Open Hypershell Reach on GitHub' })).toHaveAttribute('href', 'https://github.com/X1pheR/hypershell-reach'); + await expect(maintainedGroup.getByText('Latest public updates.', { exact: true })).toBeVisible(); + await expect(maintainedGroup.locator('.activity-item')).toHaveCount(3); + await expect(maintainedGroup.getByText('Latest release', { exact: true }).first()).toBeVisible(); + await expect(maintainedGroup.locator('.project-activity').getByText('Hypershell Website', { exact: true })).toHaveCount(0); - await page.locator('#inside .section-heading').evaluate((element) => { - element.scrollIntoView({ block: 'center' }); - }); + await page.locator('#inside .section-heading').scrollIntoViewIfNeeded(); await expect(page.locator('#inside .section-heading')).toHaveClass(/is-visible/); const unexpectedResponses = observed.failedResponses.filter(({ url }) => !url.endsWith('/missing-test-route')); @@ -142,6 +180,61 @@ for (const viewport of viewports) { }); } +test('maintained software filters are keyboard-accessible and preserve the total count', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 1366, height: 900 } }); + const page = await context.newPage(); + await page.goto('/', { waitUntil: 'networkidle' }); + + const group = page.locator('#maintained-software'); + const total = await group.locator('.repository-card').count(); + const operations = group.getByRole('button', { name: /Operations/ }); + const operationsCount = await group.locator('.repository-card[data-project-category="operations"]').count(); + expect(operationsCount).toBeGreaterThan(0); + await operations.focus(); + await page.keyboard.press('Enter'); + await expect(operations).toHaveAttribute('aria-pressed', 'true'); + await expect(group.locator('.repository-card:visible')).toHaveCount(operationsCount); + await expect(group.locator('[data-project-filter-status]')).toHaveText(`Showing ${operationsCount} of ${total} projects`); + + const all = group.getByRole('button', { name: /^All/ }); + await all.click(); + await expect(group.locator('.repository-card:visible')).toHaveCount(total); + await expect(group.locator('.project-group-count')).toContainText(String(total)); + const touchHeights = await group.locator('.project-filter, .project-repo-link').evaluateAll((items) => items.filter((item) => !item.hidden && getComputedStyle(item).display !== 'none').map((item) => item.getBoundingClientRect().height)); + for (const height of touchHeights) expect(height).toBeGreaterThanOrEqual(43.9); + const publicCard = group.locator('.repository-card', { has: page.getByRole('link', { name: 'Open DBackup MCP on GitHub' }) }); + const placement = await publicCard.evaluate((card) => { const link = card.querySelector('.project-repo-link'); const c = card.getBoundingClientRect(); const l = link.getBoundingClientRect(); return { rightGap: c.right - l.right, leftGap: l.left - c.left }; }); + expect(placement.rightGap).toBeLessThan(40); + expect(placement.leftGap).toBeGreaterThan(placement.rightGap); + await context.close(); +}); + +test('public metadata uses Hypershell identity and publishes a security contact', async ({ page }) => { + await page.goto('/', { waitUntil: 'networkidle' }); + await expect(page.locator('link[rel="icon"][href="/favicon-96x96.png"]')).toHaveCount(1); + await expect(page.locator('link[rel="icon"][href="/favicon.svg"]')).toHaveCount(0); + + const manifestResponse = await page.request.get('/site.webmanifest'); + expect(manifestResponse.status()).toBe(200); + const manifest = await manifestResponse.json(); + expect(manifest.name).toBe('Hypershell'); + expect(manifest.short_name).toBe('Hypershell'); + expect(manifest.theme_color).toBe('#050816'); + expect(manifest.background_color).toBe('#050816'); + expect(manifest.icons.every((icon) => icon.purpose === 'any')).toBe(true); + const structuredData = await page.locator('script[type="application/ld+json"]').evaluateAll((scripts) => scripts.map((script) => JSON.parse(script.textContent))); + const software = structuredData.find((entry) => entry['@type'] === 'ItemList'); + expect(software).toBeTruthy(); + expect(software.itemListElement.length).toBeGreaterThan(0); + expect(software.itemListElement.every((entry) => entry.item['@type'] === 'SoftwareSourceCode')).toBe(true); + + const securityResponse = await page.request.get('/.well-known/security.txt'); + expect(securityResponse.status()).toBe(200); + const security = await securityResponse.text(); + expect(security).toContain('Contact: mailto:info@hypershell.eu'); + expect(security).toContain('Canonical: https://www.hypershell.eu/.well-known/security.txt'); +}); + test('mobile navigation is keyboard-safe and restores focus', async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); const page = await context.newPage(); @@ -181,7 +274,7 @@ test('mobile navigation remains usable without JavaScript', async ({ browser }) await context.close(); }); -test('homepage hero glitches independently and keeps header static', async ({ page }) => { +test('homepage hero glitches intermittently and keeps header static', async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto('/', { waitUntil: 'networkidle' }); @@ -208,14 +301,24 @@ test('homepage hero glitches independently and keeps header static', async ({ pa expect(wordmarkFont.weight).toBe('700'); expect(wordmarkFont.loaded).toBe(true); - await page.waitForFunction( - () => ['spiny', 'title'].includes(document.documentElement.dataset.heroGlitchActive), - null, - { timeout: 1600 }, - ); - const activeTarget = await page.evaluate(() => document.documentElement.dataset.heroGlitchActive); - expect(['spiny', 'title']).toContain(activeTarget); - await page.waitForFunction(() => !document.documentElement.dataset.heroGlitchActive, null, { timeout: 1200 }); + await page.evaluate(() => { + window.__heroGlitchActivations = 0; + window.__heroGlitchTargets = []; + window.__heroGlitchObserver = new MutationObserver(() => { + const active = document.documentElement.dataset.heroGlitchActive; + if (active) { + window.__heroGlitchActivations += 1; + window.__heroGlitchTargets.push(active); + } + }); + window.__heroGlitchObserver.observe(document.documentElement, { + attributes: true, + attributeFilter: ['data-hero-glitch-active'], + }); + }); + await page.waitForFunction(() => window.__heroGlitchActivations > 0, null, { timeout: 4500 }); + const recurringTargets = await page.evaluate(() => window.__heroGlitchTargets); + expect(recurringTargets.some((target) => ['spiny', 'title'].includes(target))).toBe(true); const visualStyles = await page.evaluate(() => { const dashboard = document.querySelector('.dashboard-link'); @@ -255,15 +358,17 @@ test('homepage hero glitches independently and keeps header static', async ({ pa width: document.querySelector('meta[property="og:image:width"]')?.content, height: document.querySelector('meta[property="og:image:height"]')?.content, alt: document.querySelector('meta[property="og:image:alt"]')?.content, + twitterAlt: document.querySelector('meta[name="twitter:image:alt"]')?.content, siteName: document.querySelector('meta[property="og:site_name"]')?.content, locale: document.querySelector('meta[property="og:locale"]')?.content, structuredData: JSON.parse(document.querySelector('script[type="application/ld+json"]')?.textContent || '{}'), })); - expect(metadata.image).toBe('https://www.hypershell.eu/social-card.png'); + expect(metadata.image).toBe('https://www.hypershell.eu/social-card.jpg'); expect(metadata.width).toBe('1200'); expect(metadata.height).toBe('630'); expect(metadata.alt).toBeTruthy(); + expect(metadata.twitterAlt).toBeTruthy(); expect(metadata.siteName).toBe('Hypershell'); expect(metadata.locale).toBe('en_US'); expect(metadata.structuredData['@type']).toBe('WebSite'); @@ -272,11 +377,42 @@ test('homepage hero glitches independently and keeps header static', async ({ pa const image = new Image(); image.onload = () => resolve({ width: image.naturalWidth, height: image.naturalHeight }); image.onerror = reject; - image.src = '/social-card.png'; + image.src = '/social-card.jpg'; })); expect(socialCard).toEqual({ width: 1200, height: 630 }); }); +test('mobile keeps glass blur and uses sticky maintained-software filters', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + await page.goto('/', { waitUntil: 'networkidle' }); + await expect(page.locator('.dashboard-link')).toBeVisible(); + const blur = await page.locator('.glass-card').first().evaluate((element) => getComputedStyle(element).backdropFilter || getComputedStyle(element).webkitBackdropFilter); + expect(blur).toContain('blur'); + const filters = page.locator('#maintained-software [data-project-filters]'); + await filters.scrollIntoViewIfNeeded(); + const styles = await filters.evaluate((element) => { const style = getComputedStyle(element); return { position: style.position, top: style.top, blur: style.backdropFilter || style.webkitBackdropFilter }; }); + expect(styles.position).toBe('sticky'); + expect(styles.blur).toContain('blur'); + await context.close(); +}); + +test('generated project detail route is shareable, responsive and keeps public/private boundaries', async ({ browser }) => { + const context = await browser.newContext({ viewport: { width: 390, height: 844 } }); + const page = await context.newPage(); + const response = await page.goto('/projects/hypershell-reach/', { waitUntil: 'networkidle' }); + expect(response?.status()).toBe(200); + await expect(page.getByRole('heading', { name: 'Hypershell Reach', level: 1 })).toBeVisible(); + await expect(page.getByRole('link', { name: /View on GitHub/ })).toHaveAttribute('href', 'https://github.com/X1pheR/hypershell-reach'); + await expect(page.locator('link[rel="canonical"]')).toHaveAttribute('href', 'https://www.hypershell.eu/projects/hypershell-reach/'); + await expect(page.locator('.brand img')).toHaveAttribute('src', '/masterbrand-96.png'); + await expect(page.locator('.dashboard-link')).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390); + const axe = await new AxeBuilder({ page }).withTags(['wcag2a', 'wcag2aa']).analyze(); + expect(axe.violations).toEqual([]); + await context.close(); +}); + test('reduced motion disables recurring hero glitches', async ({ browser }) => { const context = await browser.newContext({ viewport: { width: 390, height: 844 }, diff --git a/tests/test_render_projects.py b/tests/test_render_projects.py index 0427dc7..15fa40b 100644 --- a/tests/test_render_projects.py +++ b/tests/test_render_projects.py @@ -3,94 +3,162 @@ import subprocess import tempfile import unittest +from datetime import datetime, timezone from pathlib import Path - import sys -sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "scripts")) import render_projects +def repo(name, **overrides): + base = { + "name": name, + "homepage": "https://www.hypershell.eu/#projects", + "archived": False, + "private": False, + "description": f"{name} description", + "html_url": f"https://github.com/X1pheR/{name}", + "pushed_at": "2026-09-01T10:00:00Z", + } + base.update(overrides) + return base + + class ProjectRenderingTests(unittest.TestCase): - def test_selects_only_active_repositories_with_exact_project_homepage(self): + def test_selects_exact_homepage_or_explicit_include_and_excludes_archived(self): repos = [ - {"name": "included", "homepage": "https://www.hypershell.eu/#projects", "archived": False, "private": False, "description": "Included", "html_url": "https://github.com/X1pheR/included"}, - {"name": "wrong-homepage", "homepage": "https://www.hypershell.eu", "archived": False, "private": False, "description": "No", "html_url": "https://github.com/X1pheR/wrong-homepage"}, - {"name": "archived", "homepage": "https://www.hypershell.eu/#projects", "archived": True, "private": False, "description": "No", "html_url": "https://github.com/X1pheR/archived"}, + repo("normal"), + repo("explicit", homepage=""), + repo("wrong", homepage="https://www.hypershell.eu"), + repo("archived", archived=True), ] - selected = render_projects.select_repositories(repos, {}) - self.assertEqual([repo["name"] for repo in selected], ["included"]) - - def test_private_repository_is_rendered_without_repository_link(self): - html = render_projects.render_repository_card( - {"name": "private-tooling", "homepage": "https://www.hypershell.eu/#projects", "archived": False, "private": True, "description": "Private tooling", "html_url": "https://github.com/X1pheR/private-tooling"}, - {"private-tooling": "Private Tooling"}, - ) - self.assertIn("Private Tooling", html) - self.assertIn("PRIVATE", html) - self.assertNotIn("https://github.com/X1pheR/private-tooling", html) - - def test_public_repository_uses_description_override_and_github_link(self): - html = render_projects.render_repository_card( - {"name": "dbackup-mcp", "homepage": "https://www.hypershell.eu/#projects", "archived": False, "private": False, "description": "Backup API", "html_url": "https://github.com/X1pheR/dbackup-mcp"}, - {"dbackup-mcp": "DBackup MCP"}, - ) - self.assertIn("DBackup MCP", html) - self.assertIn("Backup API", html) - self.assertIn("PUBLIC", html) - self.assertIn('href="https://github.com/X1pheR/dbackup-mcp"', html) - - def test_repository_fields_are_html_escaped(self): - html = render_projects.render_repository_card( - {"name": "unsafe", "homepage": "https://www.hypershell.eu/#projects", "archived": False, "private": False, "description": '', "html_url": "https://github.com/X1pheR/unsafe"}, - {}, - ) - self.assertNotIn("') + rendered = render_projects.render_repository_card(item, {}) + self.assertNotIn("