Skip to content

feat: complete the Astro migration, remove all framework islands - #199

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

feat: complete the Astro migration, remove all framework islands#199
sinduri-g wants to merge 120 commits into
mainfrom
feat/astro-migration

Conversation

@sinduri-g

@sinduri-gsinduri-g commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Summary

Completes the React Router v8 → Astro + Vue migration, then finishes the job the migration started: the site now ships zero framework islands. Every interactive surface is a .astro component plus a plain script.

This branch also carries a full parity audit against the deployed React build, restoring content, SEO and accessibility guarantees that were silently lost in the migration, and closes#216 and #217.

Weight

Per-route, gzipped. "React" is the deployed pre-migration build on gh-pages; "Vue islands" is the state this branch started in.

RouteReact JSVue islandsNowReact HTMLVue islandsNow
/139.3 KB42.19.824.7 KB25.920.7
/challenges/138.642.19.822.824.119.0
/about/133.742.27.715.215.214.4

93% less JS than React on a content page. The framework was loading on all 69 pages to power a theme toggle, a drawer, a consent banner and a nudge. Those are now markup plus a script, and so is the challenge filter, which was the only component with a real case for reactivity.

astro-island elements in the build: 0. Vue runtime chunks: 0.

Parity verification against the deployed React build

CheckResult
Titles, descriptions, canonicals65 of 66 routes identical (see note below)
Page-level JSON-LD51 blocks across 27 pages restored; 43 byte-identical
Sitemap61 URLs, symmetric diff 0
Nested anchors0
Duplicate id attributes0
axe (40 routes × dark, light, forced-colors)120 passed
e2e497 passed
Unit357 passed, 97.1% lines / 92.5% branches
astro check0 errors

The one non-identical route

/adventures/lex-imperfecta/ differs by a single character in its meta description: an em dash became a hyphen.

This is intentional content drift, not migration drift. CLAUDE.md says "Never use em dashes anywhere", and the change was made deliberately in 744563f97 ("chore: exhaustive audit fixes across 20 files"). Zero em dashes remain in any adventure YAML. No action needed.

What the parity audit found and fixed

Restored content and SEO that the migration had dropped:

  • 51 JSON-LD blocks across 27 pages (BreadcrumbList, Course, LearningResource), which drive breadcrumb and course rich results. Now derived from the same crumb array that renders the visual breadcrumb, so the two cannot drift.
  • The Community Leaders sidebar on /handbook/, the only page that lost a whole content block.
  • Adventure leaderboards, capped at 5 rows, hiding 11 of 16 contributors on echoes-lost-in-orbit.
  • Indexed titles on /adventures/ and /challenges/, which had lost their keyword phrases.
  • The GA4 click_event payload. All four parameter names had been renamed and one dropped, which would have silently killed every existing report at cutover.

Accessibility fixes:

  • The consent banner stole focus from the skip-nav link on every page load for returning visitors, and had lost its max-h-[80vh] reflow guard, so its actions could sit unreachable at 400% zoom.
  • Restoring axe coverage to 12 previously-untested level routes surfaced 38 real contrast failures: both Shiki themes colour comments below the 4.5:1 floor.
  • The <abbr> on /handbook/ was mouse-only.
  • Filter dropdowns stayed open when tabbed past.
  • Abbreviation IDs collided, producing duplicate id attributes.
  • Inline links in prose rendered without surrounding spaces.

Framework policy

Documented in styleguide.md, CONTRIBUTING.md and CLAUDE.md:

  • Default to .astro + a plain <script>. Reach for a framework only when the component has genuinely reactive state that a class toggle and a small script cannot express.
  • When one is warranted, use Vue. Never React. The @astrojs/vue toolchain stays installed even though nothing currently uses it, so adding an island is a one-file change. It is called out explicitly so a dependency audit does not strip it as unused.
  • The five components removed from islands here are the bar for what does not justify a framework. The challenge filter is the useful reference: two dropdowns, a roving-tabindex radiogroup, URL reconciliation and a live region all came out smaller as markup plus a script, because its cards can be server-rendered and filtered by attribute.

