Files
Anthias/website/scripts/fetch-screenshots.ts
Viktor Petersson 473d8991bf feat(website): home-page screenshot slider fed from CI captures (#2899)
* feat(website): home-page screenshot slider fed from CI captures

- Replace the static overview hero with a scroll-snap slider framed
  as a browser-window chrome (traffic lights, URL pill, counter,
  brand-yellow autoplay progress bars).
- Slides are sourced from website/assets/images/screenshots/ —
  gitignored; deploy-website.yaml downloads the latest successful
  marketing-screenshots artifact at build time, and the new
  bun run screenshots:fetch mirrors that for local dev.
- TypeScript slider in assets/js/slider.ts (deferred, 1.4 KB
  gzipped) handles autoplay, pause-off-screen, hover-pause,
  keyboard nav, and respects prefers-reduced-motion.
- Add a system-info marketing capture; the integration test
  overwrites the rendered DOM with curated Pi-5-shaped values
  under MARKETING_SCREENSHOTS=1 (page_context lives in uvicorn,
  out of monkeypatch reach).
- Flip marketing_screenshot fixture default to full_page=False so
  every capture is uniform 1400×900, fitting the slider's frame.
- Add a GitHub Sponsors link in the hero CTA and footer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): adopt Font Awesome icon kit + actionlint SC2012

- Add @fortawesome/fontawesome-free as a devDependency; mirror the
  fonts:install pattern with a new scripts/install-icons.ts that
  materializes a curated set (github, linkedin, x-twitter, heart)
  into assets/images/icons/ (gitignored).
- New partials/icon.html inlines the SVG via safeHTML so
  fill="currentColor" picks up the surrounding text colour — same
  Tailwind class can tint the icon and the label.
- Replace fb / instagram footer links with LinkedIn; swap the
  hand-rolled twitter + GitHub + Sponsor-heart SVGs over to the
  Font Awesome equivalents.
- Fix actionlint SC2012 in deploy-website.yaml by switching the
  post-download `ls | wc -l` to `find -name '*.png' | wc -l`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): add YouTube footer link; soften shellcheck comment

- Extend install-icons.ts list with the FA `youtube` brand SVG and
  drop a fourth social link into the footer pointing at
  https://www.youtube.com/c/screenlydigitalsignage.
- Reword the deploy-website.yaml comment so it no longer starts with
  `# shellcheck`, which actionlint was misreading as an SC1072/SC1073
  directive and failing the lint run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(website): drop X/Twitter from footer social row

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): address Copilot review on slider PR

- slider.ts: init activeIndex to -1 so the very first setActive(0)
  actually applies state (the equal-index early return was
  swallowing it; autoplay bar never started).
- baseof.html / index.html: pass a stable "screenshots-singleton"
  cache key to partialCached so the Resize pipeline runs once per
  build, not once per page.
- index.html / main.css / slider.ts: drop the role="tab" inside
  role="tablist" markup (the full ARIA Tabs pattern doesn't fit a
  horizontal-scroll carousel); use plain buttons with aria-current
  on the active pill instead.
- screenshots.html: correct the partial docstring — the layout
  omits the slider region when the slice is empty, it doesn't fall
  back to a placeholder.
- package.json: invoke the locally-pinned tailwindcss binary
  instead of `bunx @tailwindcss/cli`. bunx resolves through its
  own cache and was pulling Tailwind v4.3.0 even though the lock
  pins 4.2.4, so the committed style.css banner drifted.
  Rebuilt style.css against the pinned version (v4.2.4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): address second Copilot pass on slider PR

- deploy-website.yaml: add `actions: read` to the workflow's
  permissions block so the Fetch marketing screenshots step can call
  `gh run list` / `gh run download`. An explicit `permissions:` block
  defaults unspecified scopes to `none`, so the Actions API would
  otherwise 403.
- slider.ts: track the URL-pill fade `setTimeout` and clear it on
  every `setActive`. A rapid second slide change (button mash, swipe
  + observer update) could otherwise let a stale timeout fire later
  and briefly overwrite the URL pill with the previous slide's text.
- screenshots.html: capture the 1440-wide PNG/WebP renditions during
  the srcset ladder loop and reuse them for the <img src> + LCP
  preload fallback. Avoids re-calling $src.Resize with the same spec
  the loop already produced. Falls back to the source's own width
  when it's narrower than 1440.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): scope deploy permissions per-job; --repo support for fetch

Addresses the third Copilot pass + SonarCloud's new_security_rating
gate failure (S8264: read permissions declared at workflow level).

- deploy-website.yaml: move permissions to job level. `build` keeps
  `actions: read` (gh run list/download) + `contents: read` (checkout)
  + `pages: write` (configure-pages); `deploy` keeps `pages: write` +
  `id-token: write`. Least privilege per job, no more workflow-level
  scope inheritance for the read tokens.
- scripts/fetch-screenshots.ts: target `Screenly/Anthias` by default
  (passed through `gh run list` / `gh run download` via `--repo`) so
  contributors on fork clones still get the upstream artifact instead
  of failing against their own empty Actions API. `--repo <owner>/
  <repo>` overrides.
- data/screenshots.yaml: header comment no longer references the
  "committed seed copies" — the directory is gitignored and the
  workflow downloads into an empty dir.
- website/README.md: document the upstream-default + --repo flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): read autoplay duration from CSS; correct restart comment

Fourth Copilot pass: addresses the duplicated-constant and stale-comment
flags in slider.ts. The two artifact-path concerns Copilot raised on
deploy-website.yaml and scripts/fetch-screenshots.ts are verified false
positives — `actions/upload-artifact@v4` stores the upload relative to
the `path:` argument, so the artifact's top-level entries already are
the .png files (confirmed by downloading the latest run; no
`test-artifacts/marketing/` prefix present).

