Skip to content

feat: scaffold Astro + Vue migration (phases 0-1) - #199

Open
sinduri-g wants to merge 99 commits into
mainfrom
feat/astro-migration
Open

feat: scaffold Astro + Vue migration (phases 0-1)#199
sinduri-g wants to merge 99 commits into
mainfrom
feat/astro-migration

Conversation

@sinduri-g

@sinduri-g sinduri-g commented Jul 21, 2026

Copy link
Copy Markdown
Contributor
  • add nested astro/ app: static output, trailingSlash always, base from VITE_BASE_PATH
  • serve real public/ via publicDir; read real adventure YAML via glob content collection
  • port root.tsx head into Layout.astro: CSP, favicons, theme pre-hydration + after-swap reapply, GA4 consent bootstrap, dual JSON-LD, base-aware font preloads
  • add ClientRouter view transitions and native prefetch (replaces speculationrules script)
  • prove YAML -> Zod -> createMarkdownProcessor + rehype-sanitize field HTML at build time
  • port index.css (@theme) verbatim; wire Tailwind 4, astro-icon, Vue appEntrypoint
  • stub nanostores theme/consent persistent stores

Type of change

  • feat new feature
  • fix bug fix
  • refactor no behavior change
  • docs / chore / config / perf / style / security

Manual checks

  • Screen reader tested (UI changes only)
  • New routes added to sitemap.xml, prerender array, README (routes only)
  • UI verified at 375px, 768px, and 1280px against the production build (npm run build && npm run preview) (UI changes only)
  • Re-read every changed file; checked all call sites of any modified exports (all changes)
  • Per-level discussion JSON exists with correct discussionUrl (adventure/level changes only)

- add nested astro/ app: static output, trailingSlash always, base from VITE_BASE_PATH
- serve real public/ via publicDir; read real adventure YAML via glob content collection
- port root.tsx head into Layout.astro: CSP, favicons, theme pre-hydration + after-swap reapply, GA4 consent bootstrap, dual JSON-LD, base-aware font preloads
- add ClientRouter view transitions and native prefetch (replaces speculationrules script)
- prove YAML -> Zod -> createMarkdownProcessor + rehype-sanitize field HTML at build time
- port index.css (@theme) verbatim; wire Tailwind 4, astro-icon, Vue appEntrypoint
- stub nanostores theme/consent persistent stores

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
@github-actions

github-actions Bot commented Jul 21, 2026

Copy link
Copy Markdown
Contributor
PR Preview Action v1.8.1

QR code for preview link

🚀 View preview at
https://off-on-dev.github.io/website/pr-preview/pr-199/

Built to branch gh-pages at 2026-08-24 09:58 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