Also documents the Astro whitespace-stripping requirement ({" "} around inline links), which was not written down anywhere and had already caused one visible bug.

Issues resolved in this PR

Closes#216.btn-primary measured 1.59:1 against the near-white surfaces it sits on in light mode, under the 3:1 WCAG 1.4.11 wants. Fixed with a darkened-amber border, measured against every surface the class appears on site-wide, not just the consent banner:

darklight
boundary vs surface11.945.13 (was 1.59)
label vs fill11.9412.67

The border box is reserved in both themes so nothing moves, which also settles an existing 2px height mismatch between .btn-ghost and the other button classes.

Closes#217astro check reported 44 errors and had never run in CI despite the repo extending astro/tsconfigs/strict. Now 0 errors, and gated in both workflows. Six root causes, not 44 problems; the largest was LUCIDE_ICONS being typed as Vue's broad Component union, which made every dynamic icon call site unrenderable to the checker.

CI

The check job now runs sync, check (types), lint and test:unit:coverage. Unit tests had not run in CI since the migration, and the coverage thresholds in vitest.config.ts had never been enforced.

Test coverage

Before this branchNow
e2e244497
axe routes2840
Unit455357

Unit tests fall because the Vue component tests went with the components. That trade is deliberate: coverage moved to e2e against the rendered page, and this component's history is DOM-level bugs (focus-out, live region, heading, spacing) that unit tests did not catch. The meaningful edge cases from the 57 ChallengesFilter unit tests were ported; the ones left behind only exercised Vue prop plumbing or handler internals that no longer exist.

New guards worth noting: a 750 KB page-weight budget with a third-party allowlist, a route-coverage drift gate that fails when a new route is untested, a duplicate-id check per route, an inline-spacing scan, and console.error failing the smoke suite.

On the e2e counts moving around in this branch's history: during the filter work, three e2e failures were reported as passing. Playwright prints failure names beneath its N failed header, and reading the truncated tail of the output made them look like the last passing tests. The failures were real but benign — a stale assertion requiring the live region to announce on page load, which is exactly the behaviour that change deliberately removed; the visible count-line assertion beside it passed throughout, so nothing was ever missing from the page. Caught and fixed in 0d213b393. Result counts are now read from the summary line, not the tail. Final: 497 passed, 0 failed.

Follow-ups, not blocking

  • No astro:assets usage anywhere; public/team, public/speakers and public/solutions are the obvious <Image> candidates.
  • AGENTS.md still describes the React/RR7 architecture in places.

- 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-actionsBot 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 15:04 UTC.
Preview will be ready when the GitHub Pages deployment is complete.