- slider.ts: drop the duplicated `AUTOPLAY_MS = 6000` constant. The
  authoritative timing value lives on `--autoplay-ms` in main.css
  (drives the @keyframes width animation on the progress bar). The
  JS now reads that custom property at init via getComputedStyle and
  uses it for the setTimeout cadence, so changing one in CSS no
  longer silently drifts away from the slide-advance timing. Keeps a
  `AUTOPLAY_MS_FALLBACK` for the unlikely case where the property is
  unset / unparseable.
- slider.ts: rewrite the "Force-restart the CSS animation by
  detaching+reattaching the node" comment — that's not what the code
  does. The animation restart is purely a CSS-selector consequence
  of removing `data-state` from the previously-active pill, no DOM
  manipulation involved.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(tests): clarify system-info smoke-test docstring

Copilot flagged that the docstring overclaimed coverage. The assertions
only check that the heading renders and no 5xx fires; they don't
validate individual System Info values. Reword so the docstring matches
what the test actually does.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): add role=region to slider carousel root

Many AT only announce aria-roledescription when it augments a
non-generic role. Adding role=region keeps the existing aria-label
as the accessible name and matches the W3C carousel pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): keep slider pill state in sync with off-screen + focus

Two Copilot-flagged drift bugs:

1. The off-screen visibility observer cleared the JS autoplay timer
   but left the active pill at data-state=playing. The CSS progress
   bar kept advancing while the slider was below the fold, so when
   it scrolled back into view the fresh full-duration timer and the
   bar disagreed. Delegate to hoverPause/hoverResume so both pause
   together and restart together.

2. focusout bubbles, so moving keyboard focus between elements
   inside the slider (track → next button) fired the root-level
   focusout and re-armed autoplay mid-nav. Check relatedTarget and
   skip when focus is still within refs.root.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): derive og:image:alt from the actual page image

When the screenshots dir is empty the OG image falls back to
logo.svg, but the alt text stayed pinned to the old "dashboard
showing scheduled content" line — wrong for the logo and also
wrong if the first slide changes. Pull the alt from the same
$heroSlides[0] used to choose $pageImage, with "Anthias logo"
as the fallback when there are no slides.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(website): drop unused --frame-bg custom property

Declared on .screenshot-slider but never referenced — the actual
background uses an inline linear-gradient.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 21:53:17 +01:00

116 lines
3.2 KiB
TypeScript

#!/usr/bin/env bun
/**
* Pulls the latest successful marketing-screenshots workflow
* artifact into assets/images/screenshots/ for local Hugo builds.
*
* The CI deploy pipeline (.github/workflows/deploy-website.yaml)
* does the same thing during production builds; this script exists
* so contributors can preview the home-page slider with real
* captures without a custom CI run. Files land gitignored so the
* fetch is idempotent and never accidentally committed.
*
* Requires the `gh` CLI to be installed and authenticated. Targets
* the upstream `Screenly/Anthias` repo by default so contributors
* working from a fork still get the canonical artifact (the upstream
* is the only place marketing-screenshots.yaml runs on a schedule).
* Override with `--repo <owner>/<repo>` to pull from a different
* repo. Run from the `website/` directory:
*
* bun run screenshots:fetch
*
* Pass `--ref <branch>` to pull from a non-master branch's most
* recent run (handy when developing the capture pipeline itself).
*/
import {
copyFileSync,
mkdirSync,
mkdtempSync,
readdirSync,
rmSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const DEST = 'assets/images/screenshots'
const DEFAULT_REPO = 'Screenly/Anthias'
const args = Bun.argv.slice(2)
let branch = 'master'
let repo = DEFAULT_REPO
for (let i = 0; i < args.length; i++) {
if (args[i] === '--ref' && i + 1 < args.length) {
branch = args[++i]!
} else if (args[i] === '--repo' && i + 1 < args.length) {
repo = args[++i]!
}
}
async function sh(cmd: string[]): Promise<string> {
const proc = Bun.spawn(cmd, { stdout: 'pipe', stderr: 'pipe' })
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
const status = await proc.exited
if (status !== 0) {
throw new Error(
`command failed (${status}): ${cmd.join(' ')}\n${stderr}`,
)
}
return stdout.trim()
}
const runId = await sh([
'gh',
'run',
'list',
'--repo',
repo,
'--workflow=marketing-screenshots.yaml',
`--branch=${branch}`,
'--status=success',
'--limit=1',
'--json',
'databaseId',
'--jq',
'.[0].databaseId // ""',
])
if (!runId) {
console.error(
`No successful marketing-screenshots run on '${branch}' in ${repo}.\n` +
`Trigger one with:\n` +
` gh workflow run marketing-screenshots.yaml --repo ${repo} --ref ${branch}\n` +
`…or pass --ref <branch> / --repo <owner>/<repo> to look elsewhere.`,
)
process.exit(1)
}
console.log(
`Fetching marketing-screenshots artifact from run ${runId} in ${repo}`,
)
const tmp = mkdtempSync(join(tmpdir(), 'anthias-screenshots-'))
try {
await sh([
'gh',
'run',
'download',
runId,
'--repo',
repo,
'--name',
'marketing-screenshots',
'--dir',
tmp,
])
rmSync(DEST, { recursive: true, force: true })
mkdirSync(DEST, { recursive: true })
let copied = 0
for (const name of readdirSync(tmp)) {
if (!name.endsWith('.png')) continue
copyFileSync(join(tmp, name), join(DEST, name))
copied++
}
console.log(`Installed ${copied} screenshots → ${DEST}/`)
} finally {
rmSync(tmp, { recursive: true, force: true })
}