sinduri-g added 28 commits July 21, 2026 12:02
- add **/*.astro and **/*.vue globs to REUSE.toml MIT block (reuse-tool does not auto-recognize .astro, so reuse lint fails on the scaffold otherwise)
- exclude astro/** from root eslint; the nested app imports astro:* virtual modules that root tooling cannot resolve, and gets its own lint later

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…e 2)

- translate schemas/adventure.schema.json to a strict Zod schema (unknown fields fail the build, preserving the ajv validation gate)
- custom yaml-package loader instead of glob(): Astro's glob YAML parser auto-casts unquoted ISO deadlines to Date; the yaml package keeps them as strings, matching the generator. digest-gated
- port the generator's markdown pipeline verbatim (sanitize schema, abbr-tooltip expansion, external-link annotation, non-public-url unwrapping, md-inline/md-content)
- port field normalization: title/name, story, icon/emoji, difficulty/emoji, learnings aliases, intro/summary, codespacesUrl, discussionUrl, deadline, rewards defaults
- add verify-gate.mjs: renders real fields through the pipeline and diffs against committed *.generated.ts (155 matched, 0 mismatched)
- defer to phase 2b (TODO markers): architectureDiagram assets, meta-description synthesis, services step injection, solutions collection, discussion/leaderboard references

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…phase 2b)

- port buildAdventureMetaDescription/buildLevelMetaDescription; always emit metaDescription (synthesized when absent), matching the generator
- port services -> "Explore the UIs" how_to_play step injection
- extract pure derivations to src/lib/adventure-derive.mjs, shared by the loader and the verification gate
- extend verify-gate to cover metaDescription and the injected services step (191 matched, 0 mismatched)
- add npm scripts: sync (astro sync validation gate) and verify:content (byte-fidelity gate)
- reclassify solutions (pre-built TS) and discussion/leaderboard (refreshed JSON) out of the content collection; they become phase 3 import.meta.glob loads

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- add SEO.astro (canonical, OG incl article type, Twitter, fixed brand og:image); Layout delegates per-page meta to it
- add lib/site.ts (SITE_URL, BRAND_NAME, OG_IMAGE_ALT, canonicalUrl) and lib/markdown.ts (stripLinks, stripHtml, slugify ported)
- add /adventures/[id] detail route via getStaticPaths from the collection; prose via set:html with md-inline and stripLinks in link cards
- add /adventures list page (month-sorted, stripHtml story in cards)
- verified in dist: titles, canonical (trailing slash), OG/Twitter, synthesized descriptions, unescaped prose, trailing-slash routing

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…d (phase 3 increment 2)

- add /adventures/[id]/levels/[levelId] via getStaticPaths (all 17 level routes)
- render hook, intro, scenario, objective, toolbox, walkthrough, verification, helpful links, audience via set:html; abbr tooltip expansion confirmed on a real page
- load discussion posts and leaderboard at build time via node fs (community-data.ts) -> fully static, no client fetch
- resolve external data dir from process.cwd(), not import.meta.url: page-imported modules are bundled by Vite which rewrites import.meta.url

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…nt 3)

- add /challenges/[...tag] (base + 25 tag routes) via getStaticPaths from the collection
- add ChallengesFilter.vue island (client:load): SSR renders the full/pre-filtered grid so no-JS users get content, hydrates for topic/difficulty filtering
- URL-synced state via history.replaceState (scroll-preserving); hydration-safe seed from the route tag, restore ?topics/?difficulty in onMounted
- add lib/challenges.ts deriving the flat level list and tag set (mirrors filter-utils/tag-utils)
- solution page deferred: pre-built solution TS import from the React src via the @/ alias; bridge at cutover

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
… increment 4)

- add Navbar.astro (nav landmark, links, active state) and Footer.astro (Explore/Community landmarks, social, copyright)
- wire into Layout with skip-nav, new-tab-hint span, and focus-to-main on astro:after-swap
- add ThemeToggle.vue island (nanostores $theme + lucide-vue-next): hydration-safe, persists to localStorage, client:load + transition:persist
- replace placeholder home with a real landing (hero + featured adventures from the collection)
- add nav/site constants to lib/site.ts; remove scaffold smoke island

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ment 5)

- port the full consent state machine from useConsent.tsx to stores/consent.ts: plain nanostores atom (hydration-safe, default null), React storage format ({value,timestamp}+180d expiry), gtag injector (queue update/js/config before appendChild, module-scoped once-guard), revoke, GPC auto-deny, _ga cookie clearing, firePageView on astro:page-load
- add ConsentBanner.vue (client:load + transition:persist): SSR renders the banner with aria-live; floating cookie-preferences button after a decision
- mount in Layout; gated load verified statically (zero gtag.js prerendered)
- NOTE: runtime transitions (accept/decline/GPC/re-grant/expiry) still need real-browser regression tests before merge

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…rement 6)

- port Hero.astro (fireflies, hero-badge, brand slogan/line, animate-fade-up) and AdventureCard.astro with sub-components DifficultyBadge, ContributorBadge, LivePill, AdventureIcon
- add lib/difficulty.ts and brand slogan constants
- use Astro class:list in place of cn; astro-icon (lucide, inline SVG, zero JS) in place of lucide-react
- home page uses Hero + card grid; /adventures uses the same cards; build-time isLive

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
… (phase 3 increment 7)

- port FilteredLevelCard design into ChallengesFilter.vue: card-glow, difficulty badge with dot, learnings, estimated-time pill, live pill, adventure icon
- inline badge/pill/icon markup with lucide-vue-next since .astro components can't render inside a Vue island
- extend challenges.ts ChallengeEntry with learnings, adventureIcon, and build-time isLive

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- port the two-column AdventureDetail layout: breadcrumb, header (title/icon/contributor badge/month/live pill/tag chips/story), main column (overview, challenge level cards, rewards, backstory), sidebar (leaderboard + contributor)
- add components: Breadcrumb, TagChips, InlineProse, PersonNameLink, RewardsCard, and lib/utils.ts (formatDeadline, isDeadlinePast)
- reuse ported AdventureIcon/DifficultyBadge/ContributorBadge/LivePill; build-time deadline gating for live pill and rewards placement

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- add src/pages/404.astro; Astro emits dist/404.html which GitHub Pages serves for unmatched paths as a real HTTP 404 (no soft-404 catch-all)

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ook (phase 3)

- port four nav/footer-linked static pages to .astro with faithful content, correct SEO, and chrome via Layout
- add noindex prop to Layout/SEO; privacy is noindex (kept out of the index, matching the React page)
- notes: CommunityLeaders sidebar rendered as a build-time snapshot; abbr tooltip keeps CSS hover/focus, JS click/Escape toggle deferred

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- port About (board members + challenge builders), Sponsors (sponsor data + CSS theme-swapped logo), Brand Guidelines (all 8 sections), and Presentation Templates to .astro with faithful content
- copy sponsors.ts and team.ts data into astro/src/data/; add Dynatrace logos to public/brand/
- add redirects (/docs, /docs/community-guide, /community-guide -> /handbook/) via astro.config; presentation-templates is noindex
- nav and footer now have zero 404s; notes: brand TOC scroll-spy and community-leaders live refresh are static snapshots

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…(phase 3)

- port the ChallengeDetail two-column layout: header, objective/learnings cards, collapsible audience/story/architecture (native details, zero JS), synthetic Get Started walkthrough step, verification, toolbox/docs grid, share links, sidebar (compact rewards, codespaces, community sidebar, other levels)
- add components: CollapsibleSection, CodespacesButton, OtherLevelsCard, ChallengeShareLinks, CommunitySidebar; extend RewardsCard with the compact variant
- wire architectureDiagram SVGs (copied to astro/src/assets/diagrams, matched by filename) with architectureAscii fallback
- content gate still 191 matched, 0 mismatched

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- bridge the React src solution modules with two exact-find Vite aliases (@/data/solutions/types, @/data/adventures/contributors); astro's own @/ alias unaffected
- add lib/solutions.ts (import.meta.glob over src/data/solutions) and the /adventures/[id]/levels/[levelId]/solution route with getStaticPaths over authored solutions only
- port the SolutionDetail layout: spoiler warning, step nav, context card, details step cards with takeaways, block renderer (text/code/image/callout), final result, outro, sidebar
- build-time deadline gating with locked-state fallback (noindex)

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…hase 5)

- add Playwright + axe a11y suite (e2e/a11y.spec.ts) over 15 representative routes: axe dark/light/forced-colors with the full WCAG tag set, touch targets (2.5.8), focus rings dark/light, focus traps, 200% zoom reflow. 105 tests pass
- reuseExistingServer:false so tests never run against a stray dev server
- fix heading order: sr-only h2 before card grids on /adventures and /challenges
- fix touch targets: nav links min-h-[44px], footer links min-h-[48px], clear-filters button min-h-6
- fix 200% zoom reflow: abbr tooltip ::after uses display:none when hidden (was opacity:0, which kept layout and extended page width); the React JS tooltip that suppresses this CSS is a follow-up
- constrain walkthrough step grid track with minmax(0,1fr) so code blocks scroll internally

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- add e2e/smoke.spec.ts: every route has a unique correct <title>, canonical (SITE_URL+path), meta description, matching og:title/og:url, exactly one <h1>, and no page errors
- island hydration checks: theme toggle switches theme + sets .light class; challenges filter filters and syncs ?difficulty
- 19 tests pass

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…tover stage 1)

- deploy.yml: output dist/ (not dist/client/), drop the 404.html copy (astro emits dist/404.html natively), keep JamesIves/github-pages-deploy-action
- preview.yml: check job runs sync + verify:content gates; build/e2e/preview use dist/; e2e runs playwright (webServer serves via astro preview); preview build uses astro base (VITE_BASE_PATH) so public assets deploy from dist/ with no per-directory copy step
- these target the post-move root structure; inert on the branch until the cutover move lands and the branch is merged
- TODO(cutover): add npm run lint once eslint-astro/vue config is ported

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…e (stage A)

- copy adventure YAML, discussion/leaderboard JSON, solutions, contributors.ts and types.ts into astro/src/data (the app no longer reads the React src/data tree)
- re-point content.config (data/adventures), community-data (cwd/src/data/adventures), and solutions (../data/solutions) to the local copies
- drop the two interim Vite aliases; @/data/solutions/types and @/data/adventures/contributors now resolve via the app's @/ -> src alias
- build green (63 pages), content gate 191/0, all 124 e2e tests pass

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…age B)

BREAKING: the React Router app is removed; offon.dev now builds from Astro.

- delete the React app: src/ (root.tsx, entry.*, routes.ts, ~50 .tsx pages/components, hooks), react-router.config.ts, serve.json, the React vite/vitest/eslint/tsconfig configs, e2e/, and the shadcn components.json
- delete the content generator: scripts/generate-adventures.mjs, generate-solutions.mjs, create-data-aliases.mjs, schemas/adventure.schema.json, and all *.generated.ts / index.ts / summaries.ts (replaced by the Zod content collection)
- move the Astro app from astro/ to the repo root (src/, astro.config.mjs, package.json, tsconfig.json, e2e/, playwright.config.ts)
- drop publicDir override (public/ is now local); remove the obsolete verify:content gate (nothing left to diff against) and its verify-gate.mjs
- preview.yml: content gate is now `astro sync` only
- kept: public/, refresh-*/sync/community-sitemap/check-docs scripts, docs, LICENSES, REUSE.toml
- verified at root: build (63 pages), astro sync, 124 e2e tests (105 a11y + 19 smoke) all pass
- follow-ups: rewrite sync-adventure/add-discussion-url/validate-adventures workflows (still reference the removed generator), add eslint-astro/vue lint, custom sitemap endpoint, update CLAUDE.md/README/REUSE.toml, port abbr JS tooltip + Shiki highlighting + click tracking

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…tover follow-up)

