diff --git a/src/frontend/astro.config.mjs b/src/frontend/astro.config.mjs
index c47d03ed5..2921efad6 100644
--- a/src/frontend/astro.config.mjs
+++ b/src/frontend/astro.config.mjs
@@ -90,12 +90,6 @@ export default defineConfig({
plugins: [
starlightPageActions({
share: true,
- // Use the plugin's built-in actions so each entry renders its real
- // brand icon. Custom actions are always given the plugin's generic
- // "AI" sparkle glyph, which is why every "Open in …" item previously
- // showed the same icon. The built-in hrefs match what we want, and
- // the GitHub Copilot mark is restyled to the site's GitHub logo in
- // src/components/starlight/PageTitle.astro.
actions: {
chatgpt: true,
claude: true,
@@ -128,6 +122,7 @@ export default defineConfig({
showTooltip: true,
threshold: 10,
showOnHomepage: true,
+ svgStrokeWidth: 4,
tooltipText: {
da: 'Rul op',
de: 'Nach oben scrollen',
diff --git a/src/frontend/src/assets/dashboard/landing/console-dark.png b/src/frontend/src/assets/dashboard/landing/console-dark.png
index 35cd41d0b..74f5a88f0 100644
Binary files a/src/frontend/src/assets/dashboard/landing/console-dark.png and b/src/frontend/src/assets/dashboard/landing/console-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/console-light.png b/src/frontend/src/assets/dashboard/landing/console-light.png
index 0aa25e3f9..60524be95 100644
Binary files a/src/frontend/src/assets/dashboard/landing/console-light.png and b/src/frontend/src/assets/dashboard/landing/console-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/metrics-dark.png b/src/frontend/src/assets/dashboard/landing/metrics-dark.png
index c6568de3f..b6f95f0a8 100644
Binary files a/src/frontend/src/assets/dashboard/landing/metrics-dark.png and b/src/frontend/src/assets/dashboard/landing/metrics-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/metrics-light.png b/src/frontend/src/assets/dashboard/landing/metrics-light.png
index f3273cf92..85dfa8462 100644
Binary files a/src/frontend/src/assets/dashboard/landing/metrics-light.png and b/src/frontend/src/assets/dashboard/landing/metrics-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/resources-graph-dark.png b/src/frontend/src/assets/dashboard/landing/resources-graph-dark.png
index cfc5667f8..a6ce8052b 100644
Binary files a/src/frontend/src/assets/dashboard/landing/resources-graph-dark.png and b/src/frontend/src/assets/dashboard/landing/resources-graph-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/resources-graph-light.png b/src/frontend/src/assets/dashboard/landing/resources-graph-light.png
index 90d2a8246..1fbe5a851 100644
Binary files a/src/frontend/src/assets/dashboard/landing/resources-graph-light.png and b/src/frontend/src/assets/dashboard/landing/resources-graph-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/resources-table-dark.png b/src/frontend/src/assets/dashboard/landing/resources-table-dark.png
index 9cefd62e9..49ee5809a 100644
Binary files a/src/frontend/src/assets/dashboard/landing/resources-table-dark.png and b/src/frontend/src/assets/dashboard/landing/resources-table-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/resources-table-light.png b/src/frontend/src/assets/dashboard/landing/resources-table-light.png
index bbd22de8e..356c9d595 100644
Binary files a/src/frontend/src/assets/dashboard/landing/resources-table-light.png and b/src/frontend/src/assets/dashboard/landing/resources-table-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/structured-logs-dark.png b/src/frontend/src/assets/dashboard/landing/structured-logs-dark.png
index 13651613d..7a984b8f2 100644
Binary files a/src/frontend/src/assets/dashboard/landing/structured-logs-dark.png and b/src/frontend/src/assets/dashboard/landing/structured-logs-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/structured-logs-light.png b/src/frontend/src/assets/dashboard/landing/structured-logs-light.png
index aea6c4492..582d488a7 100644
Binary files a/src/frontend/src/assets/dashboard/landing/structured-logs-light.png and b/src/frontend/src/assets/dashboard/landing/structured-logs-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/trace-detail-dark.png b/src/frontend/src/assets/dashboard/landing/trace-detail-dark.png
index b020d51ea..445ffc6ab 100644
Binary files a/src/frontend/src/assets/dashboard/landing/trace-detail-dark.png and b/src/frontend/src/assets/dashboard/landing/trace-detail-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/trace-detail-light.png b/src/frontend/src/assets/dashboard/landing/trace-detail-light.png
index e6af9796f..e39b7f885 100644
Binary files a/src/frontend/src/assets/dashboard/landing/trace-detail-light.png and b/src/frontend/src/assets/dashboard/landing/trace-detail-light.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/traces-dark.png b/src/frontend/src/assets/dashboard/landing/traces-dark.png
index b5d7323d1..08fd86e77 100644
Binary files a/src/frontend/src/assets/dashboard/landing/traces-dark.png and b/src/frontend/src/assets/dashboard/landing/traces-dark.png differ
diff --git a/src/frontend/src/assets/dashboard/landing/traces-light.png b/src/frontend/src/assets/dashboard/landing/traces-light.png
index 8bc9b34d5..4eb318733 100644
Binary files a/src/frontend/src/assets/dashboard/landing/traces-light.png and b/src/frontend/src/assets/dashboard/landing/traces-light.png differ
diff --git a/src/frontend/src/assets/icons/agent-claude.svg b/src/frontend/src/assets/icons/agent-claude.svg
new file mode 100644
index 000000000..62dc0db12
--- /dev/null
+++ b/src/frontend/src/assets/icons/agent-claude.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/frontend/src/assets/icons/agent-openai.svg b/src/frontend/src/assets/icons/agent-openai.svg
new file mode 100644
index 000000000..78caf4fa2
--- /dev/null
+++ b/src/frontend/src/assets/icons/agent-openai.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/src/frontend/src/components/AppHostBuilder.astro b/src/frontend/src/components/AppHostBuilder.astro
index 404a17370..e3d8f7d23 100644
--- a/src/frontend/src/components/AppHostBuilder.astro
+++ b/src/frontend/src/components/AppHostBuilder.astro
@@ -1,6 +1,19 @@
---
import { Code, Icon } from '@astrojs/starlight/components';
+interface Props {
+ headingLevel?: 'h2' | 'h3';
+ heading?: string;
+ description?: string | null;
+}
+
+const {
+ headingLevel = 'h3',
+ heading = Astro.locals.t('appHostBuilder.heading', { appHost: 'AppHost' }),
+ description = Astro.locals.t('appHostBuilder.description'),
+} = Astro.props as Props;
+const Heading = headingLevel;
+
// Define all possible code combinations — C#
const codes = {
empty: `var builder = DistributedApplication.CreateBuilder(args);
@@ -206,7 +219,6 @@ builder.Build().Run();`,
// Add frontend service
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
builder.Build().Run();`,
@@ -216,7 +228,6 @@ builder.Build().Run();`,
// Add frontend service
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
// Add custom container
@@ -237,7 +248,6 @@ var postgres = builder.AddAzurePostgresFlexibleServer("db")
// Add frontend service
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
builder.Build().Run();`,
@@ -253,7 +263,6 @@ var postgres = builder.AddAzurePostgresFlexibleServer("db")
// Add frontend service
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
// Add custom container
@@ -273,7 +282,6 @@ var api = builder.AddProject("api", "../api/ApiService.csproj")
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
.WithReference(api)
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
builder.Build().Run();`,
@@ -288,7 +296,6 @@ var api = builder.AddProject("api", "../api/ApiService.csproj")
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
.WithReference(api)
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
// Add custom container
@@ -316,7 +323,6 @@ var api = builder.AddProject("api", "../api/ApiService.csproj")
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
.WithReference(api)
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
builder.Build().Run();`,
@@ -339,7 +345,6 @@ var api = builder.AddProject("api", "../api/ApiService.csproj")
var frontend = builder.AddViteApp("frontend", "../frontend")
.WithHttpEndpoint(env: "PORT")
.WithReference(api)
- .WithNpmPackageInstallation()
.PublishAsDockerFile();
// Add custom container
@@ -721,7 +726,6 @@ const builder = await createBuilder();
const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
- .withNpmPackageInstallation()
.publishAsDockerFile();
await builder.build().run();`,
@@ -734,7 +738,6 @@ const builder = await createBuilder();
const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
- .withNpmPackageInstallation()
.publishAsDockerFile();
// Add custom container
@@ -760,7 +763,6 @@ const postgres = await builder
const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
- .withNpmPackageInstallation()
.publishAsDockerFile();
await builder.build().run();`,
@@ -780,7 +782,6 @@ const postgres = await builder
const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
- .withNpmPackageInstallation()
.publishAsDockerFile();
// Add custom container
@@ -806,7 +807,6 @@ const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
.withReference(api)
- .withNpmPackageInstallation()
.publishAsDockerFile();
await builder.build().run();`,
@@ -826,7 +826,6 @@ const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
.withReference(api)
- .withNpmPackageInstallation()
.publishAsDockerFile();
// Add custom container
@@ -861,7 +860,6 @@ const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
.withReference(api)
- .withNpmPackageInstallation()
.publishAsDockerFile();
await builder.build().run();`,
@@ -890,7 +888,6 @@ const frontend = await builder
.addViteApp("frontend", "./frontend")
.withHttpEndpoint({ env: "PORT" })
.withReference(api)
- .withNpmPackageInstallation()
.publishAsDockerFile();
// Add custom container
@@ -1025,14 +1022,16 @@ await builder.build().run();`,
};
---
-
+
-
+
+
+
+
+
+ TypeScript AppHost code showing Front end.
+
-
+
@@ -1207,7 +1212,7 @@ await builder.build().run();`,
-
+
@@ -1241,7 +1251,7 @@ await builder.build().run();`,
border-top-right-radius: 0.75rem;
}
- .header h3 {
+ .header :is(h2, h3) {
margin: 0 0 0.5rem 0;
color: var(--sl-color-text);
font-size: 1.25rem;
@@ -1268,7 +1278,6 @@ await builder.build().run();`,
cursor: pointer;
font-size: 0.85rem;
font-weight: 500;
- transition: all 0.2s ease;
margin-top: 0;
}
@@ -1277,8 +1286,13 @@ await builder.build().run();`,
}
.lang-toggle.active {
- background: var(--aspire-color-secondary);
- color: var(--aspire-color-black);
+ background: var(--aspire-action-selected, var(--sl-color-accent-low));
+ color: var(--aspire-action-selected-text, var(--sl-color-accent-high));
+ box-shadow: inset 0 0 0 1px var(--aspire-action-selected-border, var(--sl-color-accent));
+ }
+
+ .lang-toggle.active:hover {
+ background: var(--aspire-action-selected-hover, var(--sl-color-accent-low));
}
.header p {
@@ -1292,6 +1306,32 @@ await builder.build().run();`,
gap: 0.75rem;
}
+ .editor-motion-option {
+ width: fit-content;
+ min-height: 1.5rem;
+ display: inline-flex;
+ align-items: center;
+ gap: 0.45rem;
+ margin: 1.25rem 0 0;
+ color: var(--sl-color-gray-2);
+ font-size: 0.75rem;
+ font-weight: 600;
+ line-height: 1;
+ cursor: pointer;
+ }
+
+ .editor-motion-option input {
+ width: 0.9rem;
+ height: 0.9rem;
+ margin: 0;
+ accent-color: var(--aspire-action-focus, var(--sl-color-accent));
+ }
+
+ .editor-motion-option input:focus-visible {
+ outline: 0.1875rem solid var(--aspire-action-focus, var(--sl-color-accent));
+ outline-offset: 0.18rem;
+ }
+
.toggle {
display: flex;
flex-direction: column;
@@ -1314,13 +1354,22 @@ await builder.build().run();`,
.toggle:hover {
border-color: var(--sl-color-gray-4);
background: var(--sl-color-bg-nav);
- transform: translateY(-1px);
}
.toggle.active {
- background: var(--aspire-color-primary);
- border-color: var(--sl-color-gray-4);
- color: var(--aspire-color-white);
+ background: var(--aspire-action-selected, var(--sl-color-accent-low));
+ border-color: var(--aspire-action-selected-border, var(--sl-color-accent));
+ color: var(--aspire-action-selected-text, var(--sl-color-accent-high));
+ }
+
+ .toggle.active:hover {
+ background: var(--aspire-action-selected-hover, var(--sl-color-accent-low));
+ border-color: var(--aspire-action-selected-border, var(--sl-color-accent));
+ }
+
+ :is(.lang-toggle, .toggle):focus-visible {
+ outline: 0.1875rem solid var(--aspire-action-focus, var(--sl-color-accent));
+ outline-offset: 0.125rem;
}
.code-display {
@@ -1333,8 +1382,77 @@ await builder.build().run();`,
margin-top: 0;
}
+ .code-stage {
+ position: relative;
+ isolation: isolate;
+ }
+
+ .editor-caret {
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 2;
+ width: 0.125rem;
+ border-radius: 1px;
+ background: var(--aspire-action-focus, var(--sl-color-accent));
+ box-shadow: 0 0 0.5rem
+ color-mix(in srgb, var(--aspire-action-focus, var(--sl-color-accent)) 45%, transparent);
+ opacity: 1;
+ pointer-events: none;
+ transform: translate3d(0, 0, 0);
+ }
+
+ [data-apphost-builder][data-editor-motion-enabled='false'] .editor-caret {
+ display: none;
+ }
+
+ .code-stage[data-editor-state='switching'] .editor-caret {
+ opacity: 0;
+ }
+
+ .code-stage :global(.ec-line[data-editor-selection]) {
+ background: color-mix(in srgb, var(--sl-color-accent) 24%, transparent) !important;
+ box-shadow: inset 0.125rem 0 var(--sl-color-accent);
+ }
+
+ .sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+ }
+
+ @keyframes apphost-caret-blink {
+ 0%,
+ 48% {
+ opacity: 1;
+ }
+
+ 49%,
+ 100% {
+ opacity: 0;
+ }
+ }
+
+ @keyframes apphost-line-enter {
+ from {
+ opacity: 0;
+ transform: translateY(0.2rem);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+ }
+
html[data-theme='dark'] .toggle.active {
- color: var(--aspire-color-white);
+ color: var(--aspire-action-selected-text, var(--sl-color-accent-high));
}
@media (max-width: 768px) {
@@ -1365,7 +1483,7 @@ await builder.build().run();`,
padding: 0.75rem;
}
- .header h3 {
+ .header :is(h2, h3) {
font-size: 1.1rem;
}
@@ -1386,109 +1504,75 @@ await builder.build().run();`,
word-break: break-word;
}
}
-
-
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');
+---
+
+
+
+
+
+
+
+ {
+ options.map((option, index) => (
+
+ ))
+ }
+
+
+
+
+
+
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) =>
}
+
+
+
+