Migrate roadmap data to Astro content collections

Replace inline arrays with a `roadmap` collection using glob loader
and Zod schema. Each item is a YAML file in src/content/roadmap/ with
title, description, status, date, and sortOrder fields.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-03-16 12:17:31 -04:00
parent 6f5b5daeba
commit 6c596fe027
No known key found for this signature in database
15 changed files with 220 additions and 126 deletions

View file

@ -0,0 +1,15 @@
import { defineCollection, z } from "astro:content";
import { glob } from "astro/loaders";
const roadmap = defineCollection({
loader: glob({ pattern: "**/*.yaml", base: "./src/content/roadmap" }),
schema: z.object({
title: z.string(),
description: z.string(),
status: z.enum(["shipped", "building", "next"]),
date: z.string().optional(), // e.g. "Mar 2026", required for shipped
sortOrder: z.number(), // lower = shown first within status group
}),
});
export const collections = { roadmap };

View file

@ -0,0 +1,5 @@
title: API server & web UI
description: REST API with SSE event streaming and a React dashboard for managing workflows at scale.
status: shipped
date: Feb 2026
sortOrder: 2

View file

@ -0,0 +1,5 @@
title: Automatic retrospectives
description: Each run generates a retro with cost, duration, files touched, and an LLM-written narrative.
status: shipped
date: Mar 2026
sortOrder: 1

View file

@ -0,0 +1,5 @@
title: Cloud sandboxes
description: Isolated VM execution environments with snapshot-based setup, network controls, and SSH access.
status: shipped
date: Jan 2026
sortOrder: 3

View file

@ -0,0 +1,5 @@
title: Core workflow engine
description: DOT graph parsing with branching, loops, parallel execution, and conditional routing.
status: shipped
date: May 2025
sortOrder: 7

View file

@ -0,0 +1,4 @@
title: Custom tool plugins
description: Extend agent capabilities with your own tool definitions. Package, share, and version tools independently of workflows.
status: next
sortOrder: 2

View file

@ -0,0 +1,5 @@
title: Git checkpointing & resume
description: Every stage commits to a Git branch with full metadata. Revert or resume from any checkpoint.
status: shipped
date: Nov 2025
sortOrder: 4

View file

@ -0,0 +1,5 @@
title: Human-in-the-loop
description: Approval gates pause for human decisions. Steer running agents mid-turn. Interview steps collect input.
status: shipped
date: Sep 2025
sortOrder: 5

View file

@ -0,0 +1,4 @@
title: Metrics & cost dashboards
description: Real-time cost tracking, success rates, and performance trends across all runs. Set budgets, alerts, and optimization targets.
status: next
sortOrder: 3

View file

@ -0,0 +1,5 @@
title: Multi-model routing
description: CSS-like model stylesheets assign providers per stage. Automatic fallback chains across providers.
status: shipped
date: Jul 2025
sortOrder: 6

View file

@ -0,0 +1,4 @@
title: Parallel execution scaling
description: Dynamic fan-out with resource-aware scheduling. Run dozens of sandboxes concurrently with automatic load balancing and cost caps.
status: building
sortOrder: 2

View file

@ -0,0 +1,4 @@
title: Self-hosted cloud
description: Deploy Fabro as a managed service in your own infrastructure. Kubernetes-native with horizontal scaling and multi-tenant isolation.
status: next
sortOrder: 4

View file

@ -0,0 +1,4 @@
title: Team collaboration
description: Shared workflow runs with role-based access. Assign reviewers, track ownership, and coordinate across teams.
status: next
sortOrder: 1

View file

@ -0,0 +1,4 @@
title: Workflow marketplace
description: Browse, fork, and publish community workflows. One-command install with dependency resolution and version pinning.
status: building
sortOrder: 1

View file