- 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>
- 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>
/challenges/<tag>/ rendered "<Tag> Challenges" as its h1 where the previous app
rendered "Open Source Challenges". More than a wording change: it made the same
filtered view show two different headings depending on how it was reached.
/challenges/ then click the Backstage pill -> "Open Source Challenges"
/challenges/backstage/ -> "Backstage Challenges"
Both render an identical grid of the same three challenges. The h1 names the
page; the filter is already conveyed by the active pill and the "3 challenges ·
Backstage" count line, so the heading changing was the odd one out.
The h1 is now unconditional. The <title> stays tag-specific
("Backstage Challenges - OffOn"), which was already at parity and is what
distinguishes these routes in search results and browser tabs.
This was the half of the review's F8 finding that the earlier P8 commit did not
cover: that one restored the titles and descriptions but left the heading.
Verified: h1 now matches the previous app on all 26 challenge routes, 0
mismatches. Adds 4 regression tests covering both ways of reaching a filtered
state, and asserting the tag is still surfaced in the visible count line and the
sr-only live region.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ghlighting
Every Shiki error was caught and turned into `return null`, which the caller
renders as a plain <pre><code>. So a broken highlighter setup failed nothing:
build green, tests green, and syntax highlighting silently gone from the whole
site. That is exactly what happened while fixing the comment-contrast colours,
and the only reason it was noticed was an unrelated axe run.
`return null` now means one thing only: the language has no bundled grammar,
which is a content question and a legitimate quiet fallback. The four setup
failures throw instead, each naming the stage and language and preserving the
original error as `cause`:
- highlighter initialisation
- grammar load for a language that IS in bundledLanguages
- codeToHtml rendering (the shape of the real incident)
- output parsing, if Shiki's markup changes and no <code> can be extracted
Chose failing the build over logging: these are build-time, so the e2e
zero-console.error assertion would never see them, and a warning in build output
is easy to miss.
Adds `markdown-pipeline-shiki.test.ts`: 5 tests mocking each failure mode and 2
confirming the quiet path still works (unknown language renders plain, healthy
Shiki still emits per-token colours). Verified every one of the 5 fails when its
guard is removed.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Removes the theme island. Both icons and both accessible names are rendered,
and CSS picks between them off the `.dark` class on <html> using the same
`hidden dark:block` technique as the Navbar logo.
That fixes a real defect rather than just moving code. The island rendered the
dark defaults server-side and only corrected them after hydration, so a
returning light-mode visitor was served a sun icon and the accessible name
"Switch to light mode" until the island booted. Both are now correct in the
first painted frame with no JS at all.
- The name comes from two sr-only spans, not aria-label, since an attribute
cannot be swapped by CSS
- One delegated click listener on document matched via `[data-theme-toggle]`.
It survives View Transitions without rebinding and covers both instances
- The `$theme` persistentAtom and its cross-instance `.listen` sync are gone.
They only existed to reconcile two Vue copies of one control; both copies now
read the same class, so there is nothing to reconcile. `stores/theme.ts` and
`@nanostores/persistent` deleted, no other consumer
Coverage moves from unit tests of the island's internals to 10 e2e tests of the
behaviour, including two with JavaScript disabled that the previous
implementation could not have passed: persistence across reload and client-side
navigation, both instances agreeing across a breakpoint change, and the live
region announcement.
Docs updated in styleguide.md and CLAUDE.md, including the islands/hydration
guidance that described the old SSR-mismatch workaround.
387 e2e and 462 unit tests pass. /about/ JS: 104.1 KB raw / 42.2 KB gz ->
100.5 / 40.4. Small so far because Vue still loads for ConsentBanner and
MobileMenu; the drop lands once those two are ported.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Ports the drawer to static markup plus one script. Every behaviour is
preserved, and the 9 focus-trap tests written in Phase 3 pass unchanged: they
were written against the DOM contract rather than the component, which is what
made the swap verifiable rather than hopeful.
Two things improved rather than just moved:
- `aria-current="page"` is now resolved at build time. Navbar already computes
`isActive()` for the desktop links and passes it through, so the drawer no
longer re-derives the route from `window.location` after mount. The SSR markup
is correct for assistive tech before any script runs, and the two navs cannot
disagree
- the focus trap uses the full 7-pattern focusable selector again (links,
buttons, `input`, `select`, `textarea`, `[contenteditable]`, `[tabindex]`).
The island had narrowed it to three, which was harmless only because the
drawer holds links today
Implementation notes worth knowing:
- the trigger icon is chosen by CSS from `aria-expanded` via
`group-aria-expanded:*`, so the icon cannot drift from the state assistive
tech sees, and the script sets one attribute rather than swapping nodes
- open styling is applied as classes by script, because Tailwind's `flex` sets
`display:flex` and would defeat the `hidden` attribute if both were present
- `transition:persist` is gone with the island, so the drawer is simply
re-rendered per navigation. The script still releases on `astro:before-swap`
to drop the keydown listener and any inert attributes
Adds 3 tests to the existing spec: the icon tracks state, an injected `input`
is inside the tab cycle (proving the widened selector), and crossing to the
desktop breakpoint releases the trap.
390 e2e and 440 unit tests pass. /about/ JS now 95.5 KB raw / 38.0 KB gz, from
104.1 / 42.2 before Phase 4. Vue still loads for ConsentBanner.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Ports the consent UI to static markup plus one script. Both states are rendered
`hidden` and the script reveals whichever matches `$consent`. The state machine
is untouched: src/stores/consent.ts still owns the storage format, the 180-day
expiry, GPC handling and the gtag injection order, and its 54 unit tests are
unchanged.
All Phase 1 fixes preserved and now covered by e2e instead of Vue unit tests:
the focus guard, the `max-h-[80vh] overflow-y-auto` reflow guard and safe-area
padding, Decline-before-Accept order, and `.btn-secondary` styling.
The focus guard gets simpler rather than reimplemented. The bug existed because
a reactive watcher could not tell an init-time restore from a user action. Focus
now moves only from the three click handlers, so there is no transition for it
to misread; `initConsent()` cannot move focus by construction.
Analytics lifecycle moved out of the component into its own script in
Layout.astro. Binding `firePageView` in a component's mount hook meant a
page_view could be missed if the island hydrated after `astro:page-load` had
already fired. Verified at runtime: a returning granted visitor gets a page_view
on first load, another on each client-side navigation, and undecided or declined
visitors get none. Confirmed non-vacuous by delaying the listener registration
and watching the first-load test fail.
`@nanostores/vue` removed, no longer imported. `nanostores` stays: it is the
state machine's storage and has full unit coverage.
/about/ now renders zero islands. 404 e2e and 408 unit tests pass.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Last of the four chrome islands. Rendered `hidden` and revealed by script only
when `starter_nudge_dismissed` is absent, which also removes the flash the
island had for returning visitors: it rendered nothing server-side and appeared
after hydration regardless.
Verifying it needed care. The nudge only renders when some adventure is live,
and every deadline in the repo has now passed (latest 2026-07-28, today
2026-08-24), so it emits nothing on any page. The behavioural tests would have
passed vacuously. So the spec has one test that always runs, pinning the rule
that the nudge is present exactly when a live adventure exists, and the rest
skip with a visible reason when there is none.
To actually exercise the port I temporarily moved a deadline a year out, rebuilt
and ran the suite: all 8 pass, covering reveal on both pages, the starter link
target, dismissal persisting across a reload, the atomic live region and
surviving a client-side navigation. Content reverted afterwards, no data change
in this commit.
Worth knowing separately: the nudge is currently invisible in production for
that same reason.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ive one
The nudge picked the first *live* adventure, so once every deadline passed it
rendered nothing at all. That is exactly when a newcomer still needs somewhere
to start, and it is the state the site is in today: the newest deadline was
2026-07-28. Every page had a dead pointer.
Not a migration regression, for the record: the React StarterNudge did
`ADVENTURE_SUMMARIES.find((a) => a.isLive)` too. Same latent hole, just reached
now that the dates have rolled past.
Adds `getStarterTarget()` to src/lib/challenges.ts: the easiest level of the
most recent adventure, with no live check. Challenges stay solvable after their
rewards window closes, so "latest" is the useful answer.
Two robustness choices, since the whole point is that this must not blank again:
- sorts internally rather than trusting the caller's ordering
- falls back to the easiest level present if an adventure ever ships without a
Beginner, with unknown difficulties ranked last
Also replaces the duplicated two-line starter lookup in index.astro and
challenges/[...tag].astro with one call.
Now renders on / and /challenges/, pointing at
/adventures/dead-reckoning/levels/beginner/. 9 unit tests on the helper and
10 e2e, none skipped: previously 7 of them skipped because there was nothing
live to exercise.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…udge
Rendered as "See ourPrivacy Policyfor details." and
"New here?Start with Dead Reckoning".
Astro strips the whitespace between a text node and an adjacent element when
the source has a newline there, so markup that reads correctly in the editor
loses the space in the output. Adds explicit `{" "}` at the three sites.
I introduced this in the Phase 4 ports, not the migration. Checked rather than
assumed: the React source carried explicit `{" "}` (JSX behaves the same way),
and Vue's compiler condensed the newline to a single space, which is why the
requirement disappeared from view. Both previous versions rendered correctly.
Adds `e2e/inline-spacing.spec.ts`, which scans the build for a link abutting a
word character on either side, allowing punctuation and brackets that
legitimately sit flush. It found exactly these three sites and nothing else
across all 69 pages. Verified it fails when the fix is removed.
Worth a guard rather than just a fix: this is a text-rendering defect that no
axe, smoke or a11y assertion looks at, and it is close to invisible when
reading the source.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
getStarterTarget() picked the most recent adventure outright. An open rewards
window is the more useful destination, so live now wins when one exists. It
stays a preference rather than a gate: with nothing live the pointer still
resolves to the most recent adventure, which is the hole the previous
live-gated version had.
`find()` runs over the newest-first list, so several live adventures resolve to
the newest of them rather than an arbitrary one.
Adds 4 unit tests, including the divergent case the preference exists for: an
older adventure live while a newer one has expired. Also covers several live at
once, and a future *level* deadline counting as live rather than only a rewards
deadline.
Verified against a real build in both states by temporarily moving the oldest
adventure's beginner deadline into the future: the nudge switched from
dead-reckoning (newest, expired) to echoes-lost-in-orbit (oldest, live), and
back on revert. No content change committed.
Two test-only fixes found doing that:
- the e2e helper detected liveness with `/\bLive\b/` against textContent, which
cannot match inside "AdventureLive3 Levels", and it also matched non-card
links. It now scopes to `a.card-glow` and looks for the pill element
- adds `data-live-pill` to LivePill.astro as a stable hook, matching the
data-attribute pattern used by the other ported components
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
"Start a Challenge" carries a down arrow but had been changed to navigate to
/challenges/, so the icon contradicted what the link did. Restored to
`href="#challenges"`, which is what the arrow describes and what the pre-migration
hero did.
Hero renders only on the home page, which still has `<section id="challenges">`,
and `html { scroll-padding-top: 80px }` keeps the target clear of the fixed
navbar. Drops the now-unused `base` binding.
Two tests: the CTA scrolls rather than navigating and lands the section below
the navbar, and the target exists on the page that renders the hero.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Merges the spike. Every level card is rendered server-side with
data-difficulty / data-tags and filtering toggles `hidden`, replacing the Vue
island and its 24.7 KB of serialized props.
| route | JS before | JS after | HTML before | HTML after |
| --- | --- | --- | --- | --- |
| / | 42.1 KB gz | 9.8 | 25.9 KB gz | 20.7 |
| /challenges/ | 42.1 | 9.8 | 24.1 | 19.0 |
| /challenges/kubernetes/ | 42.1 | 9.8 | 20.9 | 19.1 |
HTML shrinks even on the tag route, which now carries 18 cards instead of 3:
losing the props JSON more than pays for cards that compress well against their
siblings. The build contains zero astro-island elements.
A tag route must carry every card, because the filter widens client-side
("All Tools", adding a second tag) without navigating. That is why the payload
could not simply be trimmed per route.
Two defects fixed in passing:
- the live region announced on load for a tag route, so arriving at
/challenges/kubernetes/ spoke "Showing 3 challenges · Kubernetes" with no user
action. Pre-existing Vue behaviour. `touched` is now set only by the action
handlers
- home passed the 6-capped preview count as `adventureCount` instead of the real
total, which would have been wrong the moment a 7th adventure landed
Coverage moves from 57 unit tests on the component to 22 e2e on the rendered
page, deliberately rather than incidentally. Ported: radiogroup structure and
labelling, aria-checked exact values and single-selection invariant, toggle-off,
All Levels clearing, tag aria-pressed both ways, non-arrow keys ignored, all
four URL-sync directions, results/adventures grid swapping, difficulty-only
matching, empty state, summary text, live-region attributes and timing, both
dropdown panels' role and label, opening one closing the other, mobile choice
reflected on desktop, `embedded` heading suppression, and the `seeAllHref`
condition.
Not ported, with reasons: "acts on the radio that received the key rather than
document.activeElement" tested a handler internal that no longer exists now the
listener is per-radio; "aria-checked is the string not just truthy" is subsumed
by toHaveAttribute; several near-duplicate structural assertions collapse into
one count; the remainder only exercised Vue prop plumbing.
ChallengesFilter.vue and its unit tests deleted. No .vue files remain.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Framework policy, in styleguide.md, CONTRIBUTING.md and CLAUDE.md:
- Default to a `.astro` component with a plain `<script>`. Reach for a framework
only when the component has genuinely reactive state that a class toggle and a
small script cannot express.
- When one is warranted it is **Vue, never React**. `@astrojs/vue` and its
toolchain stay installed even though nothing currently uses them, so adding an
island is a one-file change. Called out explicitly so a dependency audit does
not strip them as unused.
- The five components removed from islands in Phase 4 are listed as the bar for
what does not justify a framework, with the challenge filter as the useful
reference: even two dropdowns, a roving-tabindex radiogroup, URL
reconciliation and a live region came out smaller as markup plus a script.
Also documents the Astro whitespace-stripping requirement, which was not written
down anywhere: inline links in prose need `{" "}` on both sides, JSX behaves the
same way, Vue's compiler did not, and `e2e/inline-spacing.spec.ts` is what
catches it.
CONTRIBUTING's code-style section still said "functional components with hooks
only, no class components" and "Tailwind on JSX", which had outlived React.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`astro check` went 57 -> 74 with the Phase 4 and 5 ports. All 15 were mine.
- TypeScript will not carry an `if (!el) return` narrowing into a hoisted
function declaration, so every nested helper in MobileMenu and
ChallengesFilter read its captured element as possibly null. Converted them to
arrow consts, which narrow correctly
- `hidden` is not a valid prop on the icon components; the two clear-icons now
sit in a wrapping span that carries it
- `isAdventureLive` and `getStarterTarget` declared `deadline?: string`, but the
content collection renders it as `string | null | undefined`. Callers
therefore failed to infer their own adventure type and lost `slug`, `title`
and `tags`. Widened both, and `AdventureData` for the same reason
Net 74 -> 44, so also below the 57 baseline recorded in #217. The new components
report zero errors. The remainder is the pre-existing cluster tracked there.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
`astro check` reported 44 errors and had never run in CI, despite the repo
extending astro/tsconfigs/strict. Now zero, and wired into the `check` job in
preview.yml and deploy.yml next to sync and lint.
Six root causes, not 44 problems:
- `LUCIDE_ICONS` was typed `Record<string, Component>`. Vue's `Component` is a
union broad enough that the Astro checker cannot see an entry as renderable,
which surfaced as "X is not a valid component" at all five dynamic call sites.
Typed to the concrete unplugin-icons import instead
- `makeAdventure` in the challenges tests was inferred, so the literal widened
`difficulty` to string and the union with `overrides.levels` stopped matching
AdventureData. Annotated the return type: 19 errors
- the content collection renders `deadline` as `string | null | undefined`;
`RewardsCard` and the difficulty helpers declared `string`
- emoji lookup maps were indexed with a plain string
- `difficulty.test.ts` passes deliberately invalid values ("Novice",
"Unknown"); those are now explicit casts with a comment saying why, rather
than accidental `any`
- two `.astro` map callbacks needed their element type stated
No behaviour change: build, lint, 357 unit and 436 e2e tests unchanged.
Adds the type gate to the pre-submit checklist in CLAUDE.md, and fixes the
stale numbering and the "Vue components" reference in it (no .vue files remain).
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…216)
The amber fill measured 1.59:1 against the near-white surfaces it sits on in
light mode, well under the 3:1 WCAG 1.4.11 wants for the boundary of a control.
Dark mode was never affected: the fill is 11.94:1 there.
Took the recommended option from the issue, a light-mode border, with a
darkened-amber `--primary-border` token rather than a neutral so it reads as
part of the button rather than an outline round it.
Measured on every surface the class actually appears on, not just the consent
banner. A sweep of all `.btn-primary` instances across every route found three
distinct backdrops in light mode: `--background`, `--card`, and the translucent
consent banner over the page. The new border clears 3:1 against all of them:
| | dark | light |
| --- | --- | --- |
| boundary vs surface | 11.94 (fill) | **5.13** (border), was 1.59 |
| label vs fill | 11.94 | 12.67 |
The border box is reserved in both themes with `border-transparent`, so nothing
moves when the colour is applied. That also settles an existing inconsistency:
`.btn-ghost` already carried a 1px border and rendered 46px while
`.btn-primary` and `.btn-secondary` were 44px. All three now match.
Adds `e2e/btn-primary-contrast.spec.ts`: for every route in both themes, every
`.btn-primary` must clear 3:1 against its nearest opaque backdrop by fill or
border, labels must clear 4.5:1, and the three button classes must stay the same
height. 58 checks.
120/120 axe checks and 494 e2e still pass.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…test
The vanilla filter deliberately stopped announcing on page load, but this
assertion still required the sr-only live region to contain the count on a tag
route. It has been failing since that change landed; I missed it because I was
reading the tail of the Playwright output rather than its summary line, and the
failure names sit directly under the "3 failed" header.
The visible count line assertion is unchanged and still passes, so the tag was
always still surfaced. The live-region assertion now checks the opposite and
correct thing: empty on load. Announcement after an actual interaction is
covered in challenges-filter-deep.spec.ts.
Full suite: 497 passed, 0 failed.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
@sinduri-gsinduri-g changed the title feat: scaffold Astro + Vue migration (phases 0-1)feat: complete the Astro migration, remove all framework islandsAug 24, 2026
WAVE flagged very small text on the adventure pages. The only sub-10px text on
the site was the avatar initials chip at `text-[0.6rem]` (9.6px), in
AvatarLink.astro and CommunitySidebar.astro. Both now use `text-xs` (12px), the
type scale minimum, which the repo's own styling rules ask for over arbitrary
values. Verified the two-letter initials still fit the 24px circle with no
overflow.
The chips are not dead code, despite rendering `display:none` most of the time.
They are the fallback for a failed avatar: the images are external Discourse
URLs that go stale when a user changes their avatar, and the inline onerror
swaps the image for the chip. All 132 current entries have an avatarUrl, so the
chip is reached through that path rather than the no-avatar one.
CommunitySidebar was only half-wired: its chip rendered solely in the
no-avatarUrl branch, which never happens, while its <img> had no onerror at all.
A failed avatar there showed a broken-image icon and the chip never appeared.
Now matches AvatarLink. Keeps the per-index colour palette, which is why it does
not simply reuse that component.
Adds two guards:
- `no very small text` in a11y.spec.ts, across all 40 routes, including hidden
elements. Set at WAVE's 10px line rather than the 12px scale minimum because
inline <code> renders at 11.9px by design
- `avatar-fallback.spec.ts`, which blocks the avatar hosts and asserts no failed
image stays displayed and a chip takes its place, on all three surfaces
Both verified non-vacuous by reverting each fix in turn.
Two things that made the fallback test subtle, noted in it: avatars are
loading="lazy", so an image never scrolled into view is pending rather than
failed and never fires onerror; and "displayed" is not the same as "broken",
since an image that loaded fine is also displayed.
541 e2e, 357 unit, 0 astro check errors.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
- ConsentBanner.astro: rewrite script to astro:page-load / astro:before-swap
lifecycle so Accept, Decline, and Reset work after client-side navigation;
fixes Reset button stuck hidden for returning users after navigation
- ChallengesFilter.astro: extract named handlers and add astro:before-swap
teardown to stop mousedown/keydown listeners accumulating across navigations
- solution.astro: move hashchange listener into page-load/before-swap lifecycle
- content.config.ts: throw on adventure directory read failure instead of
silently returning, preventing a silent empty-site deploy
- eslint.config.js: add test-results/ to ignores to prevent ENOENT crash on
fresh checkouts before any e2e run
- CONTRIBUTING.md: fix dev-server port (8080 → 4321), test script name, and
remove stale *.generated.ts claim
- README.md: correct test:e2e description, remove Vue island references for
ChallengesFilter, update nanostores pattern and stores listing
- CLAUDE.md: update Stack section (zero islands, direct atom API), naming
table, nanostores/vue install note, add mandatory listener lifecycle rule
- styleguide.md: correct ChallengesFilter section (.astro + script, not .vue),
fix filter pill reference, add teardown rule
- AGENTS.md: full rewrite for Astro stack (was React Router v8 / React 19)
- e2e/consent-ui.spec.ts: add 4 post-navigation regression tests
- e2e/challenges-filter.spec.ts: add listener accumulation regression test
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
…ecycle
- hash in URL opens the correct step on page load
- no listener accumulation across client-side navigations; teardown fires on
each astro:before-swap (mirrors the ChallengesFilter listener lifecycle test)
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
The absolute statement "must ... be torn down under astro:before-swap"
was wrong for two existing components:
- ThemeToggle.astro: module-scope delegated click on document, runs
once per session, document survives all swaps, delegation matches at
event time — no stale reference, no accumulation.
- StarterNudge.astro: click listener on [data-starter-nudge-dismiss],
a child of the component's own subtree. ClientRouter detaches the
subtree on swap; dead listeners on detached nodes cannot fire and are
GC-eligible. Next astro:page-load operates on fresh nodes.
Updated CLAUDE.md and styleguide.md to name these patterns explicitly
so a reader does not "fix" working components and introduce duplicate
handlers.
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Grep pass across all .md files for: react-router, dist/client, root.tsx,
meta.ts, useEffect, useTheme, useConsent, .tsx/.jsx, generate-adventures,
*.generated.ts, prerender array, public/sitemap.xml, lucide-react.
Changes per file:
- PERFORMANCE.md: update fonts section (root.tsx → Layout.astro, no
route-level links() exports), JS section (React Router → Astro/Vite),
markdown note (generate-adventures → content collection), script loading
(remove RR8 claim), visibility section (useEffect → astro:page-load),
prefetch section (SPECULATION_RULES/useEffect → Astro native prefetch),
Cloudflare output dir (dist/client → dist/), new-routes checklist (remove
react-router.config.ts/src/routes.ts/public/sitemap.xml)
- ADVENTURES.md: sitemap is auto-generated; remove public/sitemap.xml
from the hand-edit checklist
- ACCESSIBILITY.md: lucide-react → unplugin-icons; Footer.tsx → Footer.astro
- .claude/commands/progressive-enhancement.md: React Router v7 → Astro
static output; dist/client → dist/
- .claude/commands/user-personalization.md: useTheme/useConsent hooks →
ThemeToggle.astro + $consent nanostore
- .claude/commands/add-solution.md: dist/client → dist/
- .claude/commands/create-presentation.md: react-router.config.ts/
src/routes.ts → Astro equivalents; *.generated.ts → adventure YAML
Signed-off-by: Sinduri Guntupalli <sinduri.guntupalli@dynatrace.com>
Sign up for freeto 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.

types: wire astro check into CI (57 pre-existing errors to clear first) a11y: .btn-primary has 1.59:1 boundary contrast against light-mode surfaces

1 participant

@sinduri-g