diff --git a/.agents/skills/README.md b/.agents/skills/README.md new file mode 100644 index 000000000..9db4d8db4 --- /dev/null +++ b/.agents/skills/README.md @@ -0,0 +1,24 @@ +# Agent skills + +Reusable skills for AI agents working in the aspire.dev repository. Each skill lives in its own +folder as a `SKILL.md` with YAML frontmatter (`name`, `description`) and is discovered automatically — +there's no central registry to update. Add a new skill by creating `.agents/skills//SKILL.md`. + +> These are **internal** skills for contributors and agents working *on* this repo. They are separate +> from the public skills served under `src/frontend/public/.well-known/agent-skills/`. + +## Skills at a glance + +| Skill | What it's for | +|-------|---------------| +| [`aspire`](./aspire/SKILL.md) | Run, debug, and manage the repo's distributed app via the Aspire CLI. | +| [`container-images`](./container-images/SKILL.md) | Extract container image references from Aspire source into the site's JSON data. | +| [`doc-pr-reviewer`](./doc-pr-reviewer/SKILL.md) | Review a single docs PR for factual accuracy against Aspire's source of truth. | +| [`doc-tester`](./doc-tester/SKILL.md) | Validate documentation against Aspire's actual behavior. | +| [`doc-writer`](./doc-writer/SKILL.md) | Write and maintain accurate documentation pages. | +| [`hex1b`](./hex1b/SKILL.md) | Automate any terminal app in a headless virtual terminal. | +| [`playwright-cli`](./playwright-cli/SKILL.md) | Drive a browser for web testing, screenshots, and data extraction. | +| [`code-review`](./code-review/SKILL.md) | Review code changes (C#, TypeScript, Astro, HTML, CSS) for bugs and test coverage — no nits. | +| [`twoslash-validator`](./twoslash-validator/SKILL.md) | Validate and fix two-slash TypeScript code samples. | +| [`update-integrations`](./update-integrations/SKILL.md) | Sync integration docs links and API reference data. | +| [`update-samples`](./update-samples/SKILL.md) | Refresh the samples data file from `microsoft/aspire-samples`. | diff --git a/.agents/skills/code-review/SKILL.md b/.agents/skills/code-review/SKILL.md new file mode 100644 index 000000000..a781c6175 --- /dev/null +++ b/.agents/skills/code-review/SKILL.md @@ -0,0 +1,272 @@ +--- +name: code-review +description: "Reviews new or changed CODE on aspire.dev for correctness, safety, and adequate test coverage — not documentation prose. USE FOR: reviewing a PR supplied as a number or URL (or the current branch's diff), checking C#/TypeScript/Astro/HTML/CSS changes for bugs, catching correctness/security/data-loss/accessibility regressions, verifying that important scenarios have unit tests, e2e tests (desktop/tablet/mobile), and axe-core accessibility tests. DO NOT USE FOR: validating documentation content or examples (use doc-tester), reviewing a documentation PR for factual accuracy (use doc-pr-reviewer), writing or fixing docs pages (use doc-writer), two-slash TypeScript blocks (use twoslash-validator), or nitpicking style/formatting (ESLint and Prettier own that). INVOKES: git (read-only diff inspection), gh (to resolve and fetch a PR by number or URL), and optionally the repo's existing test commands for verification. FOR SINGLE OPERATIONS: read the diff with git or gh pr diff and apply the relevant language checklist directly." +--- + +# Code Review Skill + +Use this skill to review **code** changes on aspire.dev and produce a high-signal review. The bar is +the highest possible code quality: correct, safe, tested, and accessible. This skill mirrors the +[microsoft/aspire](https://github.com/microsoft/aspire) PR-review flow — **do not nitpick**. Report +only real, high-confidence problems and gaps that a maintainer must act on. + +This skill reviews code (C#, TypeScript, Astro, HTML, CSS). It does **not** validate documentation +accuracy or prose — that belongs to `doc-tester`, `doc-writer`, and (for reviewing a docs PR) +`doc-pr-reviewer`. + +## Input + +This skill reviews **one change set**, supplied in any of these forms: + +- a **PR number** (e.g. `1422`), +- a **full PR URL** (e.g. `https://github.com/microsoft/aspire.dev/pull/1422`), or +- **nothing** — review the current local branch's diff against its base branch. + +Unless the caller says otherwise, a PR belongs to this `aspire.dev` repository. Review exactly the PR +you are given — there is no eligibility filter or selection step; do not go looking for other PRs. +Before reviewing, resolve the PR's **base branch**, **head SHA**, and **changed files** (see below). + +### Resolve a PR with `gh` (read-only) + +Prefer inspecting the diff without switching branches; check the PR out only when you need to run +something (tests/build). A PR URL can be passed directly; for a bare number, pass `--repo` so `gh` +targets the right repository rather than a fork remote. + +```powershell +# Metadata: base branch, head SHA, and the list of changed files +gh pr view --repo microsoft/aspire.dev --json number,baseRefName,headRefName,headRefOid,files + +# The full unified diff to review +gh pr diff --repo microsoft/aspire.dev + +# Check it out locally — only needed to run the optional verification commands +gh pr checkout --repo microsoft/aspire.dev +``` + +Use the resolved **base branch** wherever the workflow below references a base ref. + +## ⚠️ Core rule: signal over noise + +**Only report issues you are confident are real and worth a maintainer's time.** If you would preface +a comment with "nit", "consider", "maybe", or "personal preference", do not write it. + +- ✅ **Report:** bugs, incorrect logic, unhandled failures, race conditions, resource leaks, security + holes (XSS, injection, secret leakage), data loss, breaking API/behavior changes, broken + accessibility, responsive/layout breakage, and **missing tests for important scenarios**. +- ❌ **Do not report:** formatting, import order, naming preferences, whitespace, "could be more + idiomatic", subjective refactors, or anything ESLint/Prettier/`dotnet format` already enforces. + +If a change is correct and adequately tested, say so plainly. A clean review is a valid outcome. + +## Severity and confidence model + +Classify every finding. Only surface **high-confidence** findings. + +| Severity | Meaning | Examples | +|----------|---------|----------| +| **Critical** | Ships a bug, breaks users, or is unsafe. Must fix before merge. | Null deref, XSS, data loss, wrong output, broken build/route, secret committed. | +| **High** | Likely defect or a real gap that should be fixed before merge. | Unhandled error path, race, missing e2e/axe coverage for a user-facing scenario, accessibility regression. | +| **Medium** | Legitimate concern worth addressing; not necessarily blocking. | Fragile logic with no unit test, edge case not handled, unclear failure mode. | + +**Confidence gate:** verify the claim against the actual code before writing it. Trace the value, +read the surrounding function, and confirm the code path is reachable. If you cannot confirm it, +either dig until you can or phrase it as an explicit question — do not assert a bug you haven't +verified. When in doubt, leave it out. + +## Scope + +**In scope (review these):** + +- **C#** — `src/statichost/**`, `src/tools/**`, `src/apphost/**`, and their tests under `tests/**`. +- **Frontend TypeScript** — `src/frontend/src/**/*.ts`, scripts under `src/frontend/scripts/**`, + and tests under `src/frontend/tests/**`. +- **Astro components/pages** — `src/frontend/src/**/*.astro`. +- **HTML** and **CSS/styles** — markup and `src/frontend/src/styles/**`, component-level styles, + and anything affecting layout, theming, or responsiveness. + +**Out of scope (defer, do not review here):** + +- Documentation prose and examples in `src/frontend/src/content/docs/**` (`.md`/`.mdx` body content) + → route to `doc-tester` / `doc-writer`. +- Two-slash TypeScript code fences → route to `twoslash-validator`. +- Generated data files (e.g. `src/frontend/src/data/*.json`) unless the generator logic changed. +- Pure formatting/lint concerns → owned by ESLint, Prettier, and `dotnet format`. + +> Note: CSS/HTML embedded in or emitted by components **is** in scope when it affects behavior, +> layout, responsiveness, or accessibility, even if it lives near docs. + +## Per-language review checklists + +Apply only the checklists for languages that actually changed. Keep findings high-signal. + +### C# (`StaticHost`, tools, AppHost — xUnit, `net10.0`, nullable enabled) + +- **Correctness:** middleware ordering and short-circuiting; request/response paths; header and + content-negotiation parsing (`AcceptHeaderParser`, path mapping) handle malformed/edge input. +- **Nullability:** honor the enabled nullable context — no unjustified `!`, no ignored possible-null. +- **Async:** no `async void` (except handlers), no sync-over-async (`.Result`/`.Wait()`), pass + `CancellationToken` where the surrounding APIs do. +- **Resource safety:** `using`/`await using` for streams, `HttpClient`/handlers, temp files/dirs; + no leaked `IDisposable`. +- **DI lifetimes:** singletons must not capture scoped/transient state; no captive dependencies. +- **Exceptions:** no swallowed exceptions that hide failures; failures surface as correct status/logs. +- **Security:** validate/normalize any path derived from input (path traversal); never log secrets. + +### TypeScript (frontend `src`, `scripts`, tests) + +- **Type safety:** no `any` that erases a real contract; no unsafe casts hiding a mismatch; narrow + before use. Prefer failing types over `@ts-expect-error`/`eslint-disable` unless justified. +- **Null/undefined:** guard optional DOM lookups (`querySelector`, `getElementById`) and API/JSON + fields before dereferencing. +- **DOM/browser:** event listeners are removed when appropriate; no leaks in long-lived scripts; + correct handling of `localStorage`/`sessionStorage` access (can throw) — see existing `try/catch` + patterns in `tests/e2e/helpers`. +- **Async:** every `await`/promise has an error path; no unhandled rejections; no floating promises. +- **Security:** never build DOM from untrusted strings via `innerHTML`; escape/encode user or + external data; no secrets or tokens embedded client-side. + +### Astro components/pages (`*.astro`) + +- **Server vs client:** frontmatter runs at build/SSR — keep browser-only APIs inside ` diff --git a/src/frontend/src/components/AppHostBuilder.client.ts b/src/frontend/src/components/AppHostBuilder.client.ts new file mode 100644 index 000000000..757ffab63 --- /dev/null +++ b/src/frontend/src/components/AppHostBuilder.client.ts @@ -0,0 +1,676 @@ +type AppHostLanguage = 'csharp' | 'typescript'; +type EditorState = 'idle' | 'navigating' | 'selecting' | 'typing' | 'switching'; + +interface DiffHunk { + startOld: number; + deleteCount: number; + insertIndices: number[]; +} + +const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)'; +const LINE_SELECTOR = 'pre > code > .ec-line'; + +function isAppHostLanguage(value: string | undefined): value is AppHostLanguage { + return value === 'csharp' || value === 'typescript'; +} + +function wait(duration: number): Promise { + return new Promise((resolve) => window.setTimeout(resolve, duration)); +} + +function getTextNodes(root: Node): Text[] { + const nodes: Text[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + + while (walker.nextNode()) { + nodes.push(walker.currentNode as Text); + } + + return nodes; +} + +function normalizeLineText(value: string | null): string { + return (value ?? '').replace(/[\r\n]/g, ''); +} + +function getLineText(line: Element): string { + return normalizeLineText(line.querySelector('.code')?.textContent ?? line.textContent); +} + +function getLines(root: ParentNode): HTMLElement[] { + return Array.from(root.querySelectorAll(LINE_SELECTOR)); +} + +function createDiffHunks(oldLines: string[], newLines: string[]): DiffHunk[] { + const table = Array.from({ length: oldLines.length + 1 }, () => + Array(newLines.length + 1).fill(0) + ); + + for (let oldIndex = oldLines.length - 1; oldIndex >= 0; oldIndex -= 1) { + for (let newIndex = newLines.length - 1; newIndex >= 0; newIndex -= 1) { + table[oldIndex][newIndex] = + oldLines[oldIndex] === newLines[newIndex] + ? table[oldIndex + 1][newIndex + 1] + 1 + : Math.max(table[oldIndex + 1][newIndex], table[oldIndex][newIndex + 1]); + } + } + + const hunks: DiffHunk[] = []; + let oldIndex = 0; + let newIndex = 0; + let currentHunk: DiffHunk | undefined; + + const flushHunk = () => { + if (currentHunk) { + hunks.push(currentHunk); + currentHunk = undefined; + } + }; + + while (oldIndex < oldLines.length || newIndex < newLines.length) { + if ( + oldIndex < oldLines.length && + newIndex < newLines.length && + oldLines[oldIndex] === newLines[newIndex] + ) { + flushHunk(); + oldIndex += 1; + newIndex += 1; + continue; + } + + currentHunk ??= { + startOld: oldIndex, + deleteCount: 0, + insertIndices: [], + }; + + const insertionKeepsMoreLines = + newIndex < newLines.length && + (oldIndex === oldLines.length || + table[oldIndex][newIndex + 1] >= table[oldIndex + 1][newIndex]); + + if (insertionKeepsMoreLines) { + currentHunk.insertIndices.push(newIndex); + newIndex += 1; + } else { + currentHunk.deleteCount += 1; + oldIndex += 1; + } + } + + flushHunk(); + return hunks; +} + +function initializeAppHostBuilder(root: HTMLElement): void { + if (root.dataset.editorEnhanced === 'true') return; + + const codeDisplay = root.querySelector('[data-apphost-code-display]'); + const stage = root.querySelector('[data-code-stage]'); + const caret = root.querySelector('[data-editor-caret]'); + const status = root.querySelector('[data-code-status]'); + const motionToggle = root.querySelector('[data-editor-motion-toggle]'); + const toggleButtons = Array.from(root.querySelectorAll('.toggle')); + const languageButtons = Array.from(root.querySelectorAll('.lang-toggle')); + const languageGroups = Array.from( + root.querySelectorAll('.code-lang-group[data-code-lang]') + ); + + if ( + !codeDisplay || + !stage || + !caret || + !status || + !motionToggle || + toggleButtons.length === 0 || + languageButtons.length === 0 + ) { + return; + } + + const selectedLanguage = languageButtons.find( + (button) => button.getAttribute('aria-pressed') === 'true' + )?.dataset.lang; + if (!isAppHostLanguage(selectedLanguage)) return; + + const reducedMotion = window.matchMedia(REDUCED_MOTION_QUERY); + const isEditorMotionAllowed = () => + motionToggle.checked && + !reducedMotion.matches && + !document.hidden && + typeof Element.prototype.animate === 'function'; + let currentLanguage = selectedLanguage; + let desiredLanguage = selectedLanguage; + let currentVariant = getVariantKey(); + let desiredVariant = currentVariant; + let pendingAnnouncement = 'TypeScript AppHost code showing Front end.'; + let processing = false; + let caretLineIndex = 0; + let caretColumn = 0; + + const getTemplate = (language: AppHostLanguage, variant: string): HTMLElement | undefined => + root.querySelector( + `.code-lang-group[data-code-lang="${language}"] .code-variant[data-variant="${variant}"]` + ) ?? undefined; + + const setEditorState = (state: EditorState) => { + stage.dataset.editorState = state; + codeDisplay.dataset.editorState = state; + }; + + const cloneFrame = (template: HTMLElement): HTMLElement | undefined => { + const sourceFrame = template.querySelector('.expressive-code'); + const frame = sourceFrame?.cloneNode(true); + if (!(frame instanceof HTMLElement)) return undefined; + + frame.dataset.editorFrame = ''; + frame.querySelectorAll('.copy').forEach((copyButton) => copyButton.remove()); + + const title = normalizeLineText(frame.querySelector('.title')?.textContent).trim(); + const codeRegion = frame.querySelector('pre'); + if (title && codeRegion) { + codeRegion.setAttribute('aria-label', `${title} preview in Build your AppHost`); + } + + return frame; + }; + + const mountFrame = (language: AppHostLanguage, variant: string): HTMLElement | undefined => { + const template = getTemplate(language, variant); + if (!template) return undefined; + + const frame = cloneFrame(template); + if (!frame) return undefined; + + stage.querySelector('[data-editor-frame]')?.remove(); + stage.insertBefore(frame, caret); + stage.dataset.codeLang = language; + stage.dataset.codeVariant = variant; + return frame; + }; + + const updateCaretPosition = ( + line: HTMLElement, + column: number, + travelDuration = 0 + ): Promise => { + if (!line.isConnected) return Promise.resolve(); + + const lineElements = getLines(stage); + const nextLineIndex = lineElements.indexOf(line); + if (nextLineIndex >= 0) { + caretLineIndex = nextLineIndex; + caretColumn = column; + } + + const code = line.querySelector('.code'); + if (!code) return Promise.resolve(); + + const lineRect = line.getBoundingClientRect(); + const codeRect = code.getBoundingClientRect(); + const stageRect = stage.getBoundingClientRect(); + // Empty/blank lines have no text node to anchor to, so the caret falls back to + // the code element's box left. That box includes Expressive Code's inline + // padding, so start from the content edge (after the left padding) to keep the + // caret aligned with the first glyph column instead of hugging the frame edge. + const codePaddingLeft = parseFloat(getComputedStyle(code).paddingLeft) || 0; + let caretX = codeRect.left + codePaddingLeft; + let caretTextRect: DOMRect | undefined; + let remainingCharacters = Math.max(0, column); + let foundTextPosition = false; + + for (const textNode of getTextNodes(code)) { + const textLength = normalizeLineText(textNode.data).length; + if (remainingCharacters > textLength) { + remainingCharacters -= textLength; + continue; + } + + const range = document.createRange(); + const offset = Math.min(remainingCharacters, textNode.data.length); + range.setStart(textNode, offset); + range.collapse(true); + const rangeRect = Array.from(range.getClientRects()).at(-1) ?? range.getBoundingClientRect(); + caretX = rangeRect.left || caretX; + caretTextRect = rangeRect; + foundTextPosition = true; + break; + } + + if (!foundTextPosition && column > 0) { + const textNodes = getTextNodes(code); + let lastTextNode: Text | undefined; + for (let index = textNodes.length - 1; index >= 0; index -= 1) { + if (normalizeLineText(textNodes[index].data).length > 0) { + lastTextNode = textNodes[index]; + break; + } + } + if (lastTextNode) { + const range = document.createRange(); + range.setStart(lastTextNode, Math.max(0, lastTextNode.length - 1)); + range.setEnd(lastTextNode, lastTextNode.length); + const rangeRect = + Array.from(range.getClientRects()).at(-1) ?? range.getBoundingClientRect(); + caretX = rangeRect.right || caretX; + caretTextRect = rangeRect; + } + } + + const caretLineHeight = caretTextRect?.height || lineRect.height; + const caretHeight = Math.max(14, Math.min(24, caretLineHeight)); + const caretTop = (caretTextRect?.top || lineRect.top) + (caretLineHeight - caretHeight) / 2; + const effectiveDuration = isEditorMotionAllowed() ? travelDuration : 0; + // Snap to the device-pixel grid so the thin caret renders crisp instead of + // smeared across sub-pixels, keeping it pixel-perfect against the glyphs. + // Snap the absolute viewport coordinate (not the stage-relative offset) so it + // stays aligned even when the stage itself sits on a fractional pixel. + const dpr = window.devicePixelRatio || 1; + const snap = (value: number) => Math.round(value * dpr) / dpr; + caret.style.height = `${snap(caretHeight)}px`; + caret.style.transitionDuration = `${effectiveDuration}ms`; + caret.style.transform = `translate3d(${snap(caretX) - stageRect.left}px, ${ + snap(caretTop) - stageRect.top + }px, 0)`; + + return effectiveDuration > 0 ? wait(effectiveDuration) : Promise.resolve(); + }; + + const placeCaretAtEnd = async (travelDuration = 0) => { + const lines = getLines(stage); + const line = lines.at(-1); + if (line) { + await updateCaretPosition(line, getLineText(line).length, travelDuration); + } + }; + + const repositionCaret = () => { + const lines = getLines(stage); + const line = lines[Math.min(caretLineIndex, Math.max(0, lines.length - 1))]; + if (line) { + void updateCaretPosition(line, Math.min(caretColumn, getLineText(line).length)); + } + }; + + const selectAndDeleteLines = async ( + startIndex: number, + deleteCount: number + ): Promise => { + const lines = getLines(stage); + const selectedLines = lines.slice(startIndex, startIndex + deleteCount); + const firstLine = selectedLines[0]; + const lastLine = selectedLines.at(-1); + if (!firstLine || !lastLine) return true; + + setEditorState('navigating'); + await updateCaretPosition(lastLine, getLineText(lastLine).length, 220); + if (!isEditorMotionAllowed()) return false; + + selectedLines.forEach((line) => line.setAttribute('data-editor-selection', '')); + setEditorState('selecting'); + await updateCaretPosition(firstLine, 0, 160); + await wait(260); + if (!isEditorMotionAllowed()) return false; + + const removals = selectedLines.map( + (line) => + line.animate( + [ + { opacity: 1, transform: 'translateX(0)' }, + { opacity: 0, transform: 'translateX(-0.35rem)' }, + ], + { + duration: 110, + easing: 'cubic-bezier(0.4, 0, 1, 1)', + fill: 'forwards', + } + ).finished + ); + await Promise.all(removals); + if (!isEditorMotionAllowed()) return false; + selectedLines.forEach((line) => line.remove()); + return true; + }; + + const typeLine = async ( + sourceLine: HTMLElement, + outputLine: HTMLElement, + charactersPerStep: number + ): Promise => { + const sourceCode = sourceLine.querySelector('.code'); + const outputCode = outputLine.querySelector('.code'); + if (!sourceCode || !outputCode) return true; + + const sourceNodes = getTextNodes(sourceCode); + const outputNodes = getTextNodes(outputCode); + const segments = sourceNodes.map((node, index) => ({ + output: outputNodes[index], + value: normalizeLineText(node.data), + })); + + outputNodes.forEach((node) => { + node.data = ''; + }); + + let typedCharacters = 0; + for (const segment of segments) { + if (!segment.output) continue; + + for (let offset = 0; offset < segment.value.length; offset += charactersPerStep) { + if (!isEditorMotionAllowed()) return false; + const nextOffset = Math.min(segment.value.length, offset + charactersPerStep); + segment.output.data = segment.value.slice(0, nextOffset); + typedCharacters += nextOffset - offset; + await updateCaretPosition(outputLine, typedCharacters); + + const lastCharacter = segment.value[nextOffset - 1] ?? ''; + await wait(/[;{}()[\]]/.test(lastCharacter) ? 22 : lastCharacter === ' ' ? 7 : 12); + } + } + + outputLine.innerHTML = sourceLine.innerHTML; + await updateCaretPosition(outputLine, getLineText(outputLine).length); + return true; + }; + + const insertLines = async ( + code: HTMLElement, + startIndex: number, + sourceLines: HTMLElement[] + ): Promise => { + const totalCharacters = sourceLines.reduce( + (total, line) => total + getLineText(line).length, + 0 + ); + const charactersPerStep = Math.max(1, Math.ceil(totalCharacters / 120)); + + setEditorState('typing'); + for (let index = 0; index < sourceLines.length; index += 1) { + if (!isEditorMotionAllowed()) return false; + const sourceLine = sourceLines[index]; + const outputLine = sourceLine.cloneNode(true); + if (!(outputLine instanceof HTMLElement)) continue; + + outputLine.dataset.editorInserting = ''; + const outputCode = outputLine.querySelector('.code'); + if (outputCode) { + getTextNodes(outputCode).forEach((node) => { + node.data = ''; + }); + } + const referenceLine = getLines(code)[startIndex + index] ?? null; + code.insertBefore(outputLine, referenceLine); + + await updateCaretPosition(outputLine, 0, index === 0 ? 180 : 55); + if (!(await typeLine(sourceLine, outputLine, charactersPerStep))) return false; + delete outputLine.dataset.editorInserting; + await wait(34); + } + return true; + }; + + const animateVariantChange = async (language: AppHostLanguage, targetVariant: string) => { + const frame = stage.querySelector('[data-editor-frame]'); + const code = frame?.querySelector('pre > code'); + const template = getTemplate(language, targetVariant); + if (!frame || !code || !template) { + mountFrame(language, targetVariant); + return; + } + + const oldLines = getLines(frame); + const targetLines = getLines(template); + const targetTexts = targetLines.map(getLineText); + const hunks = createDiffHunks(oldLines.map(getLineText), targetTexts); + let lineOffset = 0; + + for (const hunk of hunks) { + const editIndex = hunk.startOld + lineOffset; + + if (hunk.deleteCount > 0) { + if (!(await selectAndDeleteLines(editIndex, hunk.deleteCount))) { + mountFrame(language, targetVariant); + await placeCaretAtEnd(); + return; + } + } + + const insertedLines = hunk.insertIndices + .map((lineIndex) => targetLines[lineIndex]) + .filter((line): line is HTMLElement => line !== undefined); + if (insertedLines.length > 0) { + if (!(await insertLines(code, editIndex, insertedLines))) { + mountFrame(language, targetVariant); + await placeCaretAtEnd(); + return; + } + } else { + const remainingLines = getLines(stage); + const nextLine = + remainingLines[editIndex] ?? + remainingLines[Math.max(0, Math.min(editIndex - 1, remainingLines.length - 1))]; + if (nextLine) { + const column = remainingLines[editIndex] ? 0 : getLineText(nextLine).length; + await updateCaretPosition(nextLine, column, 80); + } + } + + lineOffset += insertedLines.length - hunk.deleteCount; + } + + const finalLines = getLines(stage); + const finalMatchesTarget = + finalLines.length === targetTexts.length && + finalLines.every((line, index) => getLineText(line) === targetTexts[index]); + + if (!finalMatchesTarget) { + mountFrame(language, targetVariant); + await placeCaretAtEnd(); + } else { + stage.dataset.codeVariant = targetVariant; + } + }; + + const switchLanguage = async (language: AppHostLanguage, variant: string) => { + const currentFrame = stage.querySelector('[data-editor-frame]'); + setEditorState('switching'); + + if (currentFrame) { + await currentFrame.animate( + [ + { opacity: 1, transform: 'translateY(0)' }, + { opacity: 0, transform: 'translateY(-0.25rem)' }, + ], + { + duration: 130, + easing: 'cubic-bezier(0.4, 0, 1, 1)', + fill: 'forwards', + } + ).finished; + if (!isEditorMotionAllowed()) { + mountFrame(language, variant); + await placeCaretAtEnd(); + return; + } + } + + const nextFrame = mountFrame(language, variant); + if (nextFrame) { + await nextFrame.animate( + [ + { opacity: 0, transform: 'translateY(0.25rem)' }, + { opacity: 1, transform: 'translateY(0)' }, + ], + { + duration: 180, + easing: 'cubic-bezier(0.16, 1, 0.3, 1)', + fill: 'both', + } + ).finished; + } + + await placeCaretAtEnd(); + }; + + const processRequestedState = async () => { + if (processing) return; + processing = true; + codeDisplay.setAttribute('aria-busy', 'true'); + + try { + while ( + root.isConnected && + (currentLanguage !== desiredLanguage || currentVariant !== desiredVariant) + ) { + const nextLanguage = desiredLanguage; + const nextVariant = desiredVariant; + const shouldAnimate = isEditorMotionAllowed(); + + codeDisplay.dataset.editorMotion = shouldAnimate ? 'animated' : 'reduced'; + if (!shouldAnimate) { + mountFrame(nextLanguage, nextVariant); + await placeCaretAtEnd(); + } else if (currentLanguage !== nextLanguage) { + await switchLanguage(nextLanguage, nextVariant); + } else { + await animateVariantChange(nextLanguage, nextVariant); + } + + currentLanguage = nextLanguage; + currentVariant = nextVariant; + } + } catch (error) { + console.error('AppHost editor animation failed.', error); + mountFrame(desiredLanguage, desiredVariant); + currentLanguage = desiredLanguage; + currentVariant = desiredVariant; + await placeCaretAtEnd(); + } finally { + setEditorState('idle'); + codeDisplay.setAttribute('aria-busy', 'false'); + status.textContent = `${pendingAnnouncement} Code preview updated.`; + processing = false; + + if (currentLanguage !== desiredLanguage || currentVariant !== desiredVariant) { + void processRequestedState(); + } + } + }; + + function getVariantKey(): string { + const isSelected = (name: string) => + root.querySelector(`.toggle[data-toggle="${name}"]`)?.classList.contains('active') ?? false; + const hasFrontend = isSelected('frontend'); + const hasDatabase = isSelected('database'); + const hasApi = isSelected('api'); + const hasContainer = isSelected('container'); + const hasDeployment = isSelected('deployment'); + + if (!hasFrontend && !hasDatabase && !hasApi && !hasContainer) { + return 'empty'; + } + + let variant = ''; + if (hasDatabase && hasApi && hasFrontend) { + variant = 'databaseApiFrontend'; + } else if (hasDatabase && hasApi) { + variant = 'databaseApi'; + } else if (hasDatabase && hasFrontend) { + variant = 'databaseFrontend'; + } else if (hasDatabase) { + variant = 'database'; + } else if (hasApi && hasFrontend) { + variant = 'apiFrontend'; + } else if (hasApi) { + variant = 'api'; + } else if (hasFrontend) { + variant = 'frontend'; + } else if (hasContainer) { + variant = 'container'; + } + + if (hasContainer && variant !== 'container') { + variant += 'Container'; + } + if (hasDeployment) { + variant += 'Deployment'; + } + + return variant; + } + + motionToggle.addEventListener('change', () => { + const enabled = motionToggle.checked; + root.dataset.editorMotionEnabled = String(enabled); + codeDisplay.dataset.editorMotion = enabled + ? reducedMotion.matches + ? 'reduced' + : 'animated' + : 'disabled'; + + if (enabled) { + void placeCaretAtEnd(); + } + }); + + languageButtons.forEach((button) => { + button.addEventListener('click', () => { + const language = button.dataset.lang; + if (!isAppHostLanguage(language) || desiredLanguage === language) return; + + languageButtons.forEach((candidate) => { + const isSelected = candidate === button; + candidate.classList.toggle('active', isSelected); + candidate.setAttribute('aria-pressed', String(isSelected)); + }); + + desiredLanguage = language; + desiredVariant = getVariantKey(); + pendingAnnouncement = `AppHost code language changed to ${button.textContent?.trim() ?? language}.`; + root.dispatchEvent( + new CustomEvent('aspire:apphost-language-change', { + bubbles: true, + detail: { language }, + }) + ); + void processRequestedState(); + }); + }); + + toggleButtons.forEach((button) => { + button.addEventListener('click', () => { + const isSelected = button.classList.toggle('active'); + button.setAttribute('aria-pressed', String(isSelected)); + desiredVariant = getVariantKey(); + + const label = button.textContent?.trim() ?? 'Option'; + pendingAnnouncement = `${label} ${isSelected ? 'added to' : 'removed from'} the AppHost.`; + void processRequestedState(); + }); + }); + + const initialFrame = mountFrame(currentLanguage, currentVariant); + if (!initialFrame) return; + + root.dataset.editorEnhanced = 'true'; + root.dataset.editorMotionEnabled = String(motionToggle.checked); + codeDisplay.dataset.editorEnhanced = 'true'; + stage.hidden = false; + languageGroups.forEach((group) => { + group.hidden = true; + }); + setEditorState('idle'); + + window.requestAnimationFrame(() => { + void placeCaretAtEnd(); + }); + void document.fonts?.ready.then(repositionCaret); + stage.addEventListener('scroll', repositionCaret, true); +} + +export function initializeAppHostBuilders(): void { + document + .querySelectorAll('[data-apphost-builder]') + .forEach(initializeAppHostBuilder); +} diff --git a/src/frontend/src/components/CustomSelect.astro b/src/frontend/src/components/CustomSelect.astro new file mode 100644 index 000000000..cb718b68f --- /dev/null +++ b/src/frontend/src/components/CustomSelect.astro @@ -0,0 +1,640 @@ +--- +import { Icon } from '@astrojs/starlight/components'; + +export interface CustomSelectOption { + value: string; + label: string; + description?: string; + detector?: string; + disabled?: boolean; + selected?: boolean; +} + +interface Props { + id: string; + label: string; + options: CustomSelectOption[]; + class?: string; + menuWidth?: 'trigger' | 'content'; + placement?: 'auto' | 'up' | 'down'; +} + +const { + id, + label, + options, + class: className, + menuWidth = 'trigger', + placement = 'auto', +} = Astro.props; +const selectedOption = options.find((option) => option.selected && !option.disabled) ?? options[0]; +const listboxId = `${id}-listbox`; +const hasIcon = Astro.slots.has('icon'); +--- + +
+ + + + + +
+ + + + diff --git a/src/frontend/src/components/DashboardCarousel.astro b/src/frontend/src/components/DashboardCarousel.astro index f8d45fc77..9d7be700c 100644 --- a/src/frontend/src/components/DashboardCarousel.astro +++ b/src/frontend/src/components/DashboardCarousel.astro @@ -3,6 +3,9 @@ import { Icon } from '@astrojs/starlight/components'; import ThemeImage from '@components/ThemeImage.astro'; import type { ImageMetadata } from 'astro'; +import PauseIcon from '@assets/icons/pause.svg'; +import PlayIcon from '@assets/icons/play.svg'; + import resourcesGraphLight from '@assets/dashboard/landing/resources-graph-light.png'; import resourcesGraphDark from '@assets/dashboard/landing/resources-graph-dark.png'; import resourcesTableLight from '@assets/dashboard/landing/resources-table-light.png'; @@ -26,6 +29,13 @@ interface Slide { dark: ImageMetadata; } +interface Props { + autoplay?: boolean; + presentation?: 'coverflow' | 'stage'; +} + +const { autoplay = true, presentation = 'coverflow' } = Astro.props as Props; + const slides: Slide[] = [ { id: 'resources-graph', @@ -93,6 +103,8 @@ const carouselLabel = tt('landing.dashboardCarousel.label', 'Aspire dashboard vi const chooseViewLabel = tt('landing.dashboardCarousel.chooseView', 'Choose a dashboard view'); const previousLabel = tt('landing.dashboardCarousel.previous', 'Previous view'); const nextLabel = tt('landing.dashboardCarousel.next', 'Next view'); +const playLabel = tt('landing.dashboardCarousel.play', 'Play dashboard tour'); +const pauseLabel = tt('landing.dashboardCarousel.pause', 'Pause dashboard tour'); const formatSlideLabel = (index: number, count: number, label: string) => tt('landing.dashboardCarousel.slideLabel', '{{index}} of {{count}}: {{label}}', { index, @@ -107,6 +119,8 @@ const formatSlideLabel = (index: number, count: number, label: string) => role="group" aria-roledescription="carousel" aria-label={carouselLabel} + data-autoplay={autoplay ? 'true' : 'false'} + data-presentation={presentation} >
{ @@ -132,8 +146,24 @@ const formatSlideLabel = (index: number, count: number, label: string) => }
+ +