@ -1,124 +1,35 @@
---
import Layout from "../layouts/Layout.astro";
import { getCollection } from "astro:content";
interface RoadmapItem {
title: string;
description: string;
}
const allItems = await getCollection("roadmap");
const byStatus = (status: string) =>
allItems
.filter((item) => item.data.status === status)
.sort((a, b) => a.data.sortOrder - b.data.sortOrder);
interface RoadmapSection {
label: string;
status: "shipped" | "building" | "next";
items: RoadmapItem[];
}
const shipped = byStatus("shipped");
const SHIPPED_VISIBLE = 3;
const shippedVisible = shipped.slice(0, SHIPPED_VISIBLE);
const shippedHidden = shipped.slice(SHIPPED_VISIBLE);
const sections: RoadmapSection[] = [
{
label: "Shipped",
status: "shipped",
items: [
{
title: "Core workflow engine",
description:
"DOT graph parsing with branching, loops, parallel execution, and conditional routing. Deterministic, diffable, version-controlled.",
},
{
title: "Multi-model routing",
description:
"CSS-like model stylesheets assign providers per stage by ID, class, or shape. Automatic fallback chains across Anthropic, OpenAI, and Gemini.",
},
{
title: "Human-in-the-loop",
description:
"Hexagon-shaped approval gates pause for human decisions. Steer running agents mid-turn. Interview steps collect structured input.",
},
{
title: "Git checkpointing & resume",
description:
"Every stage commits to a Git branch with full metadata. Revert, resume from any checkpoint, or trace every change back to the stage that made it.",
},
{
title: "Cloud sandboxes",
description:
"Isolated VM execution environments with snapshot-based setup, network controls, and automatic cleanup. SSH in with fabro ssh.",
},
{
title: "API server & web UI",
description:
"REST API with SSE event streaming and a React dashboard. Queue runs, track progress, and manage workflows at scale.",
},
{
title: "Automatic retrospectives",
description:
"Each run generates a retro with cost, duration, files touched, and an LLM-written narrative. Workflows improve over time.",
},
],
},
{
label: "Building",
status: "building",
items: [
{
title: "Workflow marketplace",
description:
"Browse, fork, and publish community workflows. One-command install with dependency resolution and version pinning.",
},
{
title: "Parallel execution scaling",
description:
"Dynamic fan-out with resource-aware scheduling. Run dozens of sandboxes concurrently with automatic load balancing and cost caps.",
},
],
},
{
label: "Next",
status: "next",
items: [
{
title: "Team collaboration",
description:
"Shared workflow runs with role-based access. Assign reviewers, track ownership, and coordinate across teams.",
},
{
title: "Custom tool plugins",
description:
"Extend agent capabilities with your own tool definitions. Package, share, and version tools independently of workflows.",
},
{
title: "Metrics & cost dashboards",
description:
"Real-time cost tracking, success rates, and performance trends across all runs. Set budgets, alerts, and optimization targets.",
},
{
title: "Self-hosted cloud",
description:
"Deploy Fabro as a managed service in your own infrastructure. Kubernetes-native with horizontal scaling and multi-tenant isolation.",
},
],
},
const sections = [
{ label: "Building", status: "building" as const, items: byStatus("building") },
{ label: "Next", status: "next" as const, items: byStatus("next") },
];
const statusConfig = {
shipped: {
dot: "bg-mint",
border: "via-mint/30",
label: "text-mint",
track: "bg-mint/20",
icon: "mint",
},
building: {
dot: "bg-amber",
border: "via-amber/30",
label: "text-amber",
track: "bg-amber/20",
icon: "amber",
},
next: {
dot: "bg-teal-500",
border: "via-teal-500/30",
label: "text-teal-500",
track: "bg-teal-500/20",
icon: "teal",
},
};
---
@ -127,24 +38,51 @@ const statusConfig = {
<!-- Nav -->
<nav class="fixed top-0 z-50 w-full border-b border-navy-800/60 bg-navy-950/70 backdrop-blur-xl">
<div class="mx-auto flex max-w-6xl items-center justify-between px-6 py-4">
<div class="flex items-center gap-8">
<a href="/" class="flex items-center">
<img src="/logotype.svg" alt="Fabro" class="h-7" />
</a>
<a href="/" class="flex items-center">
<img src="/logotype.svg" alt="Fabro" class="h-7" />
</a>
<div class="hidden md:flex items-center gap-8">
<a href="https://docs.fabro.sh" class="text-sm font-medium text-ice-100 hover:text-ice-50 transition-colors">Docs</a>
<a href="https://docs.fabro.sh/changelog" class="text-sm font-medium text-ice-100 hover:text-ice-50 transition-colors">Changelog</a>
<a href="/roadmap" class="text-sm font-medium text-ice-50 transition-colors">Roadmap</a>
</div>
<div class="flex items-center gap-5">
<a href="/discord" class="text-ice-300 hover:text-ice-50 transition-colors" aria-label="Discord">
<a href="/discord" class="hidden md:block text-ice-300 hover:text-ice-50 transition-colors" aria-label="Discord">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
<a href="https://github.com/fabro-sh/fabro" class="text-ice-300 hover:text-ice-50 transition-colors" aria-label="GitHub">
<a href="https://github.com/fabro-sh/fabro" class="hidden md:block text-ice-300 hover:text-ice-50 transition-colors" aria-label="GitHub">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</a>
<a
href="https://docs.fabro.sh/getting-started/quick-start"
class="rounded-lg bg-teal-700 px-4 py-2 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-[0_0_20px_-4px_rgba(53,127,158,0.5)]"
class="hidden md:inline-flex rounded-lg bg-teal-700 px-4 py-2 text-sm font-semibold text-white transition-all hover:bg-teal-500 hover:shadow-[0_0_20px_-4px_rgba(53,127,158,0.5)]"
>
Get started
</a>
<button id="mobile-menu-btn" class="md:hidden text-ice-300 hover:text-ice-50 transition-colors" aria-label="Open menu">
<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" />
</svg>
</button>
</div>
</div>
<div id="mobile-menu" class="md:hidden hidden border-t border-navy-800/60 bg-navy-950/95 backdrop-blur-xl">
<div class="px-6 py-5 space-y-4">
<a href="https://docs.fabro.sh" class="block text-sm font-medium text-ice-100 hover:text-ice-50 transition-colors">Docs</a>
<a href="https://docs.fabro.sh/changelog" class="block text-sm font-medium text-ice-100 hover:text-ice-50 transition-colors">Changelog</a>
<a href="/roadmap" class="block text-sm font-medium text-ice-50 transition-colors">Roadmap</a>
<hr class="border-navy-800/60" />
<div class="flex items-center gap-5">
<a href="/discord" class="text-ice-300 hover:text-ice-50 transition-colors" aria-label="Discord">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028c.462-.63.874-1.295 1.226-1.994a.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.095 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/></svg>
</a>
<a href="https://github.com/fabro-sh/fabro" class="text-ice-300 hover:text-ice-50 transition-colors" aria-label="GitHub">
<svg class="h-5 w-5" fill="currentColor" viewBox="0 0 24 24"><path d="M12 0C5.37 0 0 5.37 0 12c0 5.31 3.435 9.795 8.205 11.385.6.105.825-.255.825-.57 0-.285-.015-1.23-.015-2.235-3.015.555-3.795-.735-4.035-1.41-.135-.345-.72-1.41-1.23-1.695-.42-.225-1.02-.78-.015-.795.945-.015 1.62.87 1.845 1.23 1.08 1.815 2.805 1.305 3.495.99.105-.78.42-1.305.765-1.605-2.67-.3-5.46-1.335-5.46-5.925 0-1.305.465-2.385 1.23-3.225-.12-.3-.54-1.53.12-3.18 0 0 1.005-.315 3.3 1.23.96-.27 1.98-.405 3-.405s2.04.135 3 .405c2.295-1.56 3.3-1.23 3.3-1.23.66 1.65.24 2.88.12 3.18.765.84 1.23 1.905 1.23 3.225 0 4.605-2.805 5.625-5.475 5.925.435.375.81 1.095.81 2.22 0 1.605-.015 2.895-.015 3.3 0 .315.225.69.825.57A12.02 12.02 0 0024 12c0-6.63-5.37-12-12-12z"/></svg>
</a>
</div>
<a
href="https://docs.fabro.sh/getting-started/quick-start"
class="block w-full text-center rounded-lg bg-teal-700 px-4 py-2.5 text-sm font-semibold text-white transition-all hover:bg-teal-500"
>
Get started
</a>
@ -175,21 +113,83 @@ const statusConfig = {
</div>
</section>
<!-- Roadmap timeline -->
<!-- Shipped — compact rows, recent 3 visible -->
<section class="relative pb-16">
<div class="mx-auto max-w-3xl px-6">
<div class="reveal flex items-center gap-4">
<div class="flex items-center gap-3 rounded-full border border-navy-800 bg-navy-900/80 px-5 py-2">
<svg class="h-4 w-4 text-mint" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<polyline points="20,6 9,17 4,12" />
</svg>
<span class="font-display text-sm font-semibold uppercase tracking-wider text-mint">Shipped</span>
</div>
<div class="h-px flex-1 bg-gradient-to-r from-navy-800 to-transparent" />
</div>
<div class="mt-6 space-y-0">
{shippedVisible.map((item, i) => (
<div class={`reveal reveal-d${i + 1} flex items-baseline gap-4 py-3 border-b border-navy-800/40`}>
<span class="shrink-0 w-20 text-xs font-mono text-mint/60">{item.data.date}</span>
<span class="font-display text-sm font-semibold text-ice-50">{item.data.title}</span>
<span class="text-sm text-ice-300/60 hidden sm:inline">&mdash; {item.data.description}</span>
</div>
))}
</div>
{shippedHidden.length > 0 && (
<div class="reveal reveal-d4">
<button
id="shipped-toggle"
class="group mt-4 flex items-center gap-2 text-sm text-ice-300/50 hover:text-mint transition-colors cursor-pointer"
aria-expanded="false"
aria-controls="shipped-older"
>
<svg
id="shipped-chevron"
class="h-4 w-4 transition-transform duration-300"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
viewBox="0 0 24 24"
>
<polyline points="6,9 12,15 18,9" />
</svg>
<span id="shipped-toggle-label">{shippedHidden.length} older features</span>
</button>
<div
id="shipped-older"
class="grid overflow-hidden transition-[grid-template-rows] duration-500 ease-[cubic-bezier(0.22,1,0.36,1)]"
style="grid-template-rows: 0fr;"
>
<div class="min-h-0">
<div class="space-y-0">
{shippedHidden.map((item) => (
<div class="flex items-baseline gap-4 py-3 border-b border-navy-800/40">
<span class="shrink-0 w-20 text-xs font-mono text-mint/60">{item.data.date}</span>
<span class="font-display text-sm font-semibold text-ice-50">{item.data.title}</span>
<span class="text-sm text-ice-300/60 hidden sm:inline">&mdash; {item.data.description}</span>
</div>
))}
</div>
</div>
</div>
</div>
)}
</div>
</section>
<!-- Building & Next — expanded timeline -->
<section class="relative pb-32">
<div class="mx-auto max-w-3xl px-6">
{sections.map((section, sectionIdx) => {
const config = statusConfig[section.status];
return (
<div class="relative">
{/* Section header */}
<div class={`reveal flex items-center gap-4 ${sectionIdx > 0 ? "mt-20" : ""}`}>
<div class={`flex items-center gap-3 rounded-full border border-navy-800 bg-navy-900/80 px-5 py-2`}>
{section.status === "shipped" && (
<svg class="h-4 w-4 text-mint" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24">
<polyline points="20,6 9,17 4,12" />
</svg>
)}
<div class={`reveal flex items-center gap-4 ${sectionIdx > 0 ? "mt-16" : ""}`}>
<div class="flex items-center gap-3 rounded-full border border-navy-800 bg-navy-900/80 px-5 py-2">
{section.status === "building" && (
<div class="h-3 w-3 rounded-full bg-amber animate-pulse-glow" />
)}
@ -206,24 +206,18 @@ const statusConfig = {
<div class="h-px flex-1 bg-gradient-to-r from-navy-800 to-transparent" />
</div>
{/* Timeline track */}
<div class="relative mt-8 ml-5">
{/* Vertical line */}
<div class={`absolute top-0 bottom-0 left-0 w-px ${config.track}`} />
<div class="space-y-0">
{section.items.map((item, itemIdx) => (
<div class={`reveal reveal-d${Math.min(itemIdx + 1, 5)} relative pl-10 pb-8 group`}>
{/* Timeline dot */}
<div class="absolute left-0 top-1.5 -translate-x-1/2 z-10">
<div class={`h-2.5 w-2.5 rounded-full ${config.dot} ring-4 ring-navy-950 transition-all group-hover:scale-125`} />
</div>
{/* Card */}
<div class="glow-card rounded-xl border border-navy-800 bg-navy-800/20 p-6 relative overflow-hidden">
<div class={`absolute top-0 left-0 right-0 h-px bg-gradient-to-r from-transparent ${config.border} to-transparent`} />
<h3 class="font-display text-base font-semibold text-ice-50">{item.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-ice-300">{item.description}</p>
<h3 class="font-display text-base font-semibold text-ice-50">{item.data.title}</h3>
<p class="mt-2 text-sm leading-relaxed text-ice-300">{item.data.description}</p>
</div>
</div>
))}
@ -300,6 +294,18 @@ const statusConfig = {
</footer>
<script>
// Mobile menu toggle
const mobileMenuBtn = document.getElementById("mobile-menu-btn")!;
const mobileMenu = document.getElementById("mobile-menu")!;
mobileMenuBtn.addEventListener("click", () => {
const isOpen = !mobileMenu.classList.contains("hidden");
mobileMenu.classList.toggle("hidden");
mobileMenuBtn.setAttribute("aria-label", isOpen ? "Open menu" : "Close menu");
mobileMenuBtn.innerHTML = isOpen
? '<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><line x1="3" y1="6" x2="21" y2="6" /><line x1="3" y1="12" x2="21" y2="12" /><line x1="3" y1="18" x2="21" y2="18" /></svg>'
: '<svg class="h-6 w-6" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" viewBox="0 0 24 24"><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>';
});
// Scroll reveal
const observer = new IntersectionObserver(
(entries) => {
@ -313,5 +319,19 @@ const statusConfig = {
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
document.querySelectorAll(".reveal, .reveal-left, .reveal-right, .reveal-scale").forEach((el) => observer.observe(el));
// Shipped older items toggle
const toggle = document.getElementById("shipped-toggle");
const older = document.getElementById("shipped-older");
const chevron = document.getElementById("shipped-chevron");
if (toggle && older && chevron) {
toggle.addEventListener("click", () => {
const isExpanded = toggle.getAttribute("aria-expanded") === "true";
toggle.setAttribute("aria-expanded", String(!isExpanded));
older.style.gridTemplateRows = isExpanded ? "0fr" : "1fr";
chevron.style.transform = isExpanded ? "" : "rotate(180deg)";
});
}
</script>
</Layout>