- replace the deleted `generate-adventures.mjs --validate-only` with `npm run sync` (astro sync runs the Zod content schema and fails on invalid adventure YAML)
- drop the obsolete checks: generated TS existence, index.ts import, react-router prerender entries, and sitemap.xml grep (routes now come from getStaticPaths; sitemap endpoint is a follow-up)
- keep: per-level discussion JSON existence and ADVENTURE_CATEGORIES registration in refresh-leaderboard.mjs
- update path triggers (src/content.config.ts instead of schemas/react-router.config)

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…nerator (cutover follow-up)

- replace `node scripts/generate-adventures.mjs` with `npm run sync` (Zod validation) in both workflows
- commit only the collection inputs (adventure.yaml, *-posts.json, diagrams, refresh-leaderboard.mjs); stop staging the removed *.generated.ts / index.ts / summaries.ts
- rewrite PR-body prose: routes/sitemap now come from getStaticPaths at build time (nothing to regenerate); checks use `npm run sync && npm run build && npm run test:e2e`

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…llow-up)

- replace all React Router / generator guidance with Astro: stack, repo layout, commands, content collection, components/islands, hydration safety, SEO (<SEO> component), deployment (dist/, trailingSlash, PR-preview base)
- rewrite the analytics/consent section for the nanostores $consent store + Vue island (state machine preserved); update constants home to src/lib/site.ts
- preserve framework-agnostic rules verbatim: Content & Copy, Git, Brand, URLs, Stability, Debugging evidence rules
- add a Known follow-ups section (lint, sitemap endpoint, abbr JS, Shiki, click tracking, REUSE cleanup)

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…llow-up)

- README: Astro + Vue stack, commands (astro build/sync/test:e2e, dev port 4321), project structure, content collection, routes (.astro + getStaticPaths), SEO (<SEO> component), analytics (nanostores), deployment (dist/, base)
- REUSE.toml: drop entries for deleted files — *.generated.ts (block 3 + per-contributor blocks), src/components/ui + shadcn block, and community-leaders.json (CC0 block); src/lib/utils.ts now falls under the MIT **/*.ts glob

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…tion (cutover follow-up)

- ADVENTURES.md: rewrite the content pipeline (YAML -> content collection, no generation), the sync-workflow steps, the solutions flow (import.meta.glob, no barrel), the checks command (npm run sync && build && test:e2e), the validate-adventures row, and the constants pointer (src/lib/site.ts)
- PERFORMANCE.md: add a migration note (Layout.astro preloads, Astro islands code-splitting, content-collection markdown, native prefetch, dist/ output, getStaticPaths) — principles unchanged
- styleguide.md: add a migration banner (design system stays authoritative; component entries describe the former React impl; .astro/.vue components are source of truth). Full component rewrite tracked as follow-up

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…follow-up)

- add eslint.config.js (flat): @eslint/js + typescript-eslint + eslint-plugin-astro + eslint-plugin-vue; ignore dist/.astro/node_modules/public/.claude; quiet purely-stylistic vue formatting rules; keep no-unused-vars as error
- pin typescript to 6.x (typescript-eslint does not support TS 7 yet); build still green on TS 6
- add `npm run lint` script and run it in preview.yml's check job (removes the cutover TODO)
- fix the two real lint errors: unused `base` in the level page and unused `SITE_URL` import in brand.astro

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…r follow-up)

- add src/pages/sitemap.xml.ts endpoint: enumerates home, /adventures/, adventure + level routes, solution routes, /challenges/ + tag routes, and the indexable static pages, with a build-date lastmod
- exclude /privacy/, /presentation-templates/ (both noindex), and /404/
- delete the stale hand-maintained public/sitemap.xml (the generator that patched it is gone); /sitemap.xml is now generated at build (60 URLs)
- robots.txt, api-catalog, and the Layout sitemap link all reference /sitemap.xml, served by the endpoint

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- Add `requireEither` helper in content.config.ts replacing three unsafe `as string` casts with validated runtime checks
- Export `Difficulty` type from difficulty.ts and thread it through challenges.ts, DifficultyBadge.astro, and OtherLevelsCard.astro, removing all `string` widening at call sites
- Extract adventure icon registry into src/lib/adventure-icons.ts (EMOJI_TO_ICON, ICON_TO_KEBAB, AdventureIconName) and import it in content.config.ts and AdventureIcon.astro
- Remove four dead exported types from types.ts (RelatedLevel, AdventureLevelSummary, AdventureCardSummary, RelatedLevelSummary) and tighten metaDescription to required on both Adventure and AdventureLevel
- Change `interface RenderedLevel` to `type RenderedLevel` per project convention
- Replace LUCIDE_ICONS Record value type with `Component | undefined` to match guarded usage
- Fix annotateExternalLinks in markdown-pipeline.mjs: split target/rel checks so each is added independently; restrict sanitize schema `target` attribute to `["target", "_blank"]` tuple; wrap codeToHtml in try/catch
- Replace JSON-LD template literals in Layout.astro with JSON.stringify; replace all magic numbers (1500, 260, 80, 16) with named constants in the correct script blocks
- Add named constants for FIREFLY_COUNT, LEADERBOARD_PREVIEW_COUNT, and COMMUNITY_LEADERS_PREVIEW_LIMIT replacing raw literals
- Add base .section-label rule in index.css so .light override has a base to override; remove dead --border-med token from @theme block
- Fix sponsors.astro: add aria-label to sponsor anchor, set decorative logo alt to "", correct group-hover to hover
- Fix about.astro board member photo alt from "" to member.name
- Harden ConsentBanner.vue / consent.ts: fix click listener leak (unbind on unmount), add runtime shape validation to parsed consent JSON, remove unnecessary resetModules call
- Add readdirSync try/catch in content.config.ts with error logging and graceful early return
- New tests: StarterNudge.vue show/hide/dismiss/base-prop, theme.ts decode coercion, community-data.ts error paths via real temp files, isNonPublicUrl private-range branches (10.x, 172.16-31, .local, 0.0.0.0)

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- Narrow img-src from wildcard https: to community.offon.dev only, the sole external image origin (Discourse avatars)
- Guard tags[0] with ?? "" in index.astro and challenges/[...tag].astro to prevent undefined reaching StarterNudge's required tag prop
- Use satisfies operator on EMOJI_TO_ICON for stricter type inference without widening to Record<string, AdventureIconName>

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…nicalUrl

- sortAdventuresByMonthDesc: cover unrecognised month abbreviation (treated as January via Math.max(0,-1)) and malformed/empty month string (no throw)
- canonicalUrl: cover path without trailing slash, path with trailing slash (no double-slash), root, nested path, and SITE_URL prefix
- MobileMenu: replace post-hoc HTMLElement[] cast with typed Array.from<HTMLElement> generic

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- happy-dom 18 → 20.11.6: fixes critical RCE (GHSA-37j7-fg3j-429f) and two highs
- brace-expansion, js-yaml, nanoid transitive vulns resolved via npm audit fix
- astro 7.1.3 → 7.2.4, @astrojs/vue 7.0.1 → 7.0.2, vue 3.5.40 → 3.5.41
- nanostores 1.4.1 → 1.5.2, @iconify-json/lucide 118 → 125
- @axe-core/playwright 4.12.1 → 4.13.0, @playwright/test 1.62.0 → 1.62.1
- eslint 10.8.0 → 10.9.0, eslint-plugin-astro 3.0.1 → 3.1.0
- globals 17.8.0 → 17.11.0, typescript-eslint 8.65.0 → 8.67.0
- @vitejs/plugin-vue 5.x → 6.0.8 (vitest config only)
- vitest + @vitest/coverage-v8 3.x → 4.1.11
- typescript held at ^6.0.3 (typescript-eslint not TS 7 compatible)
- unplugin-icons held at 0.22.0 (0.x → 23.x needs dedicated review)

vitest.config.ts: replace __dirname with import.meta.dirname (ESM native loader)
CLAUDE.md: correct icon tooling entry and sitemap route guidance
challenges.test.ts: fix stale sort-key values in test comment

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…RL, and stale docs

- playwright.config.ts: Astro 7 daemonizes `astro preview` (parent exits 0
  immediately); switch webServer command to `--background && logs --follow` to
  keep a process alive for Playwright to monitor; add e2e/teardown.ts to stop
  the daemon after the suite
- AdventureCard: pass `url` to ContributorBadge so contributor badges render
  as links when a URL is present (was silently dropped)
- CLAUDE.md: fix icon stack entry (unplugin-icons, not astro-icon); replace
  two stale `public/sitemap.xml` references with `src/pages/sitemap.xml.ts`;
  note Astro 7 preview daemon in debugging rules; mark styleguide rewrite done
- styleguide.md: correct AdventureCard ContributorBadge description; fix
  DifficultyBadge prop type from `string` to `Difficulty` union

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…tional omission

ContributorBadge inside AdventureCard intentionally omits `url` — the card
root is already an `<a>`, so passing a url would produce a nested anchor.
Styleguide entry now explains this constraint explicitly.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Discourse serves user avatars from avatars.discourse-cdn.com, a separate
CDN domain from the community instance. Add it to img-src so leaderboard
and activity-post avatars load without CSP violations.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- `avatars.discourse-cdn.com` alone misses the other hosts Discourse
  serves avatars from (`sea2.discourse-cdn.com` et al), both of which the
  deleted `e2e/wsg.spec.ts` explicitly allowlisted
- Replaces the single host with `https://*.discourse-cdn.com`
- Verified: all 32 external avatar URLs in `dist/` are now covered

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- `initConsent()` runs in `onMounted` and restores a stored choice, which
  registers on `$consent` as a `null -> granted/denied` transition
- The focus watcher treated that like a user decision and moved focus to
  the cookie button on every page load for every returning visitor,
  destroying the skip-nav link (WCAG 2.4.3 / 3.2.1)
- Adds a `hydrating` guard released one tick after mount, so the watcher
  job queued by the restore is flushed and skipped; the React component
  guarded this same case explicitly
- Focus behaviour for genuine choices (Accept, Decline, reopen) unchanged
- Adds `ConsentBanner.hydration.test.ts`, which does NOT mock the consent
  store: verified it fails on both restore paths without the guard

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- The `<abbr>` on /handbook/ lacked `tabindex="0"`, so its tooltip could
  only be revealed by mouse hover (WCAG 2.1.1)
- The Layout tooltip script's safety net only repairs `abbr[title]`, it
  does not add `tabindex`, so nothing compensated at runtime
- Matches the pattern already used in contribute.astro
- Verified: all 135 `aria-describedby` abbreviations in `dist/` now carry
  `tabindex`, zero missing

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…weight

- Restores `max-h-[80vh] overflow-y-auto` on the banner body: without it the
  actions could sit below the fold at 400% zoom or in landscape with no way
  to scroll to them (WCAG 1.4.10)
- Restores `env(safe-area-inset-bottom)` padding so the buttons clear the
  iOS home indicator
- Restores `aria-atomic="true"` on the live region so the whole region is
  re-announced on each transition, not just the changed subtree
- Restores Decline-before-Accept DOM and tab order
- Adds `.btn-secondary` (solid inverted neutral, same geometry as
  `.btn-primary`) and moves Decline onto it: an outline button beside a
  filled Accept made declining read as the lesser option

Measured contrast (Chromium, against the opaque page background):

| | dark | light |
| --- | --- | --- |
| Decline fill vs banner | 19.16 | 18.21 |
| Decline label vs fill | 19.16 | 18.21 |
| Accept label vs fill | 11.94 | 12.67 |

Verified: both actions in-viewport, focusable and within 80vh at a 320x256
viewport (400% of 1280x1024); 12/12 consent e2e and 84/84 axe tests pass.

Pre-existing and left alone: `.btn-primary`'s amber fill has only 1.59:1
boundary contrast against the light-mode banner. Its label is 12.67:1, so
the control is identifiable by text, and the class is used site-wide.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- The React ChallengeFilters closed a dropdown via `onBlur`/`handleDropdownBlur`
  when focus left the wrapper; the Vue port kept only outside-mousedown and
  Escape, so tabbing past an open panel left it floating over the results grid
- Adds `@focusout` on both dropdown wrappers, matching the old semantics:
  ignores a null `relatedTarget` (background clicks stay with the mousedown
  handler) and never moves focus, since focus has already gone where the user
  sent it. Escape keeps its focus-restore, which is correct for a cancel
- Adds `e2e/challenges-filter.spec.ts` covering all three dismissal paths and
  the focus-steal constraint; verified the focus-out case fails without the fix

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ages

The migration dropped every page-level schema.org block, keeping only the
site-wide WebSite + Organization pair. That removed 51 blocks across 27
pages, including the BreadcrumbList that drives breadcrumb rich results.

- Adds `StructuredData.astro`, which derives the BreadcrumbList from the
  SAME crumb array that renders the visual `<Breadcrumb>`; each page now
  declares one `crumbs` const consumed by both, so they cannot drift
- Adds `src/lib/structured-data.ts` with `courseSchema` /
  `learningResourceSchema` / `SCHEMA_PROVIDER`, ported field-for-field
  from the old `meta()` exports
- Adds a named `head` slot to `Layout.astro` so pages can contribute
  `<head>` content, matching where the React app emitted these
- `item` URLs always use the production canonical, never the preview base,
  the same rule `<link rel="canonical">` follows

Verified against the live React build on origin/gh-pages across all 27
pages: 27 BreadcrumbList + 6 Course + 18 LearningResource = 51 blocks.
43 of 51 are byte-identical; the 8 that differ do so only in already-known
content drift (em-dash normalisation, and the abbr id scheme fixed in P10),
not in structure or field names.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- The React CommunityGuide wrapped its body in
  `<SidebarLayout aside={<CommunityLeaders />}>`; the Astro port rendered a
  single full-width column and dropped the aside entirely. It was the only
  page in the migration that lost a whole content block
- Restores the `lg:grid-cols-[1fr_300px] gap-12` grid with the `sticky top-24`
  aside, the same shape used by about.astro and CommunitySection.astro
- Passes no `sections` prop, so the component's "show all" default applies.
  That matches the React page and, unlike the enumerated lists on about and
  contribute, stays correct when a section is added to community-leaders.json

Verified in dist/handbook/index.html: grid class present, all 8 sections
render, 30 leader rows, matching /about/.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- The React AdventureLeaderboard passed `rows` through unbounded; the Astro
  port sliced to `LEADERBOARD_PREVIEW_COUNT = 5` with no "see more" affordance
- On echoes-lost-in-orbit that hid 11 of 16 contributors; the-ai-observatory
  lost 1
- Removes the cap. Rows stay server-rendered, which is already better than
  the React page (its `useAdventureLeaderboard` hook fetched client-side, so
  the old prod HTML shipped zero rows)

Verified rendered rows match each leaderboard.json exactly: 3/5/3/16/4/6.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
GA4 custom dimensions and saved reports are keyed on these parameter names.
The migration renamed all of them and dropped one, so every existing report
would have gone dark at cutover with no error anywhere.

Restored to match the React useClickTracking hook field-for-field:

| migration | restored |
| --- | --- |
| `link_text` | `click_text` |
| `link_url` (anchors only) | `click_url` (+ `data-url` then `"no-url"` fallback) |
| `element` | `click_element` |
| absent | `click_page` |

Also restored:
- `aria-label` preferred over `textContent`, so icon-only controls (theme
  toggle, cookie button, copy, dismiss) stop reporting an empty string, and
  `"unknown"` when neither exists
- the `#main-content` skip-nav exclusion, which otherwise fires on every
  keyboard Tab+Enter and records AT navigation as user intent
- selector widened back to `a, button` from `a[href], button`

Kept from the migration (not a parity target, and removing it would be a
regression): internal whitespace in the label is collapsed, so multi-line
button text does not fragment reports. The React hook only trimmed.

Adds 6 payload-parity tests; updates the 5 existing assertions to the
restored names. 470 unit tests and 12 consent e2e pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…nd /challenges/

- `/adventures/`: `Adventures - OffOn` -> `Adventures - Open Source Learning
  Paths | OffOn`
- `/challenges/`: `Challenges - OffOn` -> `Open Source Challenges | OffOn`
- `/challenges/` description restored to the indexed wording

These are the two highest-value non-home titles and both had lost their
keyword phrase. Nothing flagged it because `e2e/smoke.spec.ts` had been
updated to assert the new strings; it now asserts the restored ones.

Verified against the live React build across all 66 comparable routes:
65 have byte-identical title and description once HTML entity encoding is
normalised (React escaped apostrophes as `&#x27;`, Astro emits them raw,
which parses identically). The one remaining difference is the em-dash to
hyphen change in the lex-imperfecta adventure YAML, a deliberate repo-wide
copy rule rather than migration drift.

31 smoke tests pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`makeAbbrId()` derived ids purely from the abbreviation text, so the same
abbreviation twice on a page produced two elements with the same `id` -
invalid HTML. Confirmed in the build on `/`, `/challenges/` and
`/challenges/opentelemetry/` (`id="abbr-opentelemetry"` twice). The React
generator avoided this with a global `abbr-exp-N` counter.

Uniqueness now comes from two things, both derived only from the entry's own
content so the loader's digest cache stays valid:

- a scope prefix (the adventure id), set by the loader before each entry.
  Needed because the home and /challenges/ grids render learnings from every
  adventure into one document, so a per-entry counter alone is not enough
- a per-scope occurrence counter, for the same abbreviation twice in one
  adventure (`-2`, `-3`, ...)

Chose this over a build-global counter because the loader skips unchanged
entries, so a global counter would renumber ids differently depending on
which adventures happened to be re-rendered.

`beginAbbrScope()` is exported and must be called per entry; unit tests reset
it in `beforeEach` the same way the loader does.

Verified across all 69 built pages: 0 duplicate ids, and all 1158
`aria-describedby` references resolve to an element that exists. Ids are
byte-identical across two clean rebuilds and a warm-cache rebuild.
474 unit tests pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
The React BrandGuidelines page tracked the section in view with an
IntersectionObserver and marked the matching TOC link `aria-current="location"`
with an active left border. The Astro port shipped a static TOC: every link
hard-coded to the idle style, no script, no active state.

- Restores the observer with the same `-10% 0% -80% 0%` root margin and the
  same active/idle utility classes
- Uses the vanilla `<script>` + `astro:page-load` pattern already used in
  Layout.astro, and disconnects on `astro:before-swap`. The `[data-toc]` guard
  makes it a no-op on other routes
- Progressive enhancement: the first section is rendered active server-side,
  so the TOC has a correct state before the script runs and with JS disabled
  (the React version only reached that state after hydration)

Adds `e2e/brand-toc.spec.ts`: asserts the SSR state, that scrolling to three
different sections moves `aria-current`, and that exactly one link is ever
current. 7/7 axe checks on /brand/ still pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Comments added in the preceding fixes explained changes in terms of what the
React app used to do. That context belongs in commit messages, not in source a
future reader will hit cold. Rewritten to describe what the code does and why
it is shaped that way, and dropped the finding-reference tags (P1, P6, P10...).

Comment-only change across 15 files. Build, lint, 474 unit tests and 244 e2e
tests unchanged.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Follow-up to the scoped abbreviation IDs: nothing stopped a new render path
from skipping beginAbbrScope(). It would then inherit the previous entry's
prefix and counter, producing ids that look correct but collide once two
entries render onto one page.

Two guards, one per failure mode:

- makeAbbrId() throws if no scope has been started, naming the call site to
  fix. Fails the build rather than shipping bad HTML. Covered by two tests
  using vi.resetModules() for a clean module instance, plus one asserting
  markdown without an <abbr> is unaffected
- smoke.spec.ts now asserts zero duplicate `id` attributes on every route.
  This is the only layer that can check document-level uniqueness, since the
  pipeline has no page context. axe does not cover it: duplicate-id is
  deprecated and duplicate-id-aria only fires on ARIA-referenced ids

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`npm test` was dropped from the preview workflow during the migration and
never replaced, so the 19 vitest files and the coverage thresholds in
vitest.config.ts had not gated a single PR or deploy.

- Adds the unit run to the `check` job in preview.yml and to deploy.yml,
  alongside the existing sync and lint gates
- Uses `test:unit:coverage` rather than `test:unit`, so the declared
  thresholds (lines 80, functions 80, branches 70) are actually enforced.
  Current coverage clears them with headroom: 91.7 lines, 87.2 functions,
  85.5 branches

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- Remove all "ported from React", "ported from the generator", and
  "matching the React X" references across 18 files
- Rewrite comments to describe what the code does, not where it came from
- Preserve technically useful context (hydration rationale in consent.ts,
  WCAG Reflow reason in index.css, CSP constraint in AvatarLink.astro, etc.)
- Delete comments that were self-evident once the migration reference was gone

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`astro build` loads src/lib/markdown-pipeline.mjs through the content loader,
so everything that module imports is a build dependency, not a dev one. The
manifest disagreed:

- `unified`, `remark-parse`, `remark-gfm`, `remark-rehype`, `rehype-raw`,
  `rehype-sanitize`, `rehype-stringify` were devDependencies. Moved to
  dependencies
- `shiki` was imported but never declared at all; it resolved only as a
  transitive of astro, so any astro release that changed its shiki range
  would have broken the build with no manifest change. Now declared (^4.4.3)
- `@astrojs/markdown-remark` was declared with zero imports anywhere in
  src/, e2e/, scripts/ or config. Removed

Verified `npm ci --omit=dev && npm run build` succeeds, and that all eight
modules resolve in the dev-omitted tree. On the previous manifest the seven
remark/rehype packages were omitted by that flag and shiki was undeclared, so
the same command would have failed.

Full install restored: build, lint and 476 unit tests pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`scripts/lib/` and `src/lib/` each held a copy and they had already diverged,
with the sync script writing deadlines through one parser and the build
reading them through the other. Only the src/ copy had the strict
unknown-timezone behaviour.

- `scripts/lib/deadline.mjs` and `scripts/lib/level-constants.mjs` deleted;
  `scripts/sync-adventure.mjs` and `scripts/lib/level-sync.mjs` now import
  from `src/lib/`
- `src/lib/level-constants.mjs` gains `LEVEL_DIFFICULTY_BY_ID` and
  `LEVEL_ORDER`, which only the scripts copy had
- Strict `UNRESOLVABLE_DEADLINE` is the default everywhere

One thing the merge could not do bluntly: sync-adventure writes its result
back into adventure.yaml, so making it strict would have replaced an author's
human-readable deadline with `9999-12-31T23:59:59Z` in the source file and
hidden the mistake. `parseDeadline` therefore takes an explicit
`{ onUnknownTimezone: "preserve" }`, used only on that write path. Rendering
keeps the strict default, so an unparseable deadline still gates the solution
either way; a preserved string hits the sentinel when the build parses it.

Adds 7 tests covering both modes, including that a preserved value still
resolves to the sentinel at render time. 482 unit tests pass; scripts and
build verified against the shared modules.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`.ai/templates/generate-reveal-zip.mjs` imports jszip, which was dropped from
the manifest, so `/create-presentation`'s Reveal ZIP path failed with
ERR_MODULE_NOT_FOUND. The committed zip masked it: the site was fine,
only regeneration was broken.

- jszip restored as a devDependency (^3.10.1, the version it had before).
  Not a runtime dependency: it is tooling, never imported by the site build
- reveal.js deliberately left out. The script reads the deck assets from the
  committed public/reveal/, not from node_modules, so it is only needed for
  the occasional manual re-vendor. Documented as an ad hoc install, the same
  pattern the repo already uses for pptxgenjs
- Script header and the command tables in CLAUDE.md / AGENTS.md say which of
  the two is installed and which is not

Verified by running the script after a clean `npm ci`: emits a valid 26-file
zip. Regenerated output reverted, since this was a verification run and the
committed artifact is unchanged.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Restoring axe coverage to the 12 previously-untested level routes surfaced 38
real color-contrast violations, all one root cause: both GitHub Shiki themes
color comments #6a737d, which fails WCAG 1.4.3 against the code-block surfaces.

- dark:  #6a737d on #151519 = 3.78:1
- light: #6a737d on #f4f4f6 = 4.38:1

Both below the 4.5:1 floor. Replaced per theme with GitHub's own accessible
values, #8b949e and #57606a, which measure 5.97:1 and 5.75:1. Applied via
Shiki's colorReplacements so every token using that color is covered, not just
the comment tokens that happened to be in the sample.

Set in both places that highlight: the codeToHtml call in markdown-pipeline.mjs
(adventure prose) and shikiConfig in astro.config.mjs (markdown).

Note for anyone touching this: colorReplacements belongs on the codeToHtml
call, not on createHighlighter's theme entries. Passing it there makes
createHighlighter throw, and the catch in highlightCode swallows it and returns
unhighlighted code, so the only visible symptom is that syntax highlighting
quietly disappears. Verified the output still carries 6 distinct token colors
and no longer contains #6a737d.

120/120 axe checks pass across 40 routes in dark, light and forced-colors.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
The migration deleted wsg.spec.ts, hydration.spec.ts and visual.spec.ts, and
trimmed smoke.spec.ts, without replacing what they checked. Restored the parts
that guard behaviour rather than pixels:

- `budget.spec.ts`: the 750 KB page-weight budget, the third-party request
  allowlist (the only automated proof nothing phones home pre-consent), and the
  image width/height, lazy-loading and autoplay checks
- `mobile-menu.spec.ts`: initial focus, Tab and Shift+Tab wrap, focus never
  escaping, Escape restoring focus to the trigger, `inert` + `aria-hidden` on
  background siblings and their cleanup, and no trap surviving navigation.
  Written against the DOM contract only (`button[aria-controls="mobile-menu"]`,
  `#mobile-menu`, `hidden`), never component internals, so they hold when the
  drawer is reimplemented
- skip-link tests in `a11y.spec.ts`: first Tab stop, and Enter moving focus to
  `#main-content`
- `smoke.spec.ts` now fails on `console.error`, not just uncaught exceptions,
  and asserts no duplicate `id` attributes per route

Route lists moved to `e2e/routes.ts` so there is one copy, and
`route-coverage.spec.ts` walks `dist/` and fails when a built route is in no
list, when a listed route no longer exists, or when an adventure/level/solution
route lacks axe coverage. That replaces the drift protection lost with the
generated lists: a new adventure can no longer ship untested silently.

The gate immediately found 12 level routes with no axe coverage. Added them,
taking axe from 28 to 40 routes; the 24 remaining challenge-tag routes are
listed as deliberate exclusions with a reason. Those 12 routes turned out to
carry real contrast failures, fixed in the preceding commit.

e2e goes from 244 to 371 tests, all passing.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
… script

`eslint-plugin-jsx-a11y` went out with React and nothing replaced it, so no
static accessibility rule had run against any `.astro` or `.vue` file since the
migration. That is most of the site's markup.

- Adds `eslint-plugin-vuejs-accessibility` (flat/recommended) and
  `eslint-plugin-astro`'s `flat/jsx-a11y-recommended`
- Two rules tuned rather than silenced, both matching intentional patterns:
  `no-redundant-roles` allows `role="list"` on `<ul>`/`<ol>` (Safari VoiceOver
  drops list semantics when Tailwind removes list-style), carrying over the old
  config's exact allowance; `no-noninteractive-tabindex` allows `abbr` and
  `pre`, which must be focusable for the tooltip and for keyboard scrolling of
  code blocks. Everything else stays flagged
- One targeted disable on the ChallengesFilter radiogroup, where the group is
  correctly non-focusable and the keydown handler catches arrow keys bubbling
  from the roving-tabindex radios

Verified both rulesets fire: injecting an `<img>` with no alt into an `.astro`
and a `.vue` file produces errors from each.

Also adds `npm run check` (`astro check`) and `"types": ["vitest/globals"]` to
tsconfig, which alone drops the reported errors from 256 to 57. It is NOT wired
into CI yet: those 57 are pre-existing, and several sit in the components the
island refactor is about to replace. Tracked in #217 with the breakdown and a
suggested order.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…e lint disable

The arrow-key handler sat on the `role="radiogroup"` container and was reached
by bubbling, which meant an interactive handler on a deliberately non-focusable
element. That tripped `vuejs-accessibility/interactive-supports-focus`, and it
was suppressed with an inline disable rather than fixed.

Binding the handler to each radio removes the warning by making the code
correct, not by silencing the check:

- the handler now runs on the element that actually has focus when the key is
  pressed, instead of relying on the event reaching a container that can never
  be focused
- it keys off `e.currentTarget` rather than `document.activeElement`, so it
  cannot act on the wrong radio
- sibling lookup uses `closest('[role="radiogroup"]')`, matching how
  `navigatePanel` already resolves its own group

No inline eslint-disable comments remain anywhere in the repo.

This had no test in a real browser, which is how it could have broken silently.
Adds two to `challenges-filter.spec.ts`: arrow keys move focus, select, and wrap
in both directions; and exactly one radio is tabbable while the group stays out
of the tab order. Verified non-vacuous by removing the bindings and watching the
first fail.

The five existing unit tests dispatched `keydown` on the group, so they were
coupled to where the handler was bound rather than to the behaviour. Updated to
dispatch on the focused radio, as a real key event would. The
"no radio focused" test covered a branch that only existed with the old
binding; replaced with two that still mean something: non-arrow keys are
ignored, and the handler follows `currentTarget` rather than focus.

483 unit and 373 e2e tests pass.

Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant