From e3087ac1baa05b00aed376751950af59f98057c5 Mon Sep 17 00:00:00 2001
From: kang-heewon
Date: Sat, 11 Jul 2026 23:46:50 +0900
Subject: [PATCH 1/5] feat: generate Astryx UI profiles for Vite apps
---
.changeset/astryx-vite-ui-profile.md | 7 +
.github/workflows/ci.yml | 1 +
README.md | 26 +--
croco.arch.json | 3 +-
docs/package-catalog.json | 18 +-
docs/package-docs-report.md | 10 +-
packages/create-croco-app/README.md | 23 +++
packages/create-croco-app/src/cli-program.ts | 1 +
packages/create-croco-app/src/generator.ts | 15 +-
packages/create-croco-app/src/goals.ts | 2 +
.../src/helpers/croco-ranges.ts | 1 +
.../create-croco-app/src/installers/index.ts | 1 +
.../src/installers/ui-profile.ts | 119 +++++++++++++
packages/create-croco-app/src/options.ts | 35 ++++
packages/create-croco-app/src/prompts.ts | 50 +++++-
.../create-croco-app/src/supported-options.ts | 2 +
.../src/tests/e2e-vite-spa.spec.ts | 78 +++++++++
.../src/tests/options.spec.ts | 67 ++++++++
.../src/tests/prompts.spec.ts | 40 +++++
packages/create-croco-app/src/types.ts | 1 +
.../addons/ui-astryx-vite-spa/src/App.tsx.hbs | 17 ++
.../addons/ui-astryx-vite-spa/src/main.tsx | 18 ++
.../src/presentation-smoke.tsx | 14 ++
packages/docs/astro.config.mjs | 1 +
packages/docs/package.json | 1 +
.../type-aliases/GeneratedRuntimeProfile.md | 8 +
.../GeneratedUiProfileMaturity.md | 8 +
.../GeneratedUiProfileMetadata.md | 48 ++++++
.../type-aliases/GeneratedUiProfileName.md | 8 +
.../type-aliases/GeneratedUiStyleEngine.md | 8 +
.../AstryxProblemRecoveryAction.md | 60 +++++++
.../src/type-aliases/AstryxRecoveryAction.md | 42 +++++
.../src/type-aliases/AstryxSession.md | 32 ++++
.../src/type-aliases/AstryxSessionState.md | 8 +
.../docs/en/reference/extension-matrix.md | 19 ++-
.../reference/presentation-runtime-support.md | 18 +-
packages/docs/tsconfig.typedoc.json | 1 +
packages/presentation-preset/README.md | 17 +-
.../presentation-preset/runtime-profiles.json | 39 +++++
.../output-contract-validator.spec.ts | 65 +++++++
packages/presentation-preset/src/index.ts | 4 +
.../src/output-contract-validator.ts | 103 ++++++++++++
.../src/output-contract.ts | 21 +++
packages/ui-astryx/README.md | 82 +++++++++
packages/ui-astryx/package.json | 64 +++++++
packages/ui-astryx/src/index.ts | 14 ++
.../ui-astryx/src/libs/AstryxAppShell.tsx | 39 +++++
.../ui-astryx/src/libs/AstryxAuthState.tsx | 142 ++++++++++++++++
.../ui-astryx/src/libs/AstryxProblemView.tsx | 79 +++++++++
.../ui-astryx/src/libs/AstryxProvider.tsx | 25 +++
packages/ui-astryx/src/libs/crocoUiTypes.ts | 48 ++++++
packages/ui-astryx/src/tests/AstryxUi.spec.ts | 95 +++++++++++
packages/ui-astryx/styles.css | 3 +
packages/ui-astryx/tsconfig.json | 13 ++
pnpm-lock.yaml | 103 ++++++++++++
public-api-surface.snapshot.json | 104 ++++++++++++
...reate-croco-app-generated-smoke-matrix.mts | 9 +
scripts/create-croco-app-generated-smoke.mts | 159 +++++++++++++++++-
.../static-misuse-raw-error-allowlist.json | 2 +-
.../create-croco-app-generated-smoke.spec.ts | 55 +++++-
tsconfig/contract-strict.baseline.json | 78 ++++-----
61 files changed, 2084 insertions(+), 90 deletions(-)
create mode 100644 .changeset/astryx-vite-ui-profile.md
create mode 100644 packages/create-croco-app/src/installers/ui-profile.ts
create mode 100644 packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/App.tsx.hbs
create mode 100644 packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/main.tsx
create mode 100644 packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/presentation-smoke.tsx
create mode 100644 packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMaturity.md
create mode 100644 packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMetadata.md
create mode 100644 packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileName.md
create mode 100644 packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiStyleEngine.md
create mode 100644 packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxProblemRecoveryAction.md
create mode 100644 packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxRecoveryAction.md
create mode 100644 packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSession.md
create mode 100644 packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSessionState.md
create mode 100644 packages/ui-astryx/README.md
create mode 100644 packages/ui-astryx/package.json
create mode 100644 packages/ui-astryx/src/index.ts
create mode 100644 packages/ui-astryx/src/libs/AstryxAppShell.tsx
create mode 100644 packages/ui-astryx/src/libs/AstryxAuthState.tsx
create mode 100644 packages/ui-astryx/src/libs/AstryxProblemView.tsx
create mode 100644 packages/ui-astryx/src/libs/AstryxProvider.tsx
create mode 100644 packages/ui-astryx/src/libs/crocoUiTypes.ts
create mode 100644 packages/ui-astryx/src/tests/AstryxUi.spec.ts
create mode 100644 packages/ui-astryx/styles.css
create mode 100644 packages/ui-astryx/tsconfig.json
diff --git a/.changeset/astryx-vite-ui-profile.md b/.changeset/astryx-vite-ui-profile.md
new file mode 100644
index 000000000..8c91d7709
--- /dev/null
+++ b/.changeset/astryx-vite-ui-profile.md
@@ -0,0 +1,7 @@
+---
+"@croco/presentation-preset": patch
+"@croco/ui-astryx": minor
+"create-croco-app": minor
+---
+
+Generate an opt-in Astryx Vite SPA profile with Croco-aware theme, layout, Problem, and auth states, explicit UI metadata, isolated dependencies, and install/typecheck/build/render smoke evidence.
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 821b9a137..145c8d3fb 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -471,6 +471,7 @@ jobs:
- 'packages/triggers-qstash/src/**'
- 'packages/tx-core/src/**'
- 'packages/tx-drizzle/src/**'
+ - 'packages/ui-astryx/src/**'
- 'packages/webhooks-core/src/**'
- 'packages/workflow-core/src/**'
- 'packages/docs/astro.config.mjs'
diff --git a/README.md b/README.md
index 52cf98fc0..1e92955d6 100644
--- a/README.md
+++ b/README.md
@@ -382,7 +382,7 @@ Follow-up work is tracked in GitHub Issues and in [Croco 1.0 Spine](docs/release
> 이 섹션은 `pnpm docs:catalog:write`로 생성됩니다. 패키지 이름과 경로는 `packages/*/package.json`에서 읽고, 그룹/성숙도는 `docs/package-catalog.json`에서 관리합니다.
-현재 카탈로그는 **110개 public package**를 추적합니다. Private package 2개는 publish 카탈로그에서 제외됩니다. 문서 커버리지 상세는 [docs/package-docs-report.md](docs/package-docs-report.md)를 확인하세요.
+현재 카탈로그는 **111개 public package**를 추적합니다. Private package 2개는 publish 카탈로그에서 제외됩니다. 문서 커버리지 상세는 [docs/package-docs-report.md](docs/package-docs-report.md)를 확인하세요.
### Croco 1.0 Spine
@@ -432,7 +432,7 @@ Current 1.0 spine status: 18 spine packages; 10 production-ready, 8 beta, 0 alph
| Integration | Analytics, feature-flag, and observability integrations | 5 |
| Protocol | API protocol definitions and code generation | 7 |
| Transport | Runtime adapters that execute protocol routes | 3 |
-| Presentation | Frontend, SSR, and presentation-layer adapters | 7 |
+| Presentation | Frontend, SSR, and presentation-layer adapters | 8 |
| Tooling | CLIs, scaffolds, presets, migration tools, and build-time helpers | 9 |
### Maturity Guide
@@ -442,7 +442,7 @@ Adapter 경계와 공식 우선순위, compatibility certification checklist는
| 상태 | 의미 | 전체 public 패키지 수 |
| ------------------- | ----------------------------------- | --------------------: |
| 🟢 production-ready | 안정화, 적극 사용 권장 | 24 |
-| 🟡 beta | 기능 완성, 실사용 검증 중 | 74 |
+| 🟡 beta | 기능 완성, 실사용 검증 중 | 75 |
| 🔴 alpha/WIP | 개발 중, 사용 시 주의 필요 | 12 |
| ⚠️ deprecated | 대체 패키지 존재, 마이그레이션 권장 | 0 |
@@ -507,15 +507,16 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수
#### Presentation
-| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests | Certification |
-| ---------------------------- | ------------------- | ----------------------------------- | ---- | ------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------- | ---------------------------------------------------------------------------- |
-| `@croco/admin-react` | Admin React | Billing and tenant admin primitives | yes | - | - | yes | none | react
react-dom | billing panel contract
contract-aware DataTable
entitlement status primitives
pagination and search adapters
usage quota meters
provider failure state
tenant switcher
impersonation banner
permission inspector
Problem-preserving console failures | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-problems` | Frontend Problems | Problem-aware client runtime | - | - | yes | yes | none | - | Problem Details parsing
Problem-aware fetch results
declared Problem unions
form Problem mapping | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration
browser hydration smoke
page data hydration flow
generated meta-vite fullstack smoke
auth gate primitives
tenant and entitlement bridge | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config
Worker-safe IsrCacheStore required for durable Workers ISR | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR v1 exact-key TTL
Node/Lambda durable ISR smoke
Workers ISR boundary smoke
generated page/API/action/ISR smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | Worker SSR request handling
service binding API routing
ASSETS fallback
streaming Response preservation
RuntimeContext env propagation
generated Worker smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility
optional Cloudflare peer diagnostics
SPA browser build smoke
meta-vite generated build smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support
output contract validation | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests | Certification |
+| ---------------------------- | ------------------- | --------------------------------------------- | ---- | ------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------- | ---------------------------------------------------------------------------- |
+| `@croco/admin-react` | Admin React | Billing and tenant admin primitives | yes | - | - | yes | none | react
react-dom | billing panel contract
contract-aware DataTable
entitlement status primitives
pagination and search adapters
usage quota meters
provider failure state
tenant switcher
impersonation banner
permission inspector
Problem-preserving console failures | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/ui-astryx` | Astryx UI | Croco-aware Astryx React presentation adapter | yes | - | - | yes | none | react
react-dom | Astryx neutral theme provider
application shell
Problem recovery display
auth and session states
prebuilt StyleX CSS consumer path
generated Vite SPA smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-problems` | Frontend Problems | Problem-aware client runtime | - | - | yes | yes | none | - | Problem Details parsing
Problem-aware fetch results
declared Problem unions
form Problem mapping | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration
browser hydration smoke
page data hydration flow
generated meta-vite fullstack smoke
auth gate primitives
tenant and entitlement bridge | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config
Worker-safe IsrCacheStore required for durable Workers ISR | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR v1 exact-key TTL
Node/Lambda durable ISR smoke
Workers ISR boundary smoke
generated page/API/action/ISR smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | Worker SSR request handling
service binding API routing
ASSETS fallback
streaming Response preservation
RuntimeContext env propagation
generated Worker smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility
optional Cloudflare peer diagnostics
SPA browser build smoke
meta-vite generated build smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support
output contract validation | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
### 🟢 production-ready
@@ -592,6 +593,7 @@ Runtime columns: Node는 장기 실행 서버/CLI, Lambda는 서버리스 함수
| `@croco/frontend-vite` | Presentation | `packages/frontend-vite` | README, API, tests |
| `@croco/meta-vite` | Presentation | `packages/meta-vite` | README, API, tests |
| `@croco/presentation-preset` | Presentation | `packages/presentation-preset` | README, API, tests |
+| `@croco/ui-astryx` | Presentation | `packages/ui-astryx` | README, API, tests |
| `@croco/openapi-spec` | Protocol | `packages/openapi-spec` | README, API, tests |
| `@croco/protocols-core` | Protocol | `packages/protocols-core` | README, API, tests |
| `@croco/protocols-graphql` | Protocol | `packages/protocols-graphql` | README, API, tests |
diff --git a/croco.arch.json b/croco.arch.json
index 598640ee2..adb6f4857 100644
--- a/croco.arch.json
+++ b/croco.arch.json
@@ -113,7 +113,8 @@
"@croco/admin-react",
"@croco/frontend-*",
"@croco/meta-vite",
- "@croco/presentation-preset"
+ "@croco/presentation-preset",
+ "@croco/ui-*"
]
},
"app": {
diff --git a/docs/package-catalog.json b/docs/package-catalog.json
index 400f6e590..2217f52f2 100644
--- a/docs/package-catalog.json
+++ b/docs/package-catalog.json
@@ -230,7 +230,8 @@
"frontend-react",
"frontend-vite",
"meta-vite",
- "presentation-preset"
+ "presentation-preset",
+ "ui-astryx"
]
},
"Tooling": {
@@ -342,6 +343,7 @@
"protocols-graphql",
"protocols-trpc",
"rpc-codegen",
+ "ui-astryx",
"search-drizzle",
"search-meilisearch",
"storage-cloudinary",
@@ -577,6 +579,20 @@
"meta-vite generated build smoke"
]
},
+ "ui-astryx": {
+ "domain": "Astryx UI",
+ "adapter": "Croco-aware Astryx React presentation adapter",
+ "runtimes": ["browser", "node"],
+ "requiredEnv": ["none"],
+ "features": [
+ "Astryx neutral theme provider",
+ "application shell",
+ "Problem recovery display",
+ "auth and session states",
+ "prebuilt StyleX CSS consumer path",
+ "generated Vite SPA smoke"
+ ]
+ },
"integrations-posthog": {
"domain": "PostHog",
"adapter": "Shared PostHog client",
diff --git a/docs/package-docs-report.md b/docs/package-docs-report.md
index 9a3d9719c..f06b994d0 100644
--- a/docs/package-docs-report.md
+++ b/docs/package-docs-report.md
@@ -6,12 +6,12 @@
| Metric | Count |
| ------------------------------ | ----: |
-| Public packages | 110 |
+| Public packages | 111 |
| Private packages skipped | 2 |
| Missing package README | 0 |
| Missing generated API docs | 0 |
| Missing package test directory | 0 |
-| Extension matrix packages | 41 |
+| Extension matrix packages | 42 |
| Certification records | 6 |
| Croco 1.0 spine packages | 18 |
@@ -111,13 +111,13 @@ None.
| Integration | 5 |
| Protocol | 7 |
| Transport | 3 |
-| Presentation | 7 |
+| Presentation | 8 |
| Tooling | 9 |
| Maturity | Packages |
| ------------------- | -------: |
| 🟢 production-ready | 24 |
-| 🟡 beta | 74 |
+| 🟡 beta | 75 |
| 🔴 alpha/WIP | 12 |
| ⚠️ deprecated | 0 |
@@ -130,4 +130,4 @@ Extension matrix metadata is maintained in `docs/package-catalog.json` and rende
| Provider | 26 | 0 |
| Integration | 5 | 0 |
| Transport | 3 | 0 |
-| Presentation | 7 | 0 |
+| Presentation | 8 | 0 |
diff --git a/packages/create-croco-app/README.md b/packages/create-croco-app/README.md
index 19fca6847..8f2bf7764 100644
--- a/packages/create-croco-app/README.md
+++ b/packages/create-croco-app/README.md
@@ -23,6 +23,29 @@ and the zero-credential `demo:smoke` success path. Generated projects are pnpm
workspaces; `--no-install --no-git` keeps the documented setup deterministic before
the explicit install and smoke commands.
+### Astryx Vite UI profile
+
+Astryx is available as an opt-in beta UI profile for the Vite SPA frontend runtime:
+
+```bash
+npx create-croco-app@latest my-app \
+ --preset ddd-fullstack \
+ --scope @myorg \
+ --api graphql \
+ --api-hosting standalone \
+ --web-apps web \
+ --frontend-deploy vite-spa \
+ --ui astryx
+```
+
+The generated app imports Astryx's prebuilt CSS, so it does not add a StyleX compiler plugin.
+Use `--ui none` for an explicit provider-neutral Vite starter. Omitting `--ui` preserves the
+generator's existing output for compatibility. Astryx does not change `@croco/frontend-react` or
+apply to meta-vite profiles in this release.
+
+Generated projects include the package manager command and next-step instructions in
+the CLI result.
+
## Verification
```bash
diff --git a/packages/create-croco-app/src/cli-program.ts b/packages/create-croco-app/src/cli-program.ts
index a10963055..607e45d66 100644
--- a/packages/create-croco-app/src/cli-program.ts
+++ b/packages/create-croco-app/src/cli-program.ts
@@ -40,6 +40,7 @@ export function configureCreateCrocoAppProgram(program: Command): Command {
"--frontend-deploy ",
"Frontend deploy (opennext|vercel|docker|cloudflare-meta-vite|vite-spa)",
)
+ .option("--ui ", "UI profile (none|astryx). Astryx currently requires vite-spa")
.option("--db ", "Comma-separated DB types (postgres,mongodb,redis)")
.option("--no-agent-rules", "Skip agent rules")
.option("--no-install", "Skip pnpm dependency installation")
diff --git a/packages/create-croco-app/src/generator.ts b/packages/create-croco-app/src/generator.ts
index 6f191da49..b24d4aa6e 100644
--- a/packages/create-croco-app/src/generator.ts
+++ b/packages/create-croco-app/src/generator.ts
@@ -19,7 +19,7 @@ import {
writeFileSync,
} from "node:fs";
import { join, resolve } from "node:path";
-import { validateResolvedGoalOptions, writeGoalManifest } from "./goals.js";
+import { writeGoalManifest } from "./goals.js";
import { mergeInto } from "./helpers/fs.js";
import { rewriteExternalCrocoWorkspaceRanges } from "./helpers/manifest-normalizer.js";
import {
@@ -34,10 +34,12 @@ import {
installSharedUi,
installTrpcNextjs,
installTrpcStandalone,
+ installUiProfile,
installWebGraphql,
installWebTrpc,
} from "./installers/index.js";
import { DirectoryNotEmptyProblem } from "./libs/problems/DirectoryNotEmptyProblem.js";
+import { validateResolvedOptions } from "./options.js";
import {
DEFAULT_SAAS_PROVIDER_PROFILE,
assertSaasProviderTenantModelCompatibility,
@@ -54,7 +56,7 @@ import type { GeneratorOptions } from "./types.js";
import type { SaasProviderProfileManifest } from "./saas-provider-profiles.js";
export async function generate(targetDir: string, options: GeneratorOptions): Promise {
- validateResolvedGoalOptions(options);
+ validateResolvedOptions(options);
const vars = { projectName: options.projectName, scope: options.scope };
const isLegacyVikeFullstackPreset = options.preset === "ddd-vike-fullstack";
@@ -128,7 +130,9 @@ export async function generate(targetDir: string, options: GeneratorOptions): Pr
hasWebApps &&
(options.preset === "ddd-fullstack" || options.apiHosting === "nextjs")
) {
- installSharedUi(resolvedTarget, vars);
+ if (options.ui === undefined) {
+ installSharedUi(resolvedTarget, vars);
+ }
}
// Step 5: web addon (standalone hosting + web apps)
@@ -177,6 +181,11 @@ export async function generate(targetDir: string, options: GeneratorOptions): Pr
preset: options.preset,
frontendDeploy: options.frontendDeploy,
});
+ installUiProfile(resolvedTarget, webAppName, {
+ ...vars,
+ frontendDeploy: options.frontendDeploy,
+ ...(options.ui === undefined ? {} : { ui: options.ui }),
+ });
}
}
diff --git a/packages/create-croco-app/src/goals.ts b/packages/create-croco-app/src/goals.ts
index 519de5819..bc7f365b5 100644
--- a/packages/create-croco-app/src/goals.ts
+++ b/packages/create-croco-app/src/goals.ts
@@ -244,6 +244,7 @@ export function validateResolvedGoalOptions(options: GeneratorOptions): void {
if (options.apiHosting !== expectedOptions.apiHosting) mismatches.push("apiHosting");
if (options.backendDeploy !== expectedOptions.backendDeploy) mismatches.push("backendDeploy");
if (options.frontendDeploy !== expectedOptions.frontendDeploy) mismatches.push("frontendDeploy");
+ if (options.ui !== expectedOptions.ui) mismatches.push("ui");
if (options.tenantModel !== expectedOptions.tenantModel) mismatches.push("tenantModel");
if (!sameStringArray(options.webApps, expectedOptions.webApps)) mismatches.push("webApps");
if (!sameStringArray(options.db, expectedOptions.db)) mismatches.push("db");
@@ -265,6 +266,7 @@ function assertGoalDoesNotMixStackOptions(goal: AppGoal, options: Partial 0) unsupportedOptions.push("--web-apps");
diff --git a/packages/create-croco-app/src/helpers/croco-ranges.ts b/packages/create-croco-app/src/helpers/croco-ranges.ts
index a83656929..35c2ed9f6 100644
--- a/packages/create-croco-app/src/helpers/croco-ranges.ts
+++ b/packages/create-croco-app/src/helpers/croco-ranges.ts
@@ -53,6 +53,7 @@ const EXTERNAL_CROCO_PACKAGE_RANGES = {
"@croco/triggers-qstash": "^0.0.4",
"@croco/tx-core": "^0.0.4",
"@croco/tx-drizzle": "^0.0.4",
+ "@croco/ui-astryx": "^0.1.0",
"@croco/webhooks-core": "^0.1.0",
} as const satisfies Record;
diff --git a/packages/create-croco-app/src/installers/index.ts b/packages/create-croco-app/src/installers/index.ts
index aff54e2ca..a129a1545 100644
--- a/packages/create-croco-app/src/installers/index.ts
+++ b/packages/create-croco-app/src/installers/index.ts
@@ -9,5 +9,6 @@ export { installRedis } from "./redis.js";
export { installSharedUi } from "./shared-ui.js";
export { installTrpcNextjs } from "./trpc-nextjs.js";
export { installTrpcStandalone } from "./trpc-standalone.js";
+export { installUiProfile } from "./ui-profile.js";
export { installWebGraphql } from "./web-graphql.js";
export { installWebTrpc } from "./web-trpc.js";
diff --git a/packages/create-croco-app/src/installers/ui-profile.ts b/packages/create-croco-app/src/installers/ui-profile.ts
new file mode 100644
index 000000000..b6a83da31
--- /dev/null
+++ b/packages/create-croco-app/src/installers/ui-profile.ts
@@ -0,0 +1,119 @@
+import { existsSync, readFileSync, writeFileSync } from "node:fs";
+import { join } from "node:path";
+import { mergeInto } from "../helpers/fs.js";
+import { TEMPLATES_DIR } from "../template-path.js";
+import type { GeneratorOptions } from "../types.js";
+
+type GeneratedUiProfileMetadata = {
+ readonly name: "none" | "astryx";
+ readonly styleEngine: "none" | "stylex";
+ readonly requiresStylexCompile: boolean;
+ readonly maturity: "alpha" | "beta";
+ readonly generatedAppSmokeCase: string;
+};
+
+type GeneratedPresentationProfile = {
+ readonly webApp: string;
+ readonly runtimeProfile: "browser-vite-spa" | "browser-vite-spa-astryx";
+ readonly ui: GeneratedUiProfileMetadata;
+};
+
+type GeneratedPresentationProfileManifest = {
+ readonly schemaVersion: "croco.generated-presentation-profile/v1";
+ readonly profiles: readonly GeneratedPresentationProfile[];
+};
+
+const UI_METADATA = {
+ none: {
+ name: "none",
+ styleEngine: "none",
+ requiresStylexCompile: false,
+ maturity: "alpha",
+ generatedAppSmokeCase: "graphql-vite-spa-docker",
+ },
+ astryx: {
+ name: "astryx",
+ styleEngine: "stylex",
+ requiresStylexCompile: false,
+ maturity: "beta",
+ generatedAppSmokeCase: "graphql-vite-spa-astryx",
+ },
+} as const satisfies Record, GeneratedUiProfileMetadata>;
+
+export function installUiProfile(
+ targetDir: string,
+ webAppName: string,
+ options: Pick,
+): void {
+ if (!options.ui) return;
+ if (options.frontendDeploy !== "vite-spa") return;
+
+ const appTargetDir = join(targetDir, "apps", webAppName);
+ const metadata = UI_METADATA[options.ui];
+ const profile: GeneratedPresentationProfile = {
+ webApp: webAppName,
+ runtimeProfile: options.ui === "astryx" ? "browser-vite-spa-astryx" : "browser-vite-spa",
+ ui: metadata,
+ };
+
+ writePresentationProfileManifest(targetDir, profile);
+ writeFileSync(
+ join(appTargetDir, "croco.presentation-profile.json"),
+ `${JSON.stringify(profile, null, 2)}\n`,
+ );
+
+ if (options.ui === "none") return;
+
+ mergeInto(join(TEMPLATES_DIR, "addons", "ui-astryx-vite-spa"), appTargetDir, {
+ projectName: options.projectName,
+ scope: options.scope,
+ });
+ addAstryxDependencies(appTargetDir);
+}
+
+function writePresentationProfileManifest(
+ targetDir: string,
+ profile: GeneratedPresentationProfile,
+): void {
+ const manifestPath = join(targetDir, "croco-presentation-profile.manifest.json");
+ const existing = existsSync(manifestPath)
+ ? (JSON.parse(readFileSync(manifestPath, "utf8")) as GeneratedPresentationProfileManifest)
+ : undefined;
+ const profiles = [
+ ...(existing?.profiles ?? []).filter(({ webApp }) => webApp !== profile.webApp),
+ profile,
+ ];
+ const manifest: GeneratedPresentationProfileManifest = {
+ schemaVersion: "croco.generated-presentation-profile/v1",
+ profiles,
+ };
+
+ writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
+}
+
+function addAstryxDependencies(appTargetDir: string): void {
+ const packageJsonPath = join(appTargetDir, "package.json");
+ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as {
+ scripts?: Record;
+ dependencies?: Record;
+ devDependencies?: Record;
+ };
+
+ packageJson.scripts = {
+ ...packageJson.scripts,
+ "presentation:smoke": "tsx src/presentation-smoke.tsx",
+ };
+ packageJson.dependencies = {
+ ...packageJson.dependencies,
+ "@astryxdesign/core": "0.1.4",
+ "@astryxdesign/theme-neutral": "0.1.4",
+ "@croco/ui-astryx": "workspace:*",
+ "@stylexjs/stylex": "^0.18.3",
+ };
+ packageJson.devDependencies = {
+ ...packageJson.devDependencies,
+ tsx: "^4.20.0",
+ };
+
+ writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`);
+}
diff --git a/packages/create-croco-app/src/options.ts b/packages/create-croco-app/src/options.ts
index 7629e8590..f241e3c20 100644
--- a/packages/create-croco-app/src/options.ts
+++ b/packages/create-croco-app/src/options.ts
@@ -20,6 +20,7 @@ const APIS = SUPPORTED_CREATE_CROCO_APP_CHOICES.apis;
const API_HOSTING = SUPPORTED_CREATE_CROCO_APP_CHOICES.apiHosting;
const BACKEND_DEPLOYS = SUPPORTED_CREATE_CROCO_APP_CHOICES.backendDeploys;
const FRONTEND_DEPLOYS = SUPPORTED_CREATE_CROCO_APP_CHOICES.frontendDeploys;
+const UI_PROFILES = SUPPORTED_CREATE_CROCO_APP_CHOICES.uiProfiles;
const DATABASES = SUPPORTED_CREATE_CROCO_APP_CHOICES.databases;
const SAAS_PROVIDER_PROFILES = SUPPORTED_CREATE_CROCO_APP_CHOICES.saasProviderProfiles;
const TENANT_MODELS = SUPPORTED_CREATE_CROCO_APP_CHOICES.tenantModels;
@@ -30,6 +31,7 @@ type ChoiceName =
| "api-hosting"
| "backend-deploy"
| "frontend-deploy"
+ | "ui"
| "db"
| "saas-profile"
| "tenant-model";
@@ -74,6 +76,9 @@ export function parseCliOptions(
if (typeof rawOptions.frontendDeploy === "string") {
cliOptions.frontendDeploy = rawOptions.frontendDeploy as GeneratorOptions["frontendDeploy"];
}
+ if (typeof rawOptions["ui"] === "string") {
+ cliOptions.ui = rawOptions["ui"] as NonNullable;
+ }
if (typeof rawOptions.db === "string") {
cliOptions.db = rawOptions.db
.split(",")
@@ -126,10 +131,13 @@ export function validateCliOptions(cliOptions: Partial): void
if (cliOptions.frontendDeploy !== undefined) {
readChoice("frontend-deploy", cliOptions.frontendDeploy, FRONTEND_DEPLOYS);
}
+ if (cliOptions.ui !== undefined) readChoice("ui", cliOptions.ui, UI_PROFILES);
for (const db of cliOptions.db ?? []) {
readChoice("db", db, DATABASES);
}
+ assertUiPresetCompatibility(cliOptions);
+
if (isSaasPreset(cliOptions.preset)) {
assertSaasOptions(cliOptions, cliOptions.preset);
}
@@ -163,10 +171,13 @@ export function validateResolvedOptions(options: GeneratorOptions): void {
if (options.backendDeploy) readChoice("backend-deploy", options.backendDeploy, BACKEND_DEPLOYS);
if (options.frontendDeploy)
readChoice("frontend-deploy", options.frontendDeploy, FRONTEND_DEPLOYS);
+ if (options.ui) readChoice("ui", options.ui, UI_PROFILES);
for (const db of options.db) {
readChoice("db", db, DATABASES);
}
+ assertUiCompatibility(options);
+
if (!isSaasPreset(options.preset) && options.saasProviderProfile) {
throwInvalidCliOption(
"--saas-profile is only supported with the saas and ai-saas presets",
@@ -369,6 +380,7 @@ export function normalizeNonInteractiveOptions(
apiHosting,
backendDeploy,
frontendDeploy,
+ ...(cliOptions.ui === undefined ? {} : { ui: cliOptions.ui }),
saasProviderProfile: cliOptions.saasProviderProfile,
tenantModel: cliOptions.tenantModel,
db,
@@ -401,6 +413,7 @@ function assertBlankOptions(cliOptions: Partial): void {
if (cliOptions.apiHosting) throwUnsupportedPresetOption("--api-hosting", "blank");
if (cliOptions.backendDeploy) throwUnsupportedPresetOption("--backend-deploy", "blank");
if (cliOptions.frontendDeploy) throwUnsupportedPresetOption("--frontend-deploy", "blank");
+ if (cliOptions.ui) throwUnsupportedPresetOption("--ui", "blank");
if (cliOptions.webApps && cliOptions.webApps.length > 0) {
throwUnsupportedPresetOption("--web-apps", "blank");
}
@@ -568,6 +581,28 @@ function normalizeFrontendDeploy(
return frontendDeploy;
}
+export function assertUiCompatibility(options: Partial): void {
+ if (!options.ui) return;
+
+ if (options.frontendDeploy !== "vite-spa") {
+ throwInvalidCliOption(
+ "--ui is currently only supported with --frontend-deploy vite-spa",
+ "Use --frontend-deploy vite-spa, or remove --ui until another presentation runtime is supported.",
+ "--ui",
+ );
+ }
+}
+
+export function assertUiPresetCompatibility(options: Partial): void {
+ if (!options.ui || options.preset === undefined || options.preset === "ddd-fullstack") return;
+
+ throwInvalidCliOption(
+ "--ui is currently only supported with --preset ddd-fullstack",
+ "Use --preset ddd-fullstack with --frontend-deploy vite-spa, or remove --ui.",
+ "--ui",
+ );
+}
+
function requireOption(value: T | undefined, message: string): T {
if (value === undefined || value === "") {
throwInvalidCliOption(message, recoveryForRequiredOption(message), optionFromMessage(message));
diff --git a/packages/create-croco-app/src/prompts.ts b/packages/create-croco-app/src/prompts.ts
index 9ebd77445..0b719fcc7 100644
--- a/packages/create-croco-app/src/prompts.ts
+++ b/packages/create-croco-app/src/prompts.ts
@@ -6,6 +6,7 @@ import {
} from "@croco/tenant-core/tenant-model";
import pc from "picocolors";
import { GOAL_SPECS, readGoal, resolveGoalOptions } from "./goals.js";
+import { assertUiCompatibility, assertUiPresetCompatibility } from "./options.js";
import {
DEFAULT_SAAS_PROVIDER_PROFILE,
SAAS_PROVIDER_PROFILE_CHOICES,
@@ -153,6 +154,13 @@ export async function runPrompts(cliArgs: Partial): Promise): Promise): Promise): Promise;
+ }
+
+ if (cliArgs.ui !== undefined) {
+ assertUiCompatibility({ ...(frontendDeploy ? { frontendDeploy } : {}), ui: cliArgs.ui });
+ }
+
+ // 9. UI profile (Vite SPA only)
+ let ui: GeneratorOptions["ui"];
+ if (frontendDeploy === "vite-spa") {
+ const uiChoice =
+ cliArgs.ui ??
+ (await p.select({
+ message: "UI profile:",
+ initialValue: "none",
+ options: [
+ { value: "none", label: "None", hint: "Provider-neutral React starter" },
+ {
+ value: "astryx",
+ label: "Astryx",
+ hint: "Beta StyleX design-system profile",
+ },
+ ],
+ }));
+ if (p.isCancel(uiChoice)) {
+ p.cancel("Operation cancelled");
+ process.exit(0);
+ }
+ ui = uiChoice as NonNullable;
}
- // 9. db
+ // 10. db
const db =
cliArgs.db && cliArgs.db.length > 0
? cliArgs.db
@@ -435,6 +478,7 @@ export async function runPrompts(cliArgs: Partial): Promise[];
readonly frontendDeploys: readonly NonNullable[];
+ readonly uiProfiles: readonly NonNullable[];
readonly databases: readonly GeneratorOptions["db"][number][];
};
diff --git a/packages/create-croco-app/src/tests/e2e-vite-spa.spec.ts b/packages/create-croco-app/src/tests/e2e-vite-spa.spec.ts
index df62f6763..d434ddc8b 100644
--- a/packages/create-croco-app/src/tests/e2e-vite-spa.spec.ts
+++ b/packages/create-croco-app/src/tests/e2e-vite-spa.spec.ts
@@ -55,6 +55,84 @@ describe("E2E Vite SPA: generate()", () => {
},
);
+ it("generates isolated none and Astryx UI profiles", { timeout: 120_000 }, async () => {
+ const noneDir = `${testDir}-none`;
+ const astryxDir = `${testDir}-astryx`;
+ const baseOptions: GeneratorOptions = {
+ projectName: "my-vite-ui",
+ scope: "@test",
+ preset: "ddd-fullstack",
+ webApps: ["web"],
+ api: "graphql",
+ apiHosting: "standalone",
+ frontendDeploy: "vite-spa",
+ db: [],
+ agentRules: false,
+ installDeps: false,
+ initGit: false,
+ };
+
+ try {
+ await generate(noneDir, { ...baseOptions, ui: "none" });
+ await generate(astryxDir, { ...baseOptions, ui: "astryx" });
+
+ const nonePackage = readFileSync(join(noneDir, "apps", "web", "package.json"), "utf8");
+ const astryxPackage = readFileSync(join(astryxDir, "apps", "web", "package.json"), "utf8");
+ const noneManifest = JSON.parse(
+ readFileSync(join(noneDir, "croco-presentation-profile.manifest.json"), "utf8"),
+ ) as { profiles: [{ ui: { name: string; requiresStylexCompile: boolean } }] };
+ const astryxManifest = JSON.parse(
+ readFileSync(join(astryxDir, "croco-presentation-profile.manifest.json"), "utf8"),
+ ) as { profiles: [{ runtimeProfile: string; ui: { name: string; maturity: string } }] };
+
+ expect(noneManifest.profiles[0].ui).toEqual(
+ expect.objectContaining({ name: "none", requiresStylexCompile: false }),
+ );
+ expect(nonePackage).not.toContain("astryx");
+ expect(nonePackage).not.toContain("stylex");
+ expect(existsSync(join(noneDir, "libs", "shared", "ui"))).toBe(false);
+
+ expect(astryxManifest.profiles[0]).toEqual(
+ expect.objectContaining({
+ runtimeProfile: "browser-vite-spa-astryx",
+ ui: expect.objectContaining({ name: "astryx", maturity: "beta" }),
+ }),
+ );
+ expect(astryxPackage).toContain('"@croco/ui-astryx": "^0.1.0"');
+ expect(astryxPackage).toContain('"@astryxdesign/core": "0.1.4"');
+ expect(astryxPackage).toContain('"@stylexjs/stylex": "^0.18.3"');
+ expect(existsSync(join(astryxDir, "libs", "shared", "ui"))).toBe(false);
+ expect(existsSync(join(astryxDir, "apps", "web", "src", "presentation-smoke.tsx"))).toBe(
+ true,
+ );
+ } finally {
+ rmSync(noneDir, { recursive: true, force: true });
+ rmSync(astryxDir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects incompatible programmatic UI generation before writing files", async () => {
+ const options: GeneratorOptions = {
+ projectName: "invalid-astryx-runtime",
+ scope: "@test",
+ preset: "ddd-fullstack",
+ webApps: ["web"],
+ api: "graphql",
+ apiHosting: "standalone",
+ frontendDeploy: "cloudflare-meta-vite",
+ ui: "astryx",
+ db: [],
+ agentRules: false,
+ installDeps: false,
+ initGit: false,
+ };
+
+ await expect(generate(testDir, options)).rejects.toThrow(
+ "--ui is currently only supported with --frontend-deploy vite-spa",
+ );
+ expect(existsSync(testDir)).toBe(false);
+ });
+
it("generates vite spa docker file with api build artifacts", { timeout: 120_000 }, async () => {
const options: GeneratorOptions = {
projectName: "my-vite-spa-docker",
diff --git a/packages/create-croco-app/src/tests/options.spec.ts b/packages/create-croco-app/src/tests/options.spec.ts
index 56cedc7cc..01c479d71 100644
--- a/packages/create-croco-app/src/tests/options.spec.ts
+++ b/packages/create-croco-app/src/tests/options.spec.ts
@@ -38,6 +38,8 @@ describe("noninteractive CLI option validation", () => {
expect(help).toContain("saas-node-postgres|saas-cloudflare|saas-lambda");
expect(help).toContain("--tenant-model");
expect(help).toContain("single|org|workspace|shared-schema|rls-backed");
+ expect(help).toContain("--ui ");
+ expect(help).toContain("none|astryx");
expect(help).toContain("--no-install");
expect(help).toContain("Skip pnpm dependency installation");
expect(help).toContain("--json");
@@ -173,6 +175,71 @@ describe("noninteractive CLI option validation", () => {
);
});
+ it("normalizes an explicit Astryx profile for Vite SPA generation", () => {
+ const options = normalizeNonInteractiveOptions(
+ parseCliOptions("my-astryx-spa", {
+ preset: "ddd-fullstack",
+ scope: "@test",
+ api: "graphql",
+ apiHosting: "standalone",
+ webApps: "web",
+ frontendDeploy: "vite-spa",
+ ui: "astryx",
+ install: false,
+ git: false,
+ }),
+ );
+
+ expect(options.ui).toBe("astryx");
+ expect(options.frontendDeploy).toBe("vite-spa");
+ });
+
+ it("rejects UI profiles outside the Vite SPA presentation runtime", () => {
+ expect(() =>
+ normalizeNonInteractiveOptions(
+ parseCliOptions("my-next-app", {
+ preset: "ddd-fullstack",
+ scope: "@test",
+ api: "graphql",
+ apiHosting: "nextjs",
+ webApps: "web",
+ frontendDeploy: "vercel",
+ ui: "astryx",
+ install: false,
+ git: false,
+ }),
+ ),
+ ).toThrow("--ui is currently only supported with --frontend-deploy vite-spa");
+ });
+
+ it.each(["saas", "ai-saas", "production-app", "admin-console"] as const)(
+ "rejects --ui for the %s preset before preset-specific normalization",
+ (preset) => {
+ expect(() =>
+ normalizeNonInteractiveOptions(
+ parseCliOptions("my-app", {
+ preset,
+ scope: "@test",
+ ui: "astryx",
+ install: false,
+ git: false,
+ }),
+ ),
+ ).toThrow("--ui is currently only supported with --preset ddd-fullstack");
+ },
+ );
+
+ it("rejects goal-first requests mixed with a UI profile", () => {
+ expect(() =>
+ validateCliOptions(
+ parseCliOptions(undefined, {
+ goal: "worker",
+ ui: "none",
+ }),
+ ),
+ ).toThrow("--ui cannot be combined with --goal worker");
+ });
+
it("normalizes SaaS tenant model defaults and explicit choices", () => {
const defaultOptions = normalizeNonInteractiveOptions(
parseCliOptions("my-saas", {
diff --git a/packages/create-croco-app/src/tests/prompts.spec.ts b/packages/create-croco-app/src/tests/prompts.spec.ts
index be94fd42d..e16e410a6 100644
--- a/packages/create-croco-app/src/tests/prompts.spec.ts
+++ b/packages/create-croco-app/src/tests/prompts.spec.ts
@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
+import { runPrompts } from "../prompts.js";
import type { GeneratorOptions } from "../types.js";
describe("GeneratorOptions type", () => {
@@ -49,4 +50,43 @@ describe("GeneratorOptions type", () => {
};
expect(opts.frontendDeploy).toBe("vite-spa");
});
+
+ it("should accept an explicit Astryx UI profile", () => {
+ const opts: GeneratorOptions = {
+ projectName: "astryx-spa",
+ scope: "@myorg",
+ preset: "ddd-fullstack",
+ webApps: ["web"],
+ api: "graphql",
+ apiHosting: "standalone",
+ frontendDeploy: "vite-spa",
+ ui: "astryx",
+ db: [],
+ agentRules: false,
+ installDeps: true,
+ initGit: true,
+ };
+
+ expect(opts.ui).toBe("astryx");
+ });
+
+ it("should reject an explicit UI profile for an incompatible interactive runtime", async () => {
+ await expect(
+ runPrompts({
+ projectName: "astryx-worker",
+ scope: "@myorg",
+ preset: "ddd-fullstack",
+ webApps: ["web"],
+ api: "graphql",
+ apiHosting: "standalone",
+ backendDeploy: "lambda",
+ frontendDeploy: "cloudflare-meta-vite",
+ ui: "astryx",
+ db: [],
+ agentRules: false,
+ installDeps: false,
+ initGit: false,
+ }),
+ ).rejects.toThrow("--ui is currently only supported with --frontend-deploy vite-spa");
+ });
});
diff --git a/packages/create-croco-app/src/types.ts b/packages/create-croco-app/src/types.ts
index b47465831..6e8e45839 100644
--- a/packages/create-croco-app/src/types.ts
+++ b/packages/create-croco-app/src/types.ts
@@ -20,6 +20,7 @@ export type GeneratorOptions = {
apiHosting: "standalone" | "nextjs";
backendDeploy?: "docker" | "lambda";
frontendDeploy?: "opennext" | "vercel" | "docker" | "cloudflare-meta-vite" | "vite-spa";
+ ui?: "none" | "astryx";
saasProviderProfile?: "saas-node-postgres" | "saas-cloudflare" | "saas-lambda";
tenantModel?: TenantModelName;
db: ("postgres" | "mongodb" | "redis")[];
diff --git a/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/App.tsx.hbs b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/App.tsx.hbs
new file mode 100644
index 000000000..244b3825c
--- /dev/null
+++ b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/App.tsx.hbs
@@ -0,0 +1,17 @@
+import { AstryxAppShell, AstryxAuthState, AstryxProvider } from '@croco/ui-astryx';
+
+const projectName = '{{projectName}}';
+
+export default function App() {
+ return (
+
+
+
+ {projectName}
+ A Croco Vite application using the beta Astryx presentation profile.
+
+
+
+
+ );
+}
diff --git a/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/main.tsx b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/main.tsx
new file mode 100644
index 000000000..ed0d880bb
--- /dev/null
+++ b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/main.tsx
@@ -0,0 +1,18 @@
+import "@astryxdesign/core/reset.css";
+import "@astryxdesign/core/astryx.css";
+import "@astryxdesign/theme-neutral/theme.css";
+import React from "react";
+import ReactDOM from "react-dom/client";
+import App from "./App";
+
+const rootElement = document.getElementById("root");
+
+if (!rootElement) {
+ throw new Error("Root element not found");
+}
+
+ReactDOM.createRoot(rootElement).render(
+
+
+ ,
+);
diff --git a/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/presentation-smoke.tsx b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/presentation-smoke.tsx
new file mode 100644
index 000000000..931d6747b
--- /dev/null
+++ b/packages/create-croco-app/templates/addons/ui-astryx-vite-spa/src/presentation-smoke.tsx
@@ -0,0 +1,14 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import App from "./App";
+
+const markup = renderToStaticMarkup();
+
+if (!markup.includes('data-croco-ui-profile="astryx"')) {
+ throw new Error("Astryx presentation smoke did not render the Croco UI profile marker");
+}
+
+if (!markup.includes('data-croco-auth-state="signed-out"')) {
+ throw new Error("Astryx presentation smoke did not render the signed-out recovery state");
+}
+
+console.log("Astryx presentation smoke passed");
diff --git a/packages/docs/astro.config.mjs b/packages/docs/astro.config.mjs
index b6b4cc825..3ba01840a 100644
--- a/packages/docs/astro.config.mjs
+++ b/packages/docs/astro.config.mjs
@@ -144,6 +144,7 @@ export default defineConfig({
"../triggers-qstash/src/index.ts",
"../tx-core/src/index.ts",
"../tx-drizzle/src/index.ts",
+ "../ui-astryx/src/index.ts",
"../webhooks-core/src/index.ts",
"../workflow-core/src/index.ts",
],
diff --git a/packages/docs/package.json b/packages/docs/package.json
index 56aca7897..05db7a086 100644
--- a/packages/docs/package.json
+++ b/packages/docs/package.json
@@ -130,6 +130,7 @@
"@croco/triggers-qstash": "workspace:*",
"@croco/tx-core": "workspace:*",
"@croco/tx-drizzle": "workspace:*",
+ "@croco/ui-astryx": "workspace:*",
"@croco/webhooks-core": "workspace:*",
"@croco/workflow-core": "workspace:*",
"@playwright/test": "1.58.2",
diff --git a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedRuntimeProfile.md b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedRuntimeProfile.md
index ec807334b..f8289fdc4 100644
--- a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedRuntimeProfile.md
+++ b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedRuntimeProfile.md
@@ -54,3 +54,11 @@ Runtime claim this generated profile proves for the package catalog
> `readonly` **target**: [`DeployTarget`](/api/presentation-preset/src/type-aliases/deploytarget/)
Runtime target metadata and expected output contract for the profile
+
+***
+
+### ui?
+
+> `readonly` `optional` **ui?**: [`GeneratedUiProfileMetadata`](/api/presentation-preset/src/type-aliases/generateduiprofilemetadata/)
+
+Optional generated UI profile evidence for presentation-aware applications
diff --git a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMaturity.md b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMaturity.md
new file mode 100644
index 000000000..f7ddcb9db
--- /dev/null
+++ b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMaturity.md
@@ -0,0 +1,8 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "GeneratedUiProfileMaturity"
+---
+
+> **GeneratedUiProfileMaturity** = `"alpha"` \| `"beta"`
diff --git a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMetadata.md b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMetadata.md
new file mode 100644
index 000000000..f1dc3f92d
--- /dev/null
+++ b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileMetadata.md
@@ -0,0 +1,48 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "GeneratedUiProfileMetadata"
+---
+
+> **GeneratedUiProfileMetadata** = `object`
+
+## Properties
+
+### generatedAppSmokeCase
+
+> `readonly` **generatedAppSmokeCase**: `string`
+
+Generated app smoke case that proves this UI profile
+
+***
+
+### maturity
+
+> `readonly` **maturity**: [`GeneratedUiProfileMaturity`](/api/presentation-preset/src/type-aliases/generateduiprofilematurity/)
+
+Current support maturity of the generated UI profile
+
+***
+
+### name
+
+> `readonly` **name**: [`GeneratedUiProfileName`](/api/presentation-preset/src/type-aliases/generateduiprofilename/)
+
+UI profile selected by the generator
+
+***
+
+### requiresStylexCompile
+
+> `readonly` **requiresStylexCompile**: `boolean`
+
+Whether the generated application must compile StyleX source
+
+***
+
+### styleEngine
+
+> `readonly` **styleEngine**: [`GeneratedUiStyleEngine`](/api/presentation-preset/src/type-aliases/generateduistyleengine/)
+
+Styling engine used by the generated profile
diff --git a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileName.md b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileName.md
new file mode 100644
index 000000000..2979d25c8
--- /dev/null
+++ b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiProfileName.md
@@ -0,0 +1,8 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "GeneratedUiProfileName"
+---
+
+> **GeneratedUiProfileName** = `"none"` \| `"astryx"`
diff --git a/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiStyleEngine.md b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiStyleEngine.md
new file mode 100644
index 000000000..ea4cf5a58
--- /dev/null
+++ b/packages/docs/src/content/docs/api/presentation-preset/src/type-aliases/GeneratedUiStyleEngine.md
@@ -0,0 +1,8 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "GeneratedUiStyleEngine"
+---
+
+> **GeneratedUiStyleEngine** = `"none"` \| `"stylex"`
diff --git a/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxProblemRecoveryAction.md b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxProblemRecoveryAction.md
new file mode 100644
index 000000000..11a7720ae
--- /dev/null
+++ b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxProblemRecoveryAction.md
@@ -0,0 +1,60 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "AstryxProblemRecoveryAction"
+---
+
+> **AstryxProblemRecoveryAction** = `object`
+
+## Properties
+
+### ariaLabel?
+
+> `readonly` `optional` **ariaLabel?**: `string`
+
+***
+
+### disabled?
+
+> `readonly` `optional` **disabled?**: `boolean`
+
+***
+
+### href?
+
+> `readonly` `optional` **href?**: `string`
+
+***
+
+### id
+
+> `readonly` **id**: `string`
+
+***
+
+### label
+
+> `readonly` **label**: `string`
+
+***
+
+### onRecover?
+
+> `readonly` `optional` **onRecover?**: (`problem`) => `void` \| `Promise`\<`void`\>
+
+#### Parameters
+
+##### problem
+
+[`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/)
+
+#### Returns
+
+`void` \| `Promise`\<`void`\>
+
+***
+
+### problemCodes?
+
+> `readonly` `optional` **problemCodes?**: readonly `string`[]
diff --git a/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxRecoveryAction.md b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxRecoveryAction.md
new file mode 100644
index 000000000..0af24058e
--- /dev/null
+++ b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxRecoveryAction.md
@@ -0,0 +1,42 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "AstryxRecoveryAction"
+---
+
+> **AstryxRecoveryAction** = `object`
+
+## Properties
+
+### href?
+
+> `readonly` `optional` **href?**: `string`
+
+***
+
+### id
+
+> `readonly` **id**: `string`
+
+***
+
+### label
+
+> `readonly` **label**: `string`
+
+***
+
+### onRecover?
+
+> `readonly` `optional` **onRecover?**: () => `void` \| `Promise`\<`void`\>
+
+#### Returns
+
+`void` \| `Promise`\<`void`\>
+
+***
+
+### problemCodes?
+
+> `readonly` `optional` **problemCodes?**: readonly `string`[]
diff --git a/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSession.md b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSession.md
new file mode 100644
index 000000000..5ad361bae
--- /dev/null
+++ b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSession.md
@@ -0,0 +1,32 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "AstryxSession"
+---
+
+> **AstryxSession** = `object`
+
+## Properties
+
+### provider?
+
+> `readonly` `optional` **provider?**: `string`
+
+***
+
+### user
+
+> `readonly` **user**: `object`
+
+#### email?
+
+> `readonly` `optional` **email?**: `string`
+
+#### label?
+
+> `readonly` `optional` **label?**: `string`
+
+#### userId
+
+> `readonly` **userId**: `string`
diff --git a/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSessionState.md b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSessionState.md
new file mode 100644
index 000000000..651fea855
--- /dev/null
+++ b/packages/docs/src/content/docs/api/ui-astryx/src/type-aliases/AstryxSessionState.md
@@ -0,0 +1,8 @@
+---
+editUrl: false
+next: false
+prev: false
+title: "AstryxSessionState"
+---
+
+> **AstryxSessionState** = \{ `kind`: `"loading"`; `recoveryActions?`: readonly [`AstryxRecoveryAction`](/api/ui-astryx/src/type-aliases/astryxrecoveryaction/)[]; \} \| \{ `kind`: `"authenticated"`; `session`: [`AstryxSession`](/api/ui-astryx/src/type-aliases/astryxsession/); \} \| \{ `kind`: `"unauthenticated"`; `problem?`: [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/); `recoveryActions?`: readonly [`AstryxRecoveryAction`](/api/ui-astryx/src/type-aliases/astryxrecoveryaction/)[]; \} \| \{ `kind`: `"unavailable"`; `problem`: [`ProblemDetails`](/api/problems-core/src/type-aliases/problemdetails/); `recoveryActions?`: readonly [`AstryxRecoveryAction`](/api/ui-astryx/src/type-aliases/astryxrecoveryaction/)[]; \}
diff --git a/packages/docs/src/content/docs/en/reference/extension-matrix.md b/packages/docs/src/content/docs/en/reference/extension-matrix.md
index accd6ff1a..c5e1b5fd6 100644
--- a/packages/docs/src/content/docs/en/reference/extension-matrix.md
+++ b/packages/docs/src/content/docs/en/reference/extension-matrix.md
@@ -68,12 +68,13 @@ Runtime columns: Node covers long-running server and CLI use, Lambda covers serv
## Presentation
-| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests | Certification |
-| ---------------------------- | ------------------- | ----------------------------------- | ---- | ------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------- | ---------------------------------------------------------------------------- |
-| `@croco/admin-react` | Admin React | Billing and tenant admin primitives | yes | - | - | yes | none | react
react-dom | billing panel contract
contract-aware DataTable
entitlement status primitives
pagination and search adapters
usage quota meters
provider failure state
tenant switcher
impersonation banner
permission inspector
Problem-preserving console failures | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-problems` | Frontend Problems | Problem-aware client runtime | - | - | yes | yes | none | - | Problem Details parsing
Problem-aware fetch results
declared Problem unions
form Problem mapping | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration
browser hydration smoke
page data hydration flow
generated meta-vite fullstack smoke
auth gate primitives
tenant and entitlement bridge | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config
Worker-safe IsrCacheStore required for durable Workers ISR | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR v1 exact-key TTL
Node/Lambda durable ISR smoke
Workers ISR boundary smoke
generated page/API/action/ISR smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | Worker SSR request handling
service binding API routing
ASSETS fallback
streaming Response preservation
RuntimeContext env propagation
generated Worker smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility
optional Cloudflare peer diagnostics
SPA browser build smoke
meta-vite generated build smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
-| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support
output contract validation | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| Package | Domain | Adapter | Node | Lambda | Workers | Frontend | Required env/config | Peer deps | Features | Maturity | Package tests | Certification |
+| ---------------------------- | ------------------- | --------------------------------------------- | ---- | ------ | ------- | -------- | ---------------------------------------------------------------------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------- | ---------------------------------------------------------------------------- |
+| `@croco/admin-react` | Admin React | Billing and tenant admin primitives | yes | - | - | yes | none | react
react-dom | billing panel contract
contract-aware DataTable
entitlement status primitives
pagination and search adapters
usage quota meters
provider failure state
tenant switcher
impersonation banner
permission inspector
Problem-preserving console failures | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/ui-astryx` | Astryx UI | Croco-aware Astryx React presentation adapter | yes | - | - | yes | none | react
react-dom | Astryx neutral theme provider
application shell
Problem recovery display
auth and session states
prebuilt StyleX CSS consumer path
generated Vite SPA smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-problems` | Frontend Problems | Problem-aware client runtime | - | - | yes | yes | none | - | Problem Details parsing
Problem-aware fetch results
declared Problem unions
form Problem mapping | 🔴 alpha/WIP | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-react` | Frontend React | React integration helpers | yes | - | - | yes | none | @croco/meta-vite
react
react-dom | React bindings
meta-vite integration
browser hydration smoke
page data hydration flow
generated meta-vite fullstack smoke
auth gate primitives
tenant and entitlement bridge | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/meta-vite` | Frontend routing | Meta Vite runtime | yes | yes | yes | yes | optional Redis-compatible ISR adapter config
Worker-safe IsrCacheStore required for durable Workers ISR | ioredis
react
react-dom
vite
zod | route registry
server actions
SSR/RSC streaming
ISR v1 exact-key TTL
Node/Lambda durable ISR smoke
Workers ISR boundary smoke
generated page/API/action/ISR smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-cloudflare` | Frontend SSR | Cloudflare SSR handler | - | - | yes | - | API_WORKER binding optional
ASSETS binding optional | - | Worker SSR request handling
service binding API routing
ASSETS fallback
streaming Response preservation
RuntimeContext env propagation
generated Worker smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/frontend-vite` | Frontend Vite | Vite integration helpers | yes | - | yes | yes | none | @cloudflare/vite-plugin
vite | Vite config helpers
Cloudflare Vite compatibility
optional Cloudflare peer diagnostics
SPA browser build smoke
meta-vite generated build smoke | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
+| `@croco/presentation-preset` | Presentation preset | Backend/frontend preset composition | yes | yes | yes | yes | none | - | preset composition
contract wiring
generated app support
output contract validation | 🟡 beta | has package tests | not-applicable
not required until production-ready or compatibility claim |
diff --git a/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md b/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md
index 064a3d008..79a44305f 100644
--- a/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md
+++ b/packages/docs/src/content/docs/en/reference/presentation-runtime-support.md
@@ -75,12 +75,18 @@ in the relevant package README, package tests, generated-app smoke, and this pag
profile's target metadata, output entries, artifacts, contract format, generated smoke case, and
the runtime claims currently listed in `docs/package-catalog.json`.
-| Profile | Runtime | Generated smoke case |
-| ------------------- | -------------------- | ----------------------------- |
-| `node-server` | `node` | `production-app-starter` |
-| `lambda-function` | `lambda` | `graphql-lambda-api` |
-| `cloudflare-worker` | `cloudflare-workers` | `meta-vite-fullstack-workers` |
-| `browser-vite-spa` | `browser` | `graphql-vite-spa-docker` |
+| Profile | Runtime | Generated smoke case |
+| ------------------------- | -------------------- | ----------------------------- |
+| `node-server` | `node` | `production-app-starter` |
+| `lambda-function` | `lambda` | `graphql-lambda-api` |
+| `cloudflare-worker` | `cloudflare-workers` | `meta-vite-fullstack-workers` |
+| `browser-vite-spa` | `browser` | `graphql-vite-spa-docker` |
+| `browser-vite-spa-astryx` | `browser` | `graphql-vite-spa-astryx` |
+
+`browser-vite-spa-astryx` is a beta opt-in profile generated with `--ui astryx`. It uses Astryx's
+prebuilt CSS exports and records `requiresStylexCompile: false`; no Vite StyleX plugin is required.
+Use `--ui none` for the explicit provider-neutral profile. The Astryx profile does not apply to
+meta-vite hydration in this release.
Verification commands:
diff --git a/packages/docs/tsconfig.typedoc.json b/packages/docs/tsconfig.typedoc.json
index 4486c7bc3..8e7504040 100644
--- a/packages/docs/tsconfig.typedoc.json
+++ b/packages/docs/tsconfig.typedoc.json
@@ -119,6 +119,7 @@
"../triggers-qstash/src/index.ts",
"../tx-core/src/index.ts",
"../tx-drizzle/src/index.ts",
+ "../ui-astryx/src/index.ts",
"../webhooks-core/src/index.ts",
"../workflow-core/src/index.ts"
]
diff --git a/packages/presentation-preset/README.md b/packages/presentation-preset/README.md
index 012c9e131..785da078d 100644
--- a/packages/presentation-preset/README.md
+++ b/packages/presentation-preset/README.md
@@ -27,12 +27,17 @@ The source of truth is `runtime-profiles.json`. Each profile names the runtime c
metadata, output artifacts, entry descriptors, package test evidence, and generated-app smoke
case that proves the claim.
-| Profile | Catalog runtime | Generated smoke evidence |
-| ------------------- | -------------------- | ------------------------------------------------------------------------------------- |
-| `node-server` | `node` | `CROCO_GENERATED_SMOKE_CASES=production-app-starter pnpm create-croco-app:smoke` |
-| `lambda-function` | `lambda` | `CROCO_GENERATED_SMOKE_CASES=graphql-lambda-api pnpm create-croco-app:smoke` |
-| `cloudflare-worker` | `cloudflare-workers` | `CROCO_GENERATED_SMOKE_CASES=meta-vite-fullstack-workers pnpm create-croco-app:smoke` |
-| `browser-vite-spa` | `browser` | `CROCO_GENERATED_SMOKE_CASES=graphql-vite-spa-docker pnpm create-croco-app:smoke` |
+| Profile | Catalog runtime | Generated smoke evidence |
+| ------------------------- | -------------------- | ------------------------------------------------------------------------------------- |
+| `node-server` | `node` | `CROCO_GENERATED_SMOKE_CASES=production-app-starter pnpm create-croco-app:smoke` |
+| `lambda-function` | `lambda` | `CROCO_GENERATED_SMOKE_CASES=graphql-lambda-api pnpm create-croco-app:smoke` |
+| `cloudflare-worker` | `cloudflare-workers` | `CROCO_GENERATED_SMOKE_CASES=meta-vite-fullstack-workers pnpm create-croco-app:smoke` |
+| `browser-vite-spa` | `browser` | `CROCO_GENERATED_SMOKE_CASES=graphql-vite-spa-docker pnpm create-croco-app:smoke` |
+| `browser-vite-spa-astryx` | `browser` | `CROCO_GENERATED_SMOKE_CASES=graphql-vite-spa-astryx pnpm create-croco-app:smoke` |
+
+The Astryx profile uses Astryx's prebuilt CSS exports, so generated Vite applications do not need
+a StyleX compiler plugin. It remains a beta opt-in profile; the provider-neutral Vite profile is
+still available through `--ui none`, and omitted `--ui` values retain the legacy generator output.
## Verification
diff --git a/packages/presentation-preset/runtime-profiles.json b/packages/presentation-preset/runtime-profiles.json
index ab3284fe7..bb5b0ae9e 100644
--- a/packages/presentation-preset/runtime-profiles.json
+++ b/packages/presentation-preset/runtime-profiles.json
@@ -127,6 +127,45 @@
]
}
}
+ },
+ {
+ "name": "browser-vite-spa-astryx",
+ "runtime": "browser",
+ "packageTestName": "validates the browser-vite-spa-astryx generated runtime profile",
+ "generatedAppSmokeCase": "graphql-vite-spa-astryx",
+ "generatedAppSmokeCommand": "CROCO_GENERATED_SMOKE_CASES=graphql-vite-spa-astryx pnpm create-croco-app:smoke",
+ "ui": {
+ "name": "astryx",
+ "styleEngine": "stylex",
+ "requiresStylexCompile": false,
+ "maturity": "beta",
+ "generatedAppSmokeCase": "graphql-vite-spa-astryx"
+ },
+ "target": {
+ "target": "browser",
+ "requiredEnvVars": [],
+ "output": {
+ "presetName": "presentation-preset/browser-vite-spa-astryx",
+ "buildTime": "2026-01-01T00:00:00.000Z",
+ "format": "neutral",
+ "artifacts": [
+ { "path": "apps/web/dist/index.html", "format": "neutral", "type": "asset" },
+ { "path": "apps/web/src/vite-env.d.ts", "format": "neutral", "type": "types" },
+ {
+ "path": "apps/web/croco.presentation-profile.json",
+ "format": "neutral",
+ "type": "config"
+ }
+ ],
+ "entries": [
+ {
+ "exportName": "./browser",
+ "main": "apps/web/dist/index.html",
+ "types": "apps/web/src/vite-env.d.ts"
+ }
+ ]
+ }
+ }
}
]
}
diff --git a/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts b/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts
index 04f0ebcfc..35438dd73 100644
--- a/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts
+++ b/packages/presentation-preset/src/__tests__/output-contract-validator.spec.ts
@@ -224,6 +224,71 @@ describe("Generated runtime profile catalog", () => {
).toBe(true);
});
+ it("accepts optional Astryx UI profile evidence without requiring StyleX compilation", () => {
+ const [profile] = profileCatalog.profiles;
+
+ const report = validator.validateGeneratedRuntimeProfile({
+ ...profile,
+ ui: {
+ name: "astryx",
+ styleEngine: "stylex",
+ requiresStylexCompile: false,
+ maturity: "beta",
+ generatedAppSmokeCase: profile.generatedAppSmokeCase,
+ },
+ });
+
+ expect(report.passed).toBe(true);
+ });
+
+ it("preserves generated runtime profiles that omit UI metadata", () => {
+ const [profile] = profileCatalog.profiles;
+ const report = validator.validateGeneratedRuntimeProfile(profile);
+
+ expect(profile.ui).toBeUndefined();
+ expect(report.passed).toBe(true);
+ });
+
+ it("fails when UI metadata does not match its profile and smoke evidence", () => {
+ const [profile] = profileCatalog.profiles;
+
+ const report = validator.validateGeneratedRuntimeProfile({
+ ...profile,
+ ui: {
+ name: "astryx",
+ styleEngine: "none",
+ requiresStylexCompile: true,
+ maturity: "stable",
+ generatedAppSmokeCase: "different-smoke-case",
+ },
+ } as unknown as GeneratedRuntimeProfile);
+
+ expect(report.passed).toBe(false);
+ expect(report.results.map((result) => result.message)).toEqual(
+ expect.arrayContaining([
+ `Generated runtime profile '${profile.name}' UI profile 'astryx' must declare the StyleX engine`,
+ `Generated runtime profile '${profile.name}' cannot require StyleX compilation without a style engine`,
+ `Generated runtime profile '${profile.name}' has unsupported UI maturity 'stable'`,
+ `Generated runtime profile '${profile.name}' UI smoke case must match '${profile.generatedAppSmokeCase}'`,
+ ]),
+ );
+ });
+
+ it("fails without throwing when UI metadata is not an object", () => {
+ const [profile] = profileCatalog.profiles;
+ const report = validator.validateGeneratedRuntimeProfile({
+ ...profile,
+ ui: "astryx",
+ } as unknown as GeneratedRuntimeProfile);
+
+ expect(report.passed).toBe(false);
+ expect(report.results).toContainEqual({
+ path: `profile:${profile.name}:ui`,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI metadata must be an object`,
+ });
+ });
+
it("fails when runtime target env metadata is not a string array", () => {
const [profile] = profileCatalog.profiles;
const report = validator.validateGeneratedRuntimeProfile({
diff --git a/packages/presentation-preset/src/index.ts b/packages/presentation-preset/src/index.ts
index d090ea5bd..81e10fd6c 100644
--- a/packages/presentation-preset/src/index.ts
+++ b/packages/presentation-preset/src/index.ts
@@ -6,6 +6,10 @@ export type {
EntryDescriptor,
GeneratedRuntimeProfile,
GeneratedRuntimeProfileCatalog,
+ GeneratedUiProfileMaturity,
+ GeneratedUiProfileMetadata,
+ GeneratedUiProfileName,
+ GeneratedUiStyleEngine,
OutputContract,
PresentationRuntime,
} from "./output-contract";
diff --git a/packages/presentation-preset/src/output-contract-validator.ts b/packages/presentation-preset/src/output-contract-validator.ts
index dd7ee6013..4fe79e9a1 100644
--- a/packages/presentation-preset/src/output-contract-validator.ts
+++ b/packages/presentation-preset/src/output-contract-validator.ts
@@ -29,6 +29,9 @@ export type RuntimeClaimValidationOptions = {
const ARTIFACT_FORMATS = new Set(["esm", "cjs", "dual", "neutral"]);
const ARTIFACT_TYPES = new Set(["code", "types", "config", "asset"]);
const PRESENTATION_RUNTIMES = new Set(["node", "lambda", "cloudflare-workers", "browser"]);
+const GENERATED_UI_PROFILE_NAMES = new Set(["none", "astryx"]);
+const GENERATED_UI_STYLE_ENGINES = new Set(["none", "stylex"]);
+const GENERATED_UI_PROFILE_MATURITIES = new Set(["alpha", "beta"]);
export class OutputContractValidator {
validate(contract: OutputContract): ValidationReport {
@@ -460,6 +463,8 @@ export class OutputContractValidator {
});
}
+ this.validateGeneratedUiProfileMetadata(profile, results);
+
if (!isRecord(profile.target)) {
results.push({
path,
@@ -478,6 +483,104 @@ export class OutputContractValidator {
}
this.validateDeployTargetShape(profile.target, results);
}
+
+ private validateGeneratedUiProfileMetadata(
+ profile: GeneratedRuntimeProfile,
+ results: ValidationResult[],
+ ): void {
+ if (profile.ui === undefined) {
+ return;
+ }
+
+ const path = isNonEmptyString(profile.name)
+ ? `profile:${profile.name}:ui`
+ : "profile::ui";
+ if (!isRecord(profile.ui)) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI metadata must be an object`,
+ });
+ return;
+ }
+
+ if (!isNonEmptyString(profile.ui.name) || !GENERATED_UI_PROFILE_NAMES.has(profile.ui.name)) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' has unsupported UI profile '${profile.ui.name}'`,
+ });
+ }
+ if (
+ !isNonEmptyString(profile.ui.styleEngine) ||
+ !GENERATED_UI_STYLE_ENGINES.has(profile.ui.styleEngine)
+ ) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' has unsupported UI style engine '${profile.ui.styleEngine}'`,
+ });
+ }
+ if (typeof profile.ui.requiresStylexCompile !== "boolean") {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI metadata must declare whether StyleX compilation is required`,
+ });
+ }
+ if (
+ !isNonEmptyString(profile.ui.maturity) ||
+ !GENERATED_UI_PROFILE_MATURITIES.has(profile.ui.maturity)
+ ) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' has unsupported UI maturity '${profile.ui.maturity}'`,
+ });
+ }
+ if (!isNonEmptyString(profile.ui.generatedAppSmokeCase)) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI metadata must name its generated app smoke case`,
+ });
+ } else if (profile.ui.generatedAppSmokeCase !== profile.generatedAppSmokeCase) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI smoke case must match '${profile.generatedAppSmokeCase}'`,
+ });
+ }
+
+ if (profile.ui.name === "none" && profile.ui.styleEngine !== "none") {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI profile 'none' cannot declare a style engine`,
+ });
+ }
+ if (profile.ui.name === "none" && profile.ui.requiresStylexCompile !== false) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI profile 'none' cannot require StyleX compilation`,
+ });
+ }
+ if (profile.ui.name === "astryx" && profile.ui.styleEngine !== "stylex") {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' UI profile 'astryx' must declare the StyleX engine`,
+ });
+ }
+ if (profile.ui.styleEngine === "none" && profile.ui.requiresStylexCompile === true) {
+ results.push({
+ path,
+ severity: "error",
+ message: `Generated runtime profile '${profile.name}' cannot require StyleX compilation without a style engine`,
+ });
+ }
+ }
}
function isArtifactFormat(value: string): boolean {
diff --git a/packages/presentation-preset/src/output-contract.ts b/packages/presentation-preset/src/output-contract.ts
index 083568d14..a72179236 100644
--- a/packages/presentation-preset/src/output-contract.ts
+++ b/packages/presentation-preset/src/output-contract.ts
@@ -84,6 +84,25 @@ export type DeployTarget = {
export type PresentationRuntime = "node" | "lambda" | "cloudflare-workers" | "browser";
+export type GeneratedUiProfileName = "none" | "astryx";
+
+export type GeneratedUiStyleEngine = "none" | "stylex";
+
+export type GeneratedUiProfileMaturity = "alpha" | "beta";
+
+export type GeneratedUiProfileMetadata = {
+ /** UI profile selected by the generator */
+ readonly name: GeneratedUiProfileName;
+ /** Styling engine used by the generated profile */
+ readonly styleEngine: GeneratedUiStyleEngine;
+ /** Whether the generated application must compile StyleX source */
+ readonly requiresStylexCompile: boolean;
+ /** Current support maturity of the generated UI profile */
+ readonly maturity: GeneratedUiProfileMaturity;
+ /** Generated app smoke case that proves this UI profile */
+ readonly generatedAppSmokeCase: string;
+};
+
export type GeneratedRuntimeProfile = {
/** Stable generated profile name used in tests and docs */
readonly name: string;
@@ -95,6 +114,8 @@ export type GeneratedRuntimeProfile = {
readonly generatedAppSmokeCase: string;
/** Focused command for re-running the generated smoke evidence */
readonly generatedAppSmokeCommand: string;
+ /** Optional generated UI profile evidence for presentation-aware applications */
+ readonly ui?: GeneratedUiProfileMetadata;
/** Runtime target metadata and expected output contract for the profile */
readonly target: DeployTarget;
};
diff --git a/packages/ui-astryx/README.md b/packages/ui-astryx/README.md
new file mode 100644
index 000000000..47784ffa8
--- /dev/null
+++ b/packages/ui-astryx/README.md
@@ -0,0 +1,82 @@
+# @croco/ui-astryx
+
+`@croco/ui-astryx` is the Astryx UI profile adapter for Croco React applications. It keeps
+`@croco/frontend-react` provider-neutral while giving generated applications a typed Astryx theme,
+application shell, Problem Details view, and session-state presentation.
+
+Astryx `0.1.4` is beta software. This package intentionally exposes a small adapter surface and does
+not claim that every Croco presentation path is covered.
+
+## Install
+
+```bash
+pnpm add @croco/ui-astryx react react-dom
+```
+
+Import the packaged prebuilt CSS once, before rendering the application:
+
+```tsx
+import "@croco/ui-astryx/styles.css";
+```
+
+That stylesheet preserves Astryx's required cascade order:
+
+1. `@astryxdesign/core/reset.css`
+2. `@astryxdesign/core/astryx.css`
+3. `@astryxdesign/theme-neutral/theme.css`
+
+No StyleX compiler, Vite plugin, Babel plugin, or PostCSS plugin is required for this consumer path.
+
+## Generated application shell
+
+```tsx
+import { AstryxAppShell, AstryxAuthState, AstryxProvider } from "@croco/ui-astryx";
+
+export function App() {
+ return (
+
+
+
+
+
+ );
+}
+```
+
+`AstryxProvider` uses Astryx's neutral theme by default and accepts `system`, `light`, or `dark` mode.
+`AstryxAppShell` accepts React nodes for top and side navigation instead of imposing a router.
+
+## Croco Problem Details
+
+```tsx
+import type { ProblemDetails } from "@croco/problems-core";
+import { AstryxProblemView } from "@croco/ui-astryx";
+
+declare function refetch(): Promise;
+
+export function Failure({ problem }: { problem: ProblemDetails }) {
+ return (
+ refetch() }]}
+ />
+ );
+}
+```
+
+The view preserves RFC 7807 `type`, `title`, `status`, `detail`, `instance`, and Croco's stable
+`code`. Recovery actions can be restricted to specific Problem codes.
+
+## Session contracts
+
+Use `toAstryxAuthStateProps` to map the provider-neutral `FrontendSessionState` from
+`@croco/frontend-react` into the generated UI's explicit state model:
+
+```tsx
+const sessionState = useAuthBridgeState().session;
+
+return ;
+```
+
+The mapping keeps `loading`, `signed-in`, `signed-out`, and provider `unavailable` states distinct.
+An unavailable identity provider is never presented as an ordinary signed-out session.
diff --git a/packages/ui-astryx/package.json b/packages/ui-astryx/package.json
new file mode 100644
index 000000000..548e02863
--- /dev/null
+++ b/packages/ui-astryx/package.json
@@ -0,0 +1,64 @@
+{
+ "name": "@croco/ui-astryx",
+ "version": "0.1.0",
+ "description": "Astryx UI profile adapters for Croco React applications",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/croco-dev/framework.git",
+ "directory": "packages/ui-astryx"
+ },
+ "files": [
+ "dist"
+ ],
+ "type": "commonjs",
+ "sideEffects": [
+ "./dist/styles.css"
+ ],
+ "main": "./src/index.ts",
+ "types": "./src/index.ts",
+ "publishConfig": {
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "import": "./dist/index.mjs",
+ "require": "./dist/index.js",
+ "types": "./dist/index.d.ts"
+ },
+ "./styles.css": "./dist/styles.css"
+ },
+ "access": "public"
+ },
+ "scripts": {
+ "build": "tsup src/index.ts --format esm,cjs --minify --clean --dts --external react --external react-dom --external @stylexjs/stylex --external @astryxdesign/core --external @astryxdesign/core/AppShell --external @astryxdesign/core/Badge --external @astryxdesign/core/Banner --external @astryxdesign/core/Button --external @astryxdesign/core/Card --external @astryxdesign/core/theme --external @astryxdesign/theme-neutral/built --external @croco/problems-core && cp styles.css dist/styles.css",
+ "lint": "oxlint .",
+ "test": "vitest run",
+ "typecheck": "tsc --noEmit"
+ },
+ "dependencies": {
+ "@astryxdesign/core": "0.1.4",
+ "@astryxdesign/theme-neutral": "0.1.4",
+ "@croco/problems-core": "workspace:*",
+ "@stylexjs/stylex": "^0.18.3"
+ },
+ "devDependencies": {
+ "@croco/frontend-react": "workspace:*",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tsup": "^8.0.0",
+ "typescript": "^5.7.0",
+ "vitest": "4.0.16"
+ },
+ "peerDependencies": {
+ "react": ">=19.0.0",
+ "react-dom": ">=19.0.0"
+ },
+ "vitest": {
+ "include": [
+ "src/**/*.spec.ts",
+ "src/**/*.spec.tsx"
+ ]
+ }
+}
diff --git a/packages/ui-astryx/src/index.ts b/packages/ui-astryx/src/index.ts
new file mode 100644
index 000000000..c88fa79bb
--- /dev/null
+++ b/packages/ui-astryx/src/index.ts
@@ -0,0 +1,14 @@
+export type { AstryxAppShellProps } from "./libs/AstryxAppShell";
+export { AstryxAppShell } from "./libs/AstryxAppShell";
+export type { AstryxAuthStateKind, AstryxAuthStateProps } from "./libs/AstryxAuthState";
+export { AstryxAuthState, toAstryxAuthStateProps } from "./libs/AstryxAuthState";
+export type { AstryxProblemViewProps } from "./libs/AstryxProblemView";
+export { AstryxProblemView } from "./libs/AstryxProblemView";
+export type { AstryxProviderProps } from "./libs/AstryxProvider";
+export { AstryxProvider } from "./libs/AstryxProvider";
+export type {
+ AstryxProblemRecoveryAction,
+ AstryxRecoveryAction,
+ AstryxSession,
+ AstryxSessionState,
+} from "./libs/crocoUiTypes";
diff --git a/packages/ui-astryx/src/libs/AstryxAppShell.tsx b/packages/ui-astryx/src/libs/AstryxAppShell.tsx
new file mode 100644
index 000000000..482130574
--- /dev/null
+++ b/packages/ui-astryx/src/libs/AstryxAppShell.tsx
@@ -0,0 +1,39 @@
+import type { AppShellProps } from "@astryxdesign/core/AppShell";
+
+import { AppShell } from "@astryxdesign/core/AppShell";
+import type { ReactNode } from "react";
+
+export type AstryxAppShellProps = {
+ readonly appName: string;
+ readonly banner?: ReactNode;
+ readonly children?: ReactNode;
+ readonly contentPadding?: AppShellProps["contentPadding"];
+ readonly height?: AppShellProps["height"];
+ readonly navigation?: ReactNode;
+ readonly sideNavigation?: ReactNode;
+};
+
+export function AstryxAppShell({
+ appName,
+ banner,
+ children,
+ contentPadding = 4,
+ height = "auto",
+ navigation,
+ sideNavigation,
+}: AstryxAppShellProps) {
+ const topNav = navigation ?? {appName};
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/ui-astryx/src/libs/AstryxAuthState.tsx b/packages/ui-astryx/src/libs/AstryxAuthState.tsx
new file mode 100644
index 000000000..524a41d4b
--- /dev/null
+++ b/packages/ui-astryx/src/libs/AstryxAuthState.tsx
@@ -0,0 +1,142 @@
+import type { BadgeVariant } from "@astryxdesign/core/Badge";
+import { Badge } from "@astryxdesign/core/Badge";
+import { Button } from "@astryxdesign/core/Button";
+import { Card } from "@astryxdesign/core/Card";
+
+import type { ProblemDetails } from "@croco/problems-core";
+
+import type { AstryxRecoveryAction, AstryxSession, AstryxSessionState } from "./crocoUiTypes";
+
+export type AstryxAuthStateKind = "loading" | "signed-in" | "signed-out" | "unavailable";
+
+type AstryxAuthStateBaseProps = {
+ readonly detail?: string;
+ readonly recoveryActions?: readonly AstryxRecoveryAction[];
+};
+
+export type AstryxAuthStateProps = AstryxAuthStateBaseProps &
+ (
+ | {
+ readonly problem?: never;
+ readonly session?: never;
+ readonly state: "loading";
+ }
+ | {
+ readonly problem?: never;
+ readonly session: AstryxSession;
+ readonly state: "signed-in";
+ }
+ | {
+ readonly problem?: ProblemDetails;
+ readonly session?: never;
+ readonly state: "signed-out";
+ }
+ | {
+ readonly problem: ProblemDetails;
+ readonly session?: never;
+ readonly state: "unavailable";
+ }
+ );
+
+function stateBadge(state: AstryxAuthStateKind): {
+ readonly label: string;
+ readonly variant: BadgeVariant;
+} {
+ switch (state) {
+ case "loading":
+ return { label: "Loading", variant: "info" };
+ case "signed-in":
+ return { label: "Signed in", variant: "success" };
+ case "signed-out":
+ return { label: "Signed out", variant: "warning" };
+ case "unavailable":
+ return { label: "Unavailable", variant: "error" };
+ }
+}
+
+function defaultDetail(state: AstryxAuthStateKind, session?: AstryxSession): string {
+ switch (state) {
+ case "loading":
+ return "Checking your session.";
+ case "signed-in":
+ return (
+ session?.user.label ??
+ session?.user.email ??
+ session?.user.userId ??
+ "Authenticated session"
+ );
+ case "signed-out":
+ return "Sign in to continue.";
+ case "unavailable":
+ return "The session provider is unavailable.";
+ }
+}
+
+function actionButton(action: AstryxRecoveryAction) {
+ const recover = action.onRecover;
+ const clickAction = recover === undefined ? undefined : () => recover();
+
+ return (
+
+ );
+}
+
+export function AstryxAuthState({
+ detail,
+ problem,
+ recoveryActions = [],
+ session,
+ state,
+}: AstryxAuthStateProps) {
+ const badge = stateBadge(state);
+
+ return (
+
+
+ {detail ?? problem?.detail ?? defaultDetail(state, session)}
+ {session?.provider === undefined ? null : Provider: {session.provider}
}
+ {problem === undefined ? null : Problem: {problem.code}
}
+ {recoveryActions.length === 0 ? null : {recoveryActions.map(actionButton)}
}
+
+ );
+}
+
+export function toAstryxAuthStateProps(state: AstryxSessionState): AstryxAuthStateProps {
+ switch (state.kind) {
+ case "loading":
+ return {
+ recoveryActions: state.recoveryActions,
+ state: "loading",
+ };
+ case "authenticated":
+ return {
+ session: state.session,
+ state: "signed-in",
+ };
+ case "unauthenticated":
+ return {
+ problem: state.problem,
+ recoveryActions: state.recoveryActions,
+ state: "signed-out",
+ };
+ case "unavailable":
+ return {
+ problem: state.problem,
+ recoveryActions: state.recoveryActions,
+ state: "unavailable",
+ };
+ }
+}
diff --git a/packages/ui-astryx/src/libs/AstryxProblemView.tsx b/packages/ui-astryx/src/libs/AstryxProblemView.tsx
new file mode 100644
index 000000000..ab368c5e1
--- /dev/null
+++ b/packages/ui-astryx/src/libs/AstryxProblemView.tsx
@@ -0,0 +1,79 @@
+import { Banner } from "@astryxdesign/core/Banner";
+import { Button } from "@astryxdesign/core/Button";
+
+import type { ProblemDetails } from "@croco/problems-core";
+
+import type { AstryxProblemRecoveryAction } from "./crocoUiTypes";
+
+export type AstryxProblemViewProps = {
+ readonly problem: ProblemDetails;
+ readonly recoveryActions?: readonly AstryxProblemRecoveryAction[];
+};
+
+function problemStatus(status: number): "error" | "info" | "warning" {
+ if (status >= 500) {
+ return "error";
+ }
+
+ if (status >= 400) {
+ return "warning";
+ }
+
+ return "info";
+}
+
+function appliesToProblem(action: AstryxProblemRecoveryAction, problem: ProblemDetails): boolean {
+ return action.problemCodes === undefined || action.problemCodes.includes(problem.code);
+}
+
+function recoveryButton(action: AstryxProblemRecoveryAction, problem: ProblemDetails) {
+ const recover = action.onRecover;
+ const clickAction = recover === undefined ? undefined : () => recover(problem);
+
+ return (
+
+ );
+}
+
+export function AstryxProblemView({ problem, recoveryActions = [] }: AstryxProblemViewProps) {
+ const visibleActions = recoveryActions.filter((action) => appliesToProblem(action, problem));
+ const endContent =
+ visibleActions.length === 0
+ ? undefined
+ : visibleActions.map((action) => recoveryButton(action, problem));
+
+ return (
+
+
+ - Status
+ - {problem.status}
+ - Code
+ - {problem.code}
+ - Type
+ - {problem.type}
+ {problem.instance === undefined ? null : (
+ <>
+ - Instance
+ - {problem.instance}
+ >
+ )}
+
+
+ );
+}
diff --git a/packages/ui-astryx/src/libs/AstryxProvider.tsx b/packages/ui-astryx/src/libs/AstryxProvider.tsx
new file mode 100644
index 000000000..ef20d3114
--- /dev/null
+++ b/packages/ui-astryx/src/libs/AstryxProvider.tsx
@@ -0,0 +1,25 @@
+import type { DefinedTheme, ThemeMode } from "@astryxdesign/core/theme";
+
+import { Theme } from "@astryxdesign/core/theme";
+import { neutralTheme } from "@astryxdesign/theme-neutral/built";
+import type { ReactNode } from "react";
+
+export type AstryxProviderProps = {
+ readonly children?: ReactNode;
+ readonly mode?: ThemeMode;
+ readonly theme?: DefinedTheme;
+};
+
+export function AstryxProvider({
+ children,
+ mode = "system",
+ theme = neutralTheme,
+}: AstryxProviderProps) {
+ return (
+
+
+ {children}
+
+
+ );
+}
diff --git a/packages/ui-astryx/src/libs/crocoUiTypes.ts b/packages/ui-astryx/src/libs/crocoUiTypes.ts
new file mode 100644
index 000000000..be74c7afc
--- /dev/null
+++ b/packages/ui-astryx/src/libs/crocoUiTypes.ts
@@ -0,0 +1,48 @@
+import type { ProblemDetails } from "@croco/problems-core";
+
+export type AstryxRecoveryAction = {
+ readonly id: string;
+ readonly label: string;
+ readonly href?: string;
+ readonly onRecover?: () => void | Promise;
+ readonly problemCodes?: readonly string[];
+};
+
+export type AstryxProblemRecoveryAction = {
+ readonly id: string;
+ readonly label: string;
+ readonly href?: string;
+ readonly onRecover?: (problem: ProblemDetails) => void | Promise;
+ readonly problemCodes?: readonly string[];
+ readonly disabled?: boolean;
+ readonly ariaLabel?: string;
+};
+
+export type AstryxSession = {
+ readonly user: {
+ readonly userId: string;
+ readonly label?: string;
+ readonly email?: string;
+ };
+ readonly provider?: string;
+};
+
+export type AstryxSessionState =
+ | {
+ readonly kind: "loading";
+ readonly recoveryActions?: readonly AstryxRecoveryAction[];
+ }
+ | {
+ readonly kind: "authenticated";
+ readonly session: AstryxSession;
+ }
+ | {
+ readonly kind: "unauthenticated";
+ readonly problem?: ProblemDetails;
+ readonly recoveryActions?: readonly AstryxRecoveryAction[];
+ }
+ | {
+ readonly kind: "unavailable";
+ readonly problem: ProblemDetails;
+ readonly recoveryActions?: readonly AstryxRecoveryAction[];
+ };
diff --git a/packages/ui-astryx/src/tests/AstryxUi.spec.ts b/packages/ui-astryx/src/tests/AstryxUi.spec.ts
new file mode 100644
index 000000000..437ddba2d
--- /dev/null
+++ b/packages/ui-astryx/src/tests/AstryxUi.spec.ts
@@ -0,0 +1,95 @@
+import type { FrontendSessionState, ProblemRecoveryAction } from "@croco/frontend-react";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ AstryxAppShell,
+ AstryxAuthState,
+ AstryxProblemView,
+ AstryxProvider,
+ toAstryxAuthStateProps,
+} from "../index";
+import type { AstryxAuthStateProps } from "../index";
+
+const problem = {
+ code: "AUTH_PROVIDER_UNAVAILABLE",
+ detail: "The identity provider did not respond.",
+ instance: "/sessions/current",
+ status: 503,
+ title: "Authentication unavailable",
+ type: "https://croco.dev/problems/auth-provider-unavailable",
+};
+
+describe("@croco/ui-astryx", () => {
+ it("renders the neutral Astryx theme and application shell on the server", () => {
+ const content = createElement("main", undefined, "Ready");
+ const shell = createElement(AstryxAppShell, { appName: "Croco Console" }, content);
+ const html = renderToStaticMarkup(createElement(AstryxProvider, { mode: "dark" }, shell));
+
+ expect(html).toContain('data-astryx-theme="neutral"');
+ expect(html).toContain('data-croco-ui-profile="astryx"');
+ expect(html).toContain('data-theme="dark"');
+ expect(html).toContain('data-croco-app-name="true"');
+ expect(html).toContain("Croco Console");
+ expect(html).toContain("Ready");
+ });
+
+ it("preserves RFC 7807 evidence and applicable recovery actions", () => {
+ const retry = vi.fn();
+ const actions: readonly ProblemRecoveryAction[] = [
+ {
+ id: "retry",
+ label: "Retry",
+ onRecover: retry,
+ problemCodes: [problem.code],
+ },
+ { id: "ignored", label: "Ignore me", problemCodes: ["OTHER_PROBLEM"] },
+ ];
+
+ const html = renderToStaticMarkup(
+ createElement(AstryxProblemView, { problem, recoveryActions: actions }),
+ );
+
+ expect(html).toContain(`data-croco-problem-code="${problem.code}"`);
+ expect(html).toContain(problem.title);
+ expect(html).toContain(problem.detail);
+ expect(html).toContain(problem.type);
+ expect(html).toContain(problem.instance);
+ expect(html).toContain("Retry");
+ expect(html).not.toContain("Ignore me");
+ expect(retry).not.toHaveBeenCalled();
+ });
+
+ it("renders an explicit signed-out state for generated applications", () => {
+ const html = renderToStaticMarkup(
+ createElement(AstryxAuthState, {
+ detail: "Sign in to manage this tenant.",
+ state: "signed-out",
+ }),
+ );
+
+ expect(html).toContain('data-croco-auth-state="signed-out"');
+ expect(html).toContain("Signed out");
+ expect(html).toContain("Sign in to manage this tenant.");
+ });
+
+ it("maps Croco frontend session contracts without treating unavailable state as signed out", () => {
+ const mapFrontendSessionState: (state: FrontendSessionState) => AstryxAuthStateProps =
+ toAstryxAuthStateProps;
+ const sessionState: FrontendSessionState = {
+ kind: "unavailable",
+ problem,
+ recoveryActions: [{ href: "/status", id: "status", label: "Service status" }],
+ };
+
+ const props = mapFrontendSessionState(sessionState);
+ const html = renderToStaticMarkup(createElement(AstryxAuthState, props));
+
+ expect(props.state).toBe("unavailable");
+ expect(html).toContain('data-croco-auth-state="unavailable"');
+ expect(html).toContain(problem.code);
+ expect(html).toContain("Service status");
+ expect(html).toContain('href="/status"');
+ });
+});
diff --git a/packages/ui-astryx/styles.css b/packages/ui-astryx/styles.css
new file mode 100644
index 000000000..94ed4af42
--- /dev/null
+++ b/packages/ui-astryx/styles.css
@@ -0,0 +1,3 @@
+@import "@astryxdesign/core/reset.css";
+@import "@astryxdesign/core/astryx.css";
+@import "@astryxdesign/theme-neutral/theme.css";
diff --git a/packages/ui-astryx/tsconfig.json b/packages/ui-astryx/tsconfig.json
new file mode 100644
index 000000000..f1404f685
--- /dev/null
+++ b/packages/ui-astryx/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://json.schemastore.org/tsconfig",
+ "extends": "../../tsconfig/tsconfig.react.json",
+ "compilerOptions": {
+ "baseUrl": ".",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "paths": {
+ "@croco/frontend-react": ["../frontend-react/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"]
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 3a03c2a03..3fb986b4e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1275,6 +1275,9 @@ importers:
'@croco/tx-drizzle':
specifier: workspace:*
version: link:../tx-drizzle
+ '@croco/ui-astryx':
+ specifier: workspace:*
+ version: link:../ui-astryx
'@croco/webhooks-core':
specifier: workspace:*
version: link:../webhooks-core
@@ -3327,6 +3330,46 @@ importers:
specifier: 'catalog:'
version: 0.45.2(@cloudflare/workers-types@4.20260316.1)(@opentelemetry/api@1.9.0)(@types/better-sqlite3@7.6.13)(@types/pg@8.15.6)(@upstash/redis@1.36.1)(better-sqlite3@11.10.0)(kysely@0.28.17)(pg@8.20.0)
+ packages/ui-astryx:
+ dependencies:
+ '@astryxdesign/core':
+ specifier: 0.1.4
+ version: 0.1.4(@stylexjs/stylex@0.18.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ '@astryxdesign/theme-neutral':
+ specifier: 0.1.4
+ version: 0.1.4(@astryxdesign/core@0.1.4(@stylexjs/stylex@0.18.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)
+ '@croco/problems-core':
+ specifier: workspace:*
+ version: link:../problems-core
+ '@stylexjs/stylex':
+ specifier: ^0.18.3
+ version: 0.18.3
+ devDependencies:
+ '@croco/frontend-react':
+ specifier: workspace:*
+ version: link:../frontend-react
+ '@types/react':
+ specifier: ^19.0.0
+ version: 19.2.14
+ '@types/react-dom':
+ specifier: ^19.0.0
+ version: 19.2.3(@types/react@19.2.14)
+ react:
+ specifier: ^19.0.0
+ version: 19.2.5
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.2.5(react@19.2.5)
+ tsup:
+ specifier: ^8.0.0
+ version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)
+ typescript:
+ specifier: ^5.7.0
+ version: 5.9.3
+ vitest:
+ specifier: 4.0.16
+ version: 4.0.16(@opentelemetry/api@1.9.0)(@types/node@25.2.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)
+
packages/webhooks-core:
dependencies:
'@croco/idempotency-core':
@@ -3468,6 +3511,19 @@ packages:
resolution: {integrity: sha512-j8DNruA8ors99Al39RYZPJK4DC1bKkoNm93mAMuBhY9TCNC4R8n1q7ovFnJ5qhGh5Lsh7pa1gpQVpYpsJPeTHQ==}
engines: {node: 18.20.8 || ^20.3.0 || >=22.0.0}
+ '@astryxdesign/core@0.1.4':
+ resolution: {integrity: sha512-pLwdh8Pu4ifgQ3thiaFjzitWzVpGJmyaSgNe2AZnGwGO1E876HY4k7s6m2wDnxbt5kxdSSeu0O6axq1LxUbwEQ==}
+ peerDependencies:
+ '@stylexjs/stylex': ^0.18.3
+ react: '>=19.0.0'
+ react-dom: '>=19.0.0'
+
+ '@astryxdesign/theme-neutral@0.1.4':
+ resolution: {integrity: sha512-h3BS3MZ/EKxTSC1JuNFMmDlO7jwmf4zycg/B4T7Dis6a/al2lIJMoOsMtjw+sz34TPGYhIceOZNl2yUOBGnyiA==}
+ peerDependencies:
+ '@astryxdesign/core': 0.1.4
+ react: '>=19'
+
'@aws-crypto/crc32@5.2.0':
resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==}
engines: {node: '>=16.0.0'}
@@ -6212,6 +6268,9 @@ packages:
'@standard-schema/spec@1.1.0':
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
+ '@stylexjs/stylex@0.18.3':
+ resolution: {integrity: sha512-15gDzAJAorOE0yzxaWLNxldW2aqdmCiLG5QcPD8nmVCVqvrWp0Asgv45zk7LtN86WJb/4Ym9eQ6qT5MJW59tWQ==}
+
'@t3-oss/env-core@0.13.10':
resolution: {integrity: sha512-NNFfdlJ+HmPHkLi2HKy7nwuat9SIYOxei9K10lO2YlcSObDILY7mHZNSHsieIM3A0/5OOzw/P/b+yLvPdaG52g==}
peerDependencies:
@@ -7002,6 +7061,9 @@ packages:
resolution: {integrity: sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==}
engines: {node: '>=4'}
+ css-mediaquery@0.1.2:
+ resolution: {integrity: sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==}
+
css-select@5.2.2:
resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
@@ -7814,6 +7876,9 @@ packages:
inline-style-parser@0.2.7:
resolution: {integrity: sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==}
+ invariant@2.2.4:
+ resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==}
+
ioredis@5.10.1:
resolution: {integrity: sha512-HuEDBTI70aYdx1v6U97SbNx9F1+svQKBDo30o0b9fw055LMepzpOOd0Ccg9Q6tbqmBSJaMuY0fB7yw9/vjBYCA==}
engines: {node: '>=12.22.0'}
@@ -8136,6 +8201,11 @@ packages:
resolution: {integrity: sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==}
engines: {node: 20 || >=22}
+ lucide-react@1.24.0:
+ resolution: {integrity: sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==}
+ peerDependencies:
+ react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
+
lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
@@ -9400,6 +9470,9 @@ packages:
react-dom:
optional: true
+ styleq@0.2.1:
+ resolution: {integrity: sha512-L0TR0NQb+X4/ktDEKmjWyp27gla+LUYi/by5k5SjKXf6/pvZP7wbwEB5J+tqxdFVPgzbsuz+d4RTScO/QZquBw==}
+
stylis@4.3.6:
resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==}
@@ -10320,6 +10393,18 @@ snapshots:
is-wsl: 3.1.1
which-pm-runs: 1.1.0
+ '@astryxdesign/core@0.1.4(@stylexjs/stylex@0.18.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@stylexjs/stylex': 0.18.3
+ react: 19.2.5
+ react-dom: 19.2.5(react@19.2.5)
+
+ '@astryxdesign/theme-neutral@0.1.4(@astryxdesign/core@0.1.4(@stylexjs/stylex@0.18.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(react@19.2.5)':
+ dependencies:
+ '@astryxdesign/core': 0.1.4(@stylexjs/stylex@0.18.3)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
+ lucide-react: 1.24.0(react@19.2.5)
+ react: 19.2.5
+
'@aws-crypto/crc32@5.2.0':
dependencies:
'@aws-crypto/util': 5.2.0
@@ -13069,6 +13154,12 @@ snapshots:
'@standard-schema/spec@1.1.0': {}
+ '@stylexjs/stylex@0.18.3':
+ dependencies:
+ css-mediaquery: 0.1.2
+ invariant: 2.2.4
+ styleq: 0.2.1
+
'@t3-oss/env-core@0.13.10(typescript@5.9.3)(zod@4.3.6)':
optionalDependencies:
typescript: 5.9.3
@@ -13912,6 +14003,8 @@ snapshots:
css-color-keywords@1.0.0: {}
+ css-mediaquery@0.1.2: {}
+
css-select@5.2.2:
dependencies:
boolbase: 1.0.0
@@ -14905,6 +14998,10 @@ snapshots:
inline-style-parser@0.2.7: {}
+ invariant@2.2.4:
+ dependencies:
+ loose-envify: 1.4.0
+
ioredis@5.10.1:
dependencies:
'@ioredis/commands': 1.5.1
@@ -15168,6 +15265,10 @@ snapshots:
lru-cache@11.5.1: {}
+ lucide-react@1.24.0(react@19.2.5):
+ dependencies:
+ react: 19.2.5
+
lunr@2.3.9: {}
magic-string@0.30.21:
@@ -16913,6 +17014,8 @@ snapshots:
optionalDependencies:
react-dom: 19.2.5(react@19.2.5)
+ styleq@0.2.1: {}
+
stylis@4.3.6: {}
sucrase@3.35.1:
diff --git a/public-api-surface.snapshot.json b/public-api-surface.snapshot.json
index ecc55fcc7..70c099f8c 100644
--- a/public-api-surface.snapshot.json
+++ b/public-api-surface.snapshot.json
@@ -13283,6 +13283,26 @@
"exportKind": "named",
"source": "./output-contract"
},
+ {
+ "name": "GeneratedUiProfileMaturity",
+ "exportKind": "named",
+ "source": "./output-contract"
+ },
+ {
+ "name": "GeneratedUiProfileMetadata",
+ "exportKind": "named",
+ "source": "./output-contract"
+ },
+ {
+ "name": "GeneratedUiProfileName",
+ "exportKind": "named",
+ "source": "./output-contract"
+ },
+ {
+ "name": "GeneratedUiStyleEngine",
+ "exportKind": "named",
+ "source": "./output-contract"
+ },
{
"name": "OutputContract",
"exportKind": "named",
@@ -20143,6 +20163,90 @@
}
]
},
+ {
+ "packageName": "@croco/ui-astryx",
+ "relativeDir": "packages/ui-astryx",
+ "entrypoint": "packages/ui-astryx/src/index.ts",
+ "runtimeExports": [
+ {
+ "name": "AstryxAppShell",
+ "exportKind": "named",
+ "source": "./libs/AstryxAppShell",
+ "declarationKind": "function"
+ },
+ {
+ "name": "AstryxAuthState",
+ "exportKind": "named",
+ "source": "./libs/AstryxAuthState",
+ "declarationKind": "function"
+ },
+ {
+ "name": "AstryxProblemView",
+ "exportKind": "named",
+ "source": "./libs/AstryxProblemView",
+ "declarationKind": "function"
+ },
+ {
+ "name": "AstryxProvider",
+ "exportKind": "named",
+ "source": "./libs/AstryxProvider",
+ "declarationKind": "function"
+ },
+ {
+ "name": "toAstryxAuthStateProps",
+ "exportKind": "named",
+ "source": "./libs/AstryxAuthState",
+ "declarationKind": "function"
+ }
+ ],
+ "typeExports": [
+ {
+ "name": "AstryxAppShellProps",
+ "exportKind": "named",
+ "source": "./libs/AstryxAppShell"
+ },
+ {
+ "name": "AstryxAuthStateKind",
+ "exportKind": "named",
+ "source": "./libs/AstryxAuthState"
+ },
+ {
+ "name": "AstryxAuthStateProps",
+ "exportKind": "named",
+ "source": "./libs/AstryxAuthState"
+ },
+ {
+ "name": "AstryxProblemRecoveryAction",
+ "exportKind": "named",
+ "source": "./libs/crocoUiTypes"
+ },
+ {
+ "name": "AstryxProblemViewProps",
+ "exportKind": "named",
+ "source": "./libs/AstryxProblemView"
+ },
+ {
+ "name": "AstryxProviderProps",
+ "exportKind": "named",
+ "source": "./libs/AstryxProvider"
+ },
+ {
+ "name": "AstryxRecoveryAction",
+ "exportKind": "named",
+ "source": "./libs/crocoUiTypes"
+ },
+ {
+ "name": "AstryxSession",
+ "exportKind": "named",
+ "source": "./libs/crocoUiTypes"
+ },
+ {
+ "name": "AstryxSessionState",
+ "exportKind": "named",
+ "source": "./libs/crocoUiTypes"
+ }
+ ]
+ },
{
"packageName": "@croco/webhooks-core",
"relativeDir": "packages/webhooks-core",
diff --git a/scripts/create-croco-app-generated-smoke-matrix.mts b/scripts/create-croco-app-generated-smoke-matrix.mts
index 9865c3012..69443cc1a 100644
--- a/scripts/create-croco-app-generated-smoke-matrix.mts
+++ b/scripts/create-croco-app-generated-smoke-matrix.mts
@@ -133,6 +133,15 @@ export const GENERATED_SMOKE_MATRIX_CASES = [
},
},
{ name: "graphql-vite-spa-docker", tier: "spine-blocking" },
+ {
+ name: "graphql-vite-spa-astryx",
+ tier: "ecosystem-advisory",
+ advisory: {
+ owner: "Astryx presentation profile owner",
+ recoveryAction:
+ "CROCO_GENERATED_SMOKE_CASES=graphql-vite-spa-astryx pnpm create-croco-app:smoke; inspect the generated UI metadata, Astryx dependency isolation, Vite build, and Croco-aware render smoke.",
+ },
+ },
{
name: "meta-vite-web",
tier: "ecosystem-advisory",
diff --git a/scripts/create-croco-app-generated-smoke.mts b/scripts/create-croco-app-generated-smoke.mts
index 625ebec42..c36b3325f 100644
--- a/scripts/create-croco-app-generated-smoke.mts
+++ b/scripts/create-croco-app-generated-smoke.mts
@@ -14,7 +14,7 @@ import {
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
-import { dirname, extname, join, relative, resolve } from "node:path";
+import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
readGeneratedTemplateSecretAllowlistsFromMetadata,
@@ -118,6 +118,10 @@ type SmokeValidation = {
readonly matches: Record;
readonly arrayMinLengths?: Readonly>;
};
+ readonly presentationProfile?: {
+ readonly appPath: string;
+ readonly runtimeProfileName: string;
+ };
readonly artifacts?: readonly string[];
readonly env?: Readonly>;
readonly expectFailure?: {
@@ -735,6 +739,8 @@ const smokeCaseDefinitions: readonly Omit[] = [
"docker",
"--frontend-deploy",
"vite-spa",
+ "--ui",
+ "none",
"--no-install",
"--no-git",
],
@@ -766,6 +772,61 @@ const smokeCaseDefinitions: readonly Omit[] = [
{ label: "vite SPA Dockerfile", paths: ["web/Dockerfile.vite-spa"] },
],
},
+ {
+ name: "graphql-vite-spa-astryx",
+ args: [
+ "--preset",
+ "ddd-fullstack",
+ "--scope",
+ "@smoke",
+ "--api",
+ "graphql",
+ "--api-hosting",
+ "standalone",
+ "--web-apps",
+ "web",
+ "--frontend-deploy",
+ "vite-spa",
+ "--ui",
+ "astryx",
+ "--no-install",
+ "--no-git",
+ ],
+ runtimeTarget: "browser",
+ matrixTargets: ["base-ddd"],
+ validations: [
+ {
+ label: GRAPHQL_CONTRACT_CHECK_LABEL,
+ packagePath: GRAPHQL_STANDALONE_CONTRACT_PACKAGE_PATH,
+ args: ["contract:check"],
+ },
+ {
+ label: GRAPHQL_CONTRACT_SNAPSHOT_LABEL,
+ packagePath: GRAPHQL_STANDALONE_CONTRACT_PACKAGE_PATH,
+ args: ["contract:snapshot"],
+ paths: [GRAPHQL_CONTRACT_SNAPSHOT_PATH],
+ },
+ {
+ label: "Astryx presentation profile metadata",
+ presentationProfile: {
+ appPath: "apps/web/croco.presentation-profile.json",
+ runtimeProfileName: "browser-vite-spa-astryx",
+ },
+ },
+ { label: "Astryx Vite SPA typecheck", packagePath: ["apps", "web"], args: ["typecheck"] },
+ {
+ label: "Astryx Vite SPA browser build",
+ packagePath: ["apps", "web"],
+ args: ["build"],
+ paths: ["dist/index.html"],
+ },
+ {
+ label: "Astryx Croco-aware render smoke",
+ packagePath: ["apps", "web"],
+ args: ["presentation:smoke"],
+ },
+ ],
+ },
{
name: "meta-vite-web",
args: [
@@ -2350,6 +2411,15 @@ function runValidation(
);
}
+ if (validation.presentationProfile) {
+ assertGeneratedPresentationProfileMatchesCatalog(
+ projectDir,
+ validation.presentationProfile.appPath,
+ validation.presentationProfile.runtimeProfileName,
+ smokeCase.name,
+ );
+ }
+
if (validation.artifacts) {
step.artifacts = copyGeneratedSmokeArtifacts({
generatedSmokeReportDir,
@@ -2359,7 +2429,13 @@ function runValidation(
});
}
- if (!validation.args && !validation.paths && !validation.json && !validation.artifacts) {
+ if (
+ !validation.args &&
+ !validation.paths &&
+ !validation.json &&
+ !validation.presentationProfile &&
+ !validation.artifacts
+ ) {
throw new Error(`${smokeCase.name} ${validation.label} has no validation action`);
}
@@ -2602,6 +2678,7 @@ function assertSmokeCoverage(cases: readonly SmokeCase[]): void {
SUPPORTED_CREATE_CROCO_APP_CHOICES.frontendDeploys,
coverage.frontendDeploys,
);
+ assertCovers("ui", SUPPORTED_CREATE_CROCO_APP_CHOICES.uiProfiles, coverage.uiProfiles);
assertCovers("db", SUPPORTED_CREATE_CROCO_APP_CHOICES.databases, coverage.databases);
assertCovers(
"saas-profile",
@@ -2629,7 +2706,7 @@ function printSmokeCoverageSummary(cases: readonly SmokeCase[]): void {
`create-croco-app-generated-smoke: matrix cases ${cases.map(({ name }) => name).join(", ")}`,
);
console.log(
- `create-croco-app-generated-smoke: matrix covers presets=${coverage.presets.join(", ")}; apis=${coverage.apis.join(", ")}; api-hosting=${coverage.apiHosting.join(", ")}; backend-deploy=${coverage.backendDeploys.join(", ")}; frontend-deploy=${coverage.frontendDeploys.join(", ")}; db=${coverage.databases.join(", ")}; saas-profile=${coverage.saasProviderProfiles.join(", ")}; tenant-model=${coverage.tenantModels.join(", ")}`,
+ `create-croco-app-generated-smoke: matrix covers presets=${coverage.presets.join(", ")}; apis=${coverage.apis.join(", ")}; api-hosting=${coverage.apiHosting.join(", ")}; backend-deploy=${coverage.backendDeploys.join(", ")}; frontend-deploy=${coverage.frontendDeploys.join(", ")}; ui=${coverage.uiProfiles.join(", ")}; db=${coverage.databases.join(", ")}; saas-profile=${coverage.saasProviderProfiles.join(", ")}; tenant-model=${coverage.tenantModels.join(", ")}`,
);
console.log(
`create-croco-app-generated-smoke: runtime capability manifests ${coverage.runtimeCapabilityManifests.join(", ")}`,
@@ -2648,6 +2725,7 @@ function readSmokeCoverage(cases: readonly SmokeCase[]): {
readonly apiHosting: readonly string[];
readonly backendDeploys: readonly string[];
readonly frontendDeploys: readonly string[];
+ readonly uiProfiles: readonly string[];
readonly databases: readonly string[];
readonly saasProviderProfiles: readonly string[];
readonly tenantModels: readonly string[];
@@ -2671,6 +2749,7 @@ function readSmokeCoverage(cases: readonly SmokeCase[]): {
"--frontend-deploy",
SUPPORTED_CREATE_CROCO_APP_CHOICES.frontendDeploys,
),
+ uiProfiles: readCoveredValues(cases, "--ui", SUPPORTED_CREATE_CROCO_APP_CHOICES.uiProfiles),
databases: readCoveredValues(cases, "--db", SUPPORTED_CREATE_CROCO_APP_CHOICES.databases, {
splitCommaValues: true,
}),
@@ -3232,6 +3311,80 @@ function assertJsonMatches(
}
}
+export function assertGeneratedPresentationProfileMatchesCatalog(
+ projectDir: string,
+ appProfilePath: string,
+ runtimeProfileName: string,
+ smokeCaseName: string,
+ catalogPath = join(rootDir, "packages", "presentation-preset", "runtime-profiles.json"),
+): void {
+ const catalog = readJsonObject(catalogPath, "presentation runtime profile catalog");
+ if (!Array.isArray(catalog.profiles)) {
+ throw new Error(`Presentation runtime profile catalog ${catalogPath} has no profiles array`);
+ }
+
+ const runtimeProfile = catalog.profiles.find(
+ (profile): profile is Record =>
+ isRecord(profile) && profile.name === runtimeProfileName,
+ );
+ if (!runtimeProfile) {
+ throw new Error(
+ `Presentation runtime profile catalog ${catalogPath} does not define ${runtimeProfileName}`,
+ );
+ }
+ if (runtimeProfile.generatedAppSmokeCase !== smokeCaseName) {
+ throw new Error(
+ `Presentation runtime profile ${runtimeProfileName} references ${String(runtimeProfile.generatedAppSmokeCase)} instead of ${smokeCaseName}`,
+ );
+ }
+ if (!isRecord(runtimeProfile.ui)) {
+ throw new Error(`Presentation runtime profile ${runtimeProfileName} has no UI metadata`);
+ }
+ if (runtimeProfile.ui.generatedAppSmokeCase !== smokeCaseName) {
+ throw new Error(
+ `Presentation runtime profile ${runtimeProfileName} UI metadata references ${String(runtimeProfile.ui.generatedAppSmokeCase)} instead of ${smokeCaseName}`,
+ );
+ }
+
+ const generatedAppProfilePath = join(projectDir, appProfilePath);
+ const generatedAppProfile = readJsonObject(
+ generatedAppProfilePath,
+ `${smokeCaseName} generated app presentation profile`,
+ );
+ const webApp = basename(dirname(generatedAppProfilePath));
+ const expectedProfile = {
+ webApp,
+ runtimeProfile: runtimeProfileName,
+ ui: runtimeProfile.ui,
+ };
+ if (JSON.stringify(generatedAppProfile) !== JSON.stringify(expectedProfile)) {
+ throw new Error(
+ `${smokeCaseName} generated presentation profile does not match ${runtimeProfileName} in ${catalogPath}`,
+ );
+ }
+
+ const manifestPath = join(projectDir, "croco-presentation-profile.manifest.json");
+ const manifest = readJsonObject(manifestPath, `${smokeCaseName} presentation profile manifest`);
+ if (
+ manifest.schemaVersion !== "croco.generated-presentation-profile/v1" ||
+ JSON.stringify(manifest.profiles) !== JSON.stringify([expectedProfile])
+ ) {
+ throw new Error(
+ `${smokeCaseName} presentation profile manifest does not match the canonical generated profile`,
+ );
+ }
+}
+
+function readJsonObject(path: string, label: string): Record {
+ assertExists(path, `${label} did not create ${path}`);
+ const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
+ if (!isRecord(value)) {
+ throw new Error(`${label} JSON ${path} is not an object`);
+ }
+
+ return value;
+}
+
function run(
command: string,
args: readonly string[],
diff --git a/scripts/static-misuse-raw-error-allowlist.json b/scripts/static-misuse-raw-error-allowlist.json
index 25df546e5..b8d50fcae 100644
--- a/scripts/static-misuse-raw-error-allowlist.json
+++ b/scripts/static-misuse-raw-error-allowlist.json
@@ -269,7 +269,7 @@
{
"package": "create-croco-app",
"file": "packages/create-croco-app/src/generator.ts",
- "line": 495,
+ "line": 504,
"excerpt": "throw new Error(",
"reason": "Existing CLI and generator validation currently flows through command-level Error handling; tracked for future diagnostic-code migration outside this issue.",
"owner": "framework-error-handling"
diff --git a/scripts/tests/create-croco-app-generated-smoke.spec.ts b/scripts/tests/create-croco-app-generated-smoke.spec.ts
index 39fef3fd6..526893a47 100644
--- a/scripts/tests/create-croco-app-generated-smoke.spec.ts
+++ b/scripts/tests/create-croco-app-generated-smoke.spec.ts
@@ -11,6 +11,7 @@ import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
+ assertGeneratedPresentationProfileMatchesCatalog,
readCommandOutputSegment,
readGeneratedSmokeAllowlistMetadata,
} from "../create-croco-app-generated-smoke.mts";
@@ -189,6 +190,58 @@ describe("create-croco-app-generated-smoke dependency resolution", () => {
);
});
+ it("rejects generated Astryx metadata that drifts from the presentation profile catalog", () => {
+ const root = createTempRoot();
+ const projectDir = join(root, "generated-app");
+ const catalogPath = join(root, "runtime-profiles.json");
+ const ui = {
+ name: "astryx",
+ styleEngine: "stylex",
+ requiresStylexCompile: false,
+ maturity: "beta",
+ generatedAppSmokeCase: "graphql-vite-spa-astryx",
+ };
+ const profile = { webApp: "web", runtimeProfile: "browser-vite-spa-astryx", ui };
+ writeGeneratedPackage(projectDir, "apps/web/croco.presentation-profile.json", profile);
+ writeGeneratedPackage(projectDir, "croco-presentation-profile.manifest.json", {
+ schemaVersion: "croco.generated-presentation-profile/v1",
+ profiles: [profile],
+ });
+ writeGeneratedPackage(root, "runtime-profiles.json", {
+ profiles: [
+ {
+ name: "browser-vite-spa-astryx",
+ generatedAppSmokeCase: "graphql-vite-spa-astryx",
+ ui,
+ },
+ ],
+ });
+
+ expect(() =>
+ assertGeneratedPresentationProfileMatchesCatalog(
+ projectDir,
+ "apps/web/croco.presentation-profile.json",
+ "browser-vite-spa-astryx",
+ "graphql-vite-spa-astryx",
+ catalogPath,
+ ),
+ ).not.toThrow();
+
+ writeGeneratedPackage(projectDir, "apps/web/croco.presentation-profile.json", {
+ ...profile,
+ ui: { ...ui, maturity: "alpha" },
+ });
+ expect(() =>
+ assertGeneratedPresentationProfileMatchesCatalog(
+ projectDir,
+ "apps/web/croco.presentation-profile.json",
+ "browser-vite-spa-astryx",
+ "graphql-vite-spa-astryx",
+ catalogPath,
+ ),
+ ).toThrow("does not match browser-vite-spa-astryx");
+ });
+
it("copies configured smoke artifacts into the report tree and renders matrix evidence", () => {
const root = createTempRoot();
const generatedProjectDir = join(root, "generated-app");
@@ -255,7 +308,7 @@ describe("create-croco-app generated smoke matrix", () => {
]);
expect(
GENERATED_SMOKE_MATRIX_CASES.filter(({ tier }) => tier === "ecosystem-advisory"),
- ).toHaveLength(11);
+ ).toHaveLength(12);
const graphqlLambdaApiCase: SmokeMatrixCaseDefinition | undefined =
GENERATED_SMOKE_MATRIX_CASES.find(({ name }) => name === "graphql-lambda-api");
expect(graphqlLambdaApiCase?.advisory).toBeUndefined();
diff --git a/tsconfig/contract-strict.baseline.json b/tsconfig/contract-strict.baseline.json
index 1eb47c099..9959fbf23 100644
--- a/tsconfig/contract-strict.baseline.json
+++ b/tsconfig/contract-strict.baseline.json
@@ -4712,7 +4712,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/generator.ts",
- "line": 155,
+ "line": 159,
"column": 37,
"code": "TS2379",
"message": "Argument of type '{ api: \"graphql\" | \"trpc\" | undefined; frontendDeploy: \"docker\" | \"opennext\" | \"vercel\" | \"cloudflare-meta-vite\" | \"vite-spa\" | undefined; webApps: string[]; projectName: string; scope: string; }' is not assignable to parameter of type 'Pick' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
@@ -4720,7 +4720,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/generator.ts",
- "line": 162,
+ "line": 166,
"column": 37,
"code": "TS2379",
"message": "Argument of type '{ api: \"graphql\" | \"trpc\" | undefined; projectName: string; scope: string; }' is not assignable to parameter of type 'Pick' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
@@ -4728,15 +4728,15 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 363,
+ "line": 374,
"column": 9,
"code": "TS2375",
- "message": "Type '{ projectName: string; scope: string; preset: \"ddd-fullstack\" | \"ddd-vike-fullstack\" | \"ddd-api\"; webApps: string[]; api: \"graphql\" | \"trpc\" | undefined; apiHosting: \"standalone\" | \"nextjs\"; ... 7 more ...; initGit: boolean; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
+ "message": "Type '{ saasProviderProfile: \"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\" | undefined; tenantModel: \"single\" | \"org\" | \"workspace\" | \"shared-schema\" | \"rls-backed\" | undefined; ... 12 more ...; frontendDeploy: \"docker\" | ... 4 more ... | undefined; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
},
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 48,
+ "line": 50,
"column": 25,
"code": "TS4111",
"message": "Property 'goal' comes from an index signature, so it must be accessed with ['goal']."
@@ -4744,7 +4744,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 49,
+ "line": 51,
"column": 34,
"code": "TS4111",
"message": "Property 'goal' comes from an index signature, so it must be accessed with ['goal']."
@@ -4752,7 +4752,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 49,
+ "line": 51,
"column": 5,
"code": "TS2412",
"message": "Type 'AppGoal | undefined' is not assignable to type 'AppGoal' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4760,7 +4760,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 50,
+ "line": 52,
"column": 25,
"code": "TS4111",
"message": "Property 'preset' comes from an index signature, so it must be accessed with ['preset']."
@@ -4768,7 +4768,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 51,
+ "line": 53,
"column": 36,
"code": "TS4111",
"message": "Property 'preset' comes from an index signature, so it must be accessed with ['preset']."
@@ -4776,7 +4776,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 52,
+ "line": 54,
"column": 25,
"code": "TS4111",
"message": "Property 'scope' comes from an index signature, so it must be accessed with ['scope']."
@@ -4784,7 +4784,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 52,
+ "line": 54,
"column": 75,
"code": "TS4111",
"message": "Property 'scope' comes from an index signature, so it must be accessed with ['scope']."
@@ -4792,7 +4792,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 53,
+ "line": 55,
"column": 25,
"code": "TS4111",
"message": "Property 'saasProfile' comes from an index signature, so it must be accessed with ['saasProfile']."
@@ -4800,7 +4800,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 54,
+ "line": 56,
"column": 5,
"code": "TS2412",
"message": "Type '\"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\" | undefined' is not assignable to type '\"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\"' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4808,7 +4808,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 55,
+ "line": 57,
"column": 18,
"code": "TS4111",
"message": "Property 'saasProfile' comes from an index signature, so it must be accessed with ['saasProfile']."
@@ -4816,7 +4816,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 57,
+ "line": 59,
"column": 25,
"code": "TS4111",
"message": "Property 'tenantModel' comes from an index signature, so it must be accessed with ['tenantModel']."
@@ -4824,7 +4824,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 58,
+ "line": 60,
"column": 41,
"code": "TS4111",
"message": "Property 'tenantModel' comes from an index signature, so it must be accessed with ['tenantModel']."
@@ -4832,7 +4832,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 58,
+ "line": 60,
"column": 5,
"code": "TS2412",
"message": "Type '\"single\" | \"org\" | \"workspace\" | \"shared-schema\" | \"rls-backed\" | undefined' is not assignable to type '\"single\" | \"org\" | \"workspace\" | \"shared-schema\" | \"rls-backed\"' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4840,7 +4840,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 60,
+ "line": 62,
"column": 25,
"code": "TS4111",
"message": "Property 'api' comes from an index signature, so it must be accessed with ['api']."
@@ -4848,7 +4848,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 61,
+ "line": 63,
"column": 33,
"code": "TS4111",
"message": "Property 'api' comes from an index signature, so it must be accessed with ['api']."
@@ -4856,7 +4856,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 61,
+ "line": 63,
"column": 5,
"code": "TS2412",
"message": "Type '\"graphql\" | \"trpc\" | undefined' is not assignable to type '\"graphql\" | \"trpc\"' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4864,7 +4864,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 62,
+ "line": 64,
"column": 25,
"code": "TS4111",
"message": "Property 'apiHosting' comes from an index signature, so it must be accessed with ['apiHosting']."
@@ -4872,7 +4872,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 63,
+ "line": 65,
"column": 40,
"code": "TS4111",
"message": "Property 'apiHosting' comes from an index signature, so it must be accessed with ['apiHosting']."
@@ -4880,7 +4880,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 65,
+ "line": 67,
"column": 25,
"code": "TS4111",
"message": "Property 'webApps' comes from an index signature, so it must be accessed with ['webApps']."
@@ -4888,7 +4888,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 66,
+ "line": 68,
"column": 37,
"code": "TS4111",
"message": "Property 'webApps' comes from an index signature, so it must be accessed with ['webApps']."
@@ -4896,7 +4896,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 71,
+ "line": 73,
"column": 25,
"code": "TS4111",
"message": "Property 'backendDeploy' comes from an index signature, so it must be accessed with ['backendDeploy']."
@@ -4904,7 +4904,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 72,
+ "line": 74,
"column": 43,
"code": "TS4111",
"message": "Property 'backendDeploy' comes from an index signature, so it must be accessed with ['backendDeploy']."
@@ -4912,7 +4912,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 72,
+ "line": 74,
"column": 5,
"code": "TS2412",
"message": "Type '\"lambda\" | \"docker\" | undefined' is not assignable to type '\"lambda\" | \"docker\"' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4920,7 +4920,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 74,
+ "line": 76,
"column": 25,
"code": "TS4111",
"message": "Property 'frontendDeploy' comes from an index signature, so it must be accessed with ['frontendDeploy']."
@@ -4928,7 +4928,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 75,
+ "line": 77,
"column": 44,
"code": "TS4111",
"message": "Property 'frontendDeploy' comes from an index signature, so it must be accessed with ['frontendDeploy']."
@@ -4936,7 +4936,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 75,
+ "line": 77,
"column": 5,
"code": "TS2412",
"message": "Type '\"docker\" | \"opennext\" | \"vercel\" | \"cloudflare-meta-vite\" | \"vite-spa\" | undefined' is not assignable to type '\"docker\" | \"opennext\" | \"vercel\" | \"cloudflare-meta-vite\" | \"vite-spa\"' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the type of the target."
@@ -4944,7 +4944,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 77,
+ "line": 82,
"column": 25,
"code": "TS4111",
"message": "Property 'db' comes from an index signature, so it must be accessed with ['db']."
@@ -4952,7 +4952,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 78,
+ "line": 83,
"column": 32,
"code": "TS4111",
"message": "Property 'db' comes from an index signature, so it must be accessed with ['db']."
@@ -4960,7 +4960,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 83,
+ "line": 88,
"column": 18,
"code": "TS4111",
"message": "Property 'agentRules' comes from an index signature, so it must be accessed with ['agentRules']."
@@ -4968,7 +4968,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 84,
+ "line": 89,
"column": 18,
"code": "TS4111",
"message": "Property 'install' comes from an index signature, so it must be accessed with ['install']."
@@ -4976,7 +4976,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/options.ts",
- "line": 85,
+ "line": 90,
"column": 18,
"code": "TS4111",
"message": "Property 'git' comes from an index signature, so it must be accessed with ['git']."
@@ -4984,7 +4984,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/prompts.ts",
- "line": 196,
+ "line": 204,
"column": 13,
"code": "TS2322",
"message": "Type '{ value: \"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\"; label: \"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\"; hint: string | undefined; }[]' is not assignable to type '({ value: \"saas-node-postgres\"; label?: string; hint?: string; } | { value: \"saas-cloudflare\"; label?: string; hint?: string; } | { value: \"saas-lambda\"; label?: string; hint?: string; })[]'."
@@ -4992,7 +4992,7 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/prompts.ts",
- "line": 258,
+ "line": 266,
"column": 5,
"code": "TS2375",
"message": "Type '{ projectName: string; scope: string; preset: \"production-app\" | \"admin-console\" | \"saas\" | \"ai-saas\"; saasProviderProfile: \"saas-node-postgres\" | \"saas-cloudflare\" | \"saas-lambda\" | undefined; ... 6 more ...; initGit: boolean; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
@@ -5000,10 +5000,10 @@
{
"packageName": "create-croco-app",
"file": "packages/create-croco-app/src/prompts.ts",
- "line": 429,
+ "line": 472,
"column": 3,
"code": "TS2375",
- "message": "Type '{ projectName: string; scope: string; preset: GeneratorOptions[\"preset\"]; webApps: string[]; api: GeneratorOptions[\"api\"]; apiHosting: \"standalone\" | \"nextjs\"; backendDeploy: \"lambda\" | \"docker\" | undefined; ... 4 more ...; initGit: boolean; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
+ "message": "Type '{ db: GeneratorOptions[\"db\"]; agentRules: boolean; installDeps: boolean; initGit: boolean; ui?: \"none\" | \"astryx\"; projectName: string; scope: string; preset: GeneratorOptions[\"preset\"]; ... 4 more ...; frontendDeploy: \"docker\" | ... 4 more ... | undefined; }' is not assignable to type 'GeneratorOptions' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties."
},
{
"packageName": "create-croco-app",
From bebc00067560f67bcd44d0eac141e10ef9b53165 Mon Sep 17 00:00:00 2001
From: kang-heewon
Date: Sat, 11 Jul 2026 23:51:49 +0900
Subject: [PATCH 2/5] docs: align public package counts with catalog
---
packages/docs/src/content/docs/en/guides/getting-started.mdx | 2 +-
packages/docs/src/content/docs/en/index.mdx | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/packages/docs/src/content/docs/en/guides/getting-started.mdx b/packages/docs/src/content/docs/en/guides/getting-started.mdx
index 2ca05e41f..cf6bd755d 100644
--- a/packages/docs/src/content/docs/en/guides/getting-started.mdx
+++ b/packages/docs/src/content/docs/en/guides/getting-started.mdx
@@ -132,4 +132,4 @@ Now that you have a running SaaS API, explore what Croco can do:
- **[Events Core](/en/guides/events-core/)** — Build event-driven workflows with transactional consistency.
- **[Retry Core](/en/guides/retry-core/)** — Add resilience with retries, backoff, and circuit breakers.
- **[CLI & Generators](https://github.com/croco-dev/framework/tree/trunk/packages/create-croco-app)** — Scaffold new projects with presets for DDD API, fullstack, and more.
-- **[Package Catalog](https://github.com/croco-dev/framework#readme)** — Browse all 110 packages by domain and maturity.
+- **[Package Catalog](https://github.com/croco-dev/framework#readme)** — Browse all 111 packages by domain and maturity.
diff --git a/packages/docs/src/content/docs/en/index.mdx b/packages/docs/src/content/docs/en/index.mdx
index 380a4ee66..693cef26d 100644
--- a/packages/docs/src/content/docs/en/index.mdx
+++ b/packages/docs/src/content/docs/en/index.mdx
@@ -54,7 +54,7 @@ Every API exports as `app.lambdaHandler()`. Also runs on Docker, Cloudflare, and
boundaries. [Deploy →](/en/guides/deployment-recipes/)
- 110 packages organized by maturity: production, beta, alpha. [Browse
+ 111 packages organized by maturity: production, beta, alpha. [Browse
→](https://github.com/croco-dev/framework#readme)
From 619cd81ac5b142b6404b5768906bd92dc05b4ac9 Mon Sep 17 00:00:00 2001
From: kang-heewon
Date: Sun, 12 Jul 2026 00:21:24 +0900
Subject: [PATCH 3/5] fix: keep Astryx UI entrypoints loadable in Node
---
packages/ui-astryx/package.json | 2 +-
.../ui-astryx/src/libs/AstryxProvider.tsx | 4 ++-
scripts/package-entrypoint-smoke.mts | 31 +++++++++++++++++++
.../tests/package-entrypoint-smoke.spec.ts | 20 ++++++++++++
4 files changed, 55 insertions(+), 2 deletions(-)
diff --git a/packages/ui-astryx/package.json b/packages/ui-astryx/package.json
index 548e02863..206730563 100644
--- a/packages/ui-astryx/package.json
+++ b/packages/ui-astryx/package.json
@@ -30,7 +30,7 @@
"access": "public"
},
"scripts": {
- "build": "tsup src/index.ts --format esm,cjs --minify --clean --dts --external react --external react-dom --external @stylexjs/stylex --external @astryxdesign/core --external @astryxdesign/core/AppShell --external @astryxdesign/core/Badge --external @astryxdesign/core/Banner --external @astryxdesign/core/Button --external @astryxdesign/core/Card --external @astryxdesign/core/theme --external @astryxdesign/theme-neutral/built --external @croco/problems-core && cp styles.css dist/styles.css",
+ "build": "tsup src/index.ts --format esm,cjs --minify --clean --dts --external react --external react-dom --external @stylexjs/stylex --external @astryxdesign/core --external @astryxdesign/core/AppShell --external @astryxdesign/core/Badge --external @astryxdesign/core/Banner --external @astryxdesign/core/Button --external @astryxdesign/core/Card --external @astryxdesign/core/theme --external @astryxdesign/theme-neutral --external @croco/problems-core && cp styles.css dist/styles.css",
"lint": "oxlint .",
"test": "vitest run",
"typecheck": "tsc --noEmit"
diff --git a/packages/ui-astryx/src/libs/AstryxProvider.tsx b/packages/ui-astryx/src/libs/AstryxProvider.tsx
index ef20d3114..7bdf8e9bf 100644
--- a/packages/ui-astryx/src/libs/AstryxProvider.tsx
+++ b/packages/ui-astryx/src/libs/AstryxProvider.tsx
@@ -1,9 +1,11 @@
import type { DefinedTheme, ThemeMode } from "@astryxdesign/core/theme";
import { Theme } from "@astryxdesign/core/theme";
-import { neutralTheme } from "@astryxdesign/theme-neutral/built";
+import { neutralTheme as neutralSourceTheme } from "@astryxdesign/theme-neutral";
import type { ReactNode } from "react";
+const neutralTheme = { ...neutralSourceTheme, __built: true } satisfies DefinedTheme;
+
export type AstryxProviderProps = {
readonly children?: ReactNode;
readonly mode?: ThemeMode;
diff --git a/scripts/package-entrypoint-smoke.mts b/scripts/package-entrypoint-smoke.mts
index 66dc6b489..e0aa007d7 100644
--- a/scripts/package-entrypoint-smoke.mts
+++ b/scripts/package-entrypoint-smoke.mts
@@ -980,6 +980,13 @@ function pushConditionalTarget(
targets: SmokeTarget[],
): void {
if (typeof value === "string") {
+ if (isStaticAssetTargetPath(value)) {
+ if (condition === "import") {
+ validateStaticAssetTarget(value, fieldName, packageInfo, diagnostics);
+ }
+ return;
+ }
+
if (condition === "types" && isJsonTargetPath(value)) {
return;
}
@@ -1006,6 +1013,26 @@ function pushConditionalTarget(
pushStringTarget(specifier, target, fieldName, packageInfo, diagnostics, targets);
}
+function validateStaticAssetTarget(
+ target: string,
+ fieldName: string,
+ packageInfo: PackedPackageInfo,
+ diagnostics: string[],
+): void {
+ const packageName = packageNameFor(packageInfo.packedManifest, packageInfo.packagePath);
+
+ if (!target.startsWith("./")) {
+ diagnostics.push(`${packageName}: ${fieldName} must be a relative package file path`);
+ return;
+ }
+
+ if (
+ !packedFileExists(packageInfo.tarballPath, `package/${target.slice(2)}`, packageInfo.packageDir)
+ ) {
+ diagnostics.push(`${packageName}: ${fieldName} points to missing file ${target}`);
+ }
+}
+
function pushStringTarget(
specifier: string,
target: unknown,
@@ -1045,6 +1072,10 @@ function isJsonTargetPath(target: string): boolean {
return target.endsWith(".json");
}
+function isStaticAssetTargetPath(target: string): boolean {
+ return target.endsWith(".css");
+}
+
function writeEsmConsumer(smokeRoot: string, targets: readonly SmokeTarget[]): void {
writeFileSync(
join(smokeRoot, "esm.mjs"),
diff --git a/scripts/tests/package-entrypoint-smoke.spec.ts b/scripts/tests/package-entrypoint-smoke.spec.ts
index 819b7e7df..2c0ded936 100644
--- a/scripts/tests/package-entrypoint-smoke.spec.ts
+++ b/scripts/tests/package-entrypoint-smoke.spec.ts
@@ -40,6 +40,26 @@ describe("package-entrypoint-smoke.mts", () => {
expect(result.stdout).toContain("summary checked=1 exempt=0 skippedPrivate=1");
});
+ it("validates CSS exports as static assets without loading them in Node", () => {
+ const root = createTempRoot();
+ writeImportablePackage(root, "styled", {
+ exportsValue: {
+ ".": {
+ import: "./dist/index.mjs",
+ require: "./dist/index.js",
+ types: "./dist/index.d.ts",
+ },
+ "./styles.css": "./dist/styles.css",
+ },
+ });
+ writeFileSync(join(root, "packages", "styled", "dist", "styles.css"), ".root {}\n");
+
+ const result = runScript(root);
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("✓ @croco/styled: esm 1, cjs 1, types 1");
+ });
+
it("requires the root package manager pin for isolated consumers", () => {
const root = createTempRoot({ packageManager: false });
writeImportablePackage(root, "valid");
From 1a5a5a9b87fc76d95a048146b19f2608b35bddb7 Mon Sep 17 00:00:00 2001
From: kang-heewon
Date: Sun, 12 Jul 2026 02:21:02 +0900
Subject: [PATCH 4/5] fix: validate scaffold variants without weakening first
success
---
packages/create-croco-app/README.md | 4 +-
scripts/first-success-verify.mts | 66 ++++++++++++++++------
scripts/tests/first-success-verify.spec.ts | 35 +++++++++++-
3 files changed, 87 insertions(+), 18 deletions(-)
diff --git a/packages/create-croco-app/README.md b/packages/create-croco-app/README.md
index 8f2bf7764..1f8892d67 100644
--- a/packages/create-croco-app/README.md
+++ b/packages/create-croco-app/README.md
@@ -35,7 +35,9 @@ npx create-croco-app@latest my-app \
--api-hosting standalone \
--web-apps web \
--frontend-deploy vite-spa \
- --ui astryx
+ --ui astryx \
+ --no-install \
+ --no-git
```
The generated app imports Astryx's prebuilt CSS, so it does not add a StyleX compiler plugin.
diff --git a/scripts/first-success-verify.mts b/scripts/first-success-verify.mts
index d55392b0a..3d976b3df 100644
--- a/scripts/first-success-verify.mts
+++ b/scripts/first-success-verify.mts
@@ -53,6 +53,13 @@ type ExtractedCommand = {
readonly line: number;
};
+type PublicCreateCommandValidation = {
+ readonly failures: string[];
+ readonly isCanonical: boolean;
+ readonly line: number;
+ readonly resolvedJourney: string | undefined;
+};
+
type RootReadmeToolingCommand = {
readonly command: string;
readonly line: number;
@@ -267,8 +274,9 @@ function extractCreateCrocoAppCommands(content: string): ExtractedCommand[] {
const lines = content.split(/\r?\n/);
let inFence = false;
- for (const [index, rawLine] of lines.entries()) {
- const line = normalizeMarkdownShellLine(rawLine);
+ for (let index = 0; index < lines.length; index += 1) {
+ const rawLine = lines[index];
+ let line = normalizeMarkdownShellLine(rawLine ?? "");
if (line.startsWith("```")) {
inFence = !inFence;
@@ -277,7 +285,13 @@ function extractCreateCrocoAppCommands(content: string): ExtractedCommand[] {
if (inFence) {
if (isCreateCrocoAppCommandSnippet(line)) {
- commands.push({ command: line, line: index + 1 });
+ const commandLine = index + 1;
+ while (line.endsWith("\\") && index + 1 < lines.length) {
+ index += 1;
+ const continuation = normalizeMarkdownShellLine(lines[index] ?? "");
+ line = `${line.slice(0, -1).trimEnd()} ${continuation.trim()}`;
+ }
+ commands.push({ command: line, line: commandLine });
}
continue;
}
@@ -383,14 +397,21 @@ function isCreateCrocoAppExecutable(arg: string | undefined): boolean {
async function validatePublicCreateCommand(
extracted: ExtractedCommand,
source: PublicDocsSource,
-): Promise {
+): Promise {
const cliArgs = extractCreateCrocoAppArgs(splitShellWords(extracted.command));
if (!cliArgs) {
- return [`${source.label}:${extracted.line} could not parse create-croco-app command`];
+ return {
+ failures: [`${source.label}:${extracted.line} could not parse create-croco-app command`],
+ isCanonical: false,
+ line: extracted.line,
+ resolvedJourney: undefined,
+ };
}
const failures: string[] = [];
+ let isCanonical = false;
+ let resolvedJourney: string | undefined;
if (source.requireSkipFlags) {
const missingSkipFlags = ["--no-install", "--no-git"].filter((flag) => !cliArgs.includes(flag));
@@ -423,11 +444,8 @@ async function validatePublicCreateCommand(
}
const options = normalizeNonInteractiveOptions(cliOptions);
- if (options.goal !== "saas-api" || options.preset !== "saas") {
- failures.push(
- `${source.label}:${extracted.line} create-croco-app command resolves to ${options.goal ? `goal ${options.goal}` : `preset ${options.preset}`}, not the canonical goal saas-api journey`,
- );
- }
+ resolvedJourney = options.goal ? `goal ${options.goal}` : `preset ${options.preset}`;
+ isCanonical = options.goal === "saas-api" && options.preset === "saas";
const targetDir = join(tempRoot, options.projectName);
await generate(targetDir, {
@@ -435,7 +453,9 @@ async function validatePublicCreateCommand(
installDeps: false,
initGit: false,
});
- failures.push(...validateGeneratedSaasJourney(targetDir, source, extracted));
+ if (isCanonical) {
+ failures.push(...validateGeneratedSaasJourney(targetDir, source, extracted));
+ }
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
failures.push(
@@ -445,7 +465,7 @@ async function validatePublicCreateCommand(
rmSync(tempRoot, { force: true, recursive: true });
}
- return failures;
+ return { failures, isCanonical, line: extracted.line, resolvedJourney };
}
function validateGeneratedSaasJourney(
@@ -1042,9 +1062,23 @@ console.log("\n📋 E. Docs contract\n");
return [`${source.label} missing a public create-croco-app command`];
}
- return (
- await Promise.all(commands.map((command) => validatePublicCreateCommand(command, source)))
- ).flat();
+ const validations = await Promise.all(
+ commands.map((command) => validatePublicCreateCommand(command, source)),
+ );
+ const failures = validations.flatMap((validation) => validation.failures);
+
+ if (!validations.some((validation) => validation.isCanonical)) {
+ const resolved = validations.find(
+ (validation) => validation.resolvedJourney !== undefined,
+ );
+ failures.push(
+ resolved
+ ? `${source.label}:${resolved.line} create-croco-app command resolves to ${resolved.resolvedJourney}, not the canonical goal saas-api journey`
+ : `${source.label} is missing a valid canonical goal saas-api create-croco-app command`,
+ );
+ }
+
+ return failures;
}),
)
).flat();
@@ -1056,7 +1090,7 @@ console.log("\n📋 E. Docs contract\n");
} else {
pass(
"D3",
- "Public create-croco-app commands normalize and generate the canonical saas-api journey",
+ "Public create-croco-app commands validate and each source retains the canonical saas-api journey",
);
}
diff --git a/scripts/tests/first-success-verify.spec.ts b/scripts/tests/first-success-verify.spec.ts
index 21423cab7..97fbd5b41 100644
--- a/scripts/tests/first-success-verify.spec.ts
+++ b/scripts/tests/first-success-verify.spec.ts
@@ -54,6 +54,7 @@ type FixtureOptions = {
readonly omitReleaseFirstSuccessCommand?: string;
readonly omittedReadmeToolingCommand?: string;
readonly packageReadmeCommand?: string | null;
+ readonly packageReadmeExtraCommand?: string;
readonly rootSaasSmokeScript?: string | null;
readonly saasReadmeCommands?: readonly string[];
readonly staleReadmeRoadmapStatus?: boolean;
@@ -136,6 +137,28 @@ describe("first-success-verify.mts", () => {
);
});
+ it("allows an additional valid noncanonical scaffold command beside the canonical journey", () => {
+ const root = createFixture({
+ packageReadmeExtraCommand: [
+ "npx create-croco-app@latest my-app \\",
+ " --preset ddd-fullstack \\",
+ " --scope @myorg \\",
+ " --api graphql \\",
+ " --api-hosting standalone \\",
+ " --web-apps web \\",
+ " --frontend-deploy vite-spa \\",
+ " --ui astryx \\",
+ " --no-install \\",
+ " --no-git",
+ ].join("\n"),
+ });
+
+ const result = runScript(root);
+
+ expect(result.status).toBe(0);
+ expect(result.stdout).toContain("first-success contract verification PASSED");
+ });
+
it("fails when public package-count claims drift from the generated report", () => {
const root = createFixture({ gettingStartedPackageCount: 98 });
@@ -572,7 +595,17 @@ function createFixture(options: FixtureOptions = {}): string {
root,
"packages/create-croco-app/README.md",
packageReadmeCommand
- ? ["# create-croco-app", "", "```bash", packageReadmeCommand, "```", ""].join("\n")
+ ? [
+ "# create-croco-app",
+ "",
+ "```bash",
+ packageReadmeCommand,
+ "```",
+ ...(options.packageReadmeExtraCommand
+ ? ["", "```bash", options.packageReadmeExtraCommand, "```"]
+ : []),
+ "",
+ ].join("\n")
: "# create-croco-app\n",
);
writeFile(
From ae4faa41c60472925d0db299e47b92a4007014f0 Mon Sep 17 00:00:00 2001
From: kang-heewon
Date: Sun, 12 Jul 2026 03:00:04 +0900
Subject: [PATCH 5/5] fix: scope Astryx recovery actions and CSS entrypoints
correctly
---
packages/ui-astryx/src/index.ts | 8 +++----
.../ui-astryx/src/libs/AstryxAuthState.tsx | 12 +++++++++--
packages/ui-astryx/src/tests/AstryxUi.spec.ts | 21 ++++++++++++++++++-
scripts/package-entrypoint-smoke.mts | 4 ++++
.../tests/package-entrypoint-smoke.spec.ts | 7 ++++++-
5 files changed, 44 insertions(+), 8 deletions(-)
diff --git a/packages/ui-astryx/src/index.ts b/packages/ui-astryx/src/index.ts
index c88fa79bb..8f210ed71 100644
--- a/packages/ui-astryx/src/index.ts
+++ b/packages/ui-astryx/src/index.ts
@@ -1,11 +1,11 @@
-export type { AstryxAppShellProps } from "./libs/AstryxAppShell";
export { AstryxAppShell } from "./libs/AstryxAppShell";
-export type { AstryxAuthStateKind, AstryxAuthStateProps } from "./libs/AstryxAuthState";
export { AstryxAuthState, toAstryxAuthStateProps } from "./libs/AstryxAuthState";
-export type { AstryxProblemViewProps } from "./libs/AstryxProblemView";
export { AstryxProblemView } from "./libs/AstryxProblemView";
-export type { AstryxProviderProps } from "./libs/AstryxProvider";
export { AstryxProvider } from "./libs/AstryxProvider";
+export type { AstryxAppShellProps } from "./libs/AstryxAppShell";
+export type { AstryxAuthStateKind, AstryxAuthStateProps } from "./libs/AstryxAuthState";
+export type { AstryxProblemViewProps } from "./libs/AstryxProblemView";
+export type { AstryxProviderProps } from "./libs/AstryxProvider";
export type {
AstryxProblemRecoveryAction,
AstryxRecoveryAction,
diff --git a/packages/ui-astryx/src/libs/AstryxAuthState.tsx b/packages/ui-astryx/src/libs/AstryxAuthState.tsx
index 524a41d4b..43018df32 100644
--- a/packages/ui-astryx/src/libs/AstryxAuthState.tsx
+++ b/packages/ui-astryx/src/libs/AstryxAuthState.tsx
@@ -1,8 +1,8 @@
-import type { BadgeVariant } from "@astryxdesign/core/Badge";
import { Badge } from "@astryxdesign/core/Badge";
import { Button } from "@astryxdesign/core/Button";
import { Card } from "@astryxdesign/core/Card";
+import type { BadgeVariant } from "@astryxdesign/core/Badge";
import type { ProblemDetails } from "@croco/problems-core";
import type { AstryxRecoveryAction, AstryxSession, AstryxSessionState } from "./crocoUiTypes";
@@ -87,6 +87,13 @@ function actionButton(action: AstryxRecoveryAction) {
);
}
+function appliesToProblem(action: AstryxRecoveryAction, problem: ProblemDetails | undefined) {
+ return (
+ action.problemCodes === undefined ||
+ (problem !== undefined && action.problemCodes.includes(problem.code))
+ );
+}
+
export function AstryxAuthState({
detail,
problem,
@@ -95,6 +102,7 @@ export function AstryxAuthState({
state,
}: AstryxAuthStateProps) {
const badge = stateBadge(state);
+ const visibleActions = recoveryActions.filter((action) => appliesToProblem(action, problem));
return (
{detail ?? problem?.detail ?? defaultDetail(state, session)}
{session?.provider === undefined ? null : Provider: {session.provider}
}
{problem === undefined ? null : Problem: {problem.code}
}
- {recoveryActions.length === 0 ? null : {recoveryActions.map(actionButton)}
}
+ {visibleActions.length === 0 ? null : {visibleActions.map(actionButton)}
}
);
}
diff --git a/packages/ui-astryx/src/tests/AstryxUi.spec.ts b/packages/ui-astryx/src/tests/AstryxUi.spec.ts
index 437ddba2d..c62fd19be 100644
--- a/packages/ui-astryx/src/tests/AstryxUi.spec.ts
+++ b/packages/ui-astryx/src/tests/AstryxUi.spec.ts
@@ -65,6 +65,14 @@ describe("@croco/ui-astryx", () => {
const html = renderToStaticMarkup(
createElement(AstryxAuthState, {
detail: "Sign in to manage this tenant.",
+ recoveryActions: [
+ { id: "sign-in", label: "Sign in" },
+ {
+ id: "provider-status",
+ label: "Provider status",
+ problemCodes: [problem.code],
+ },
+ ],
state: "signed-out",
}),
);
@@ -72,6 +80,8 @@ describe("@croco/ui-astryx", () => {
expect(html).toContain('data-croco-auth-state="signed-out"');
expect(html).toContain("Signed out");
expect(html).toContain("Sign in to manage this tenant.");
+ expect(html).toContain("Sign in");
+ expect(html).not.toContain("Provider status");
});
it("maps Croco frontend session contracts without treating unavailable state as signed out", () => {
@@ -80,7 +90,15 @@ describe("@croco/ui-astryx", () => {
const sessionState: FrontendSessionState = {
kind: "unavailable",
problem,
- recoveryActions: [{ href: "/status", id: "status", label: "Service status" }],
+ recoveryActions: [
+ {
+ href: "/status",
+ id: "status",
+ label: "Service status",
+ problemCodes: [problem.code],
+ },
+ { id: "ignored", label: "Ignore me", problemCodes: ["OTHER_PROBLEM"] },
+ ],
};
const props = mapFrontendSessionState(sessionState);
@@ -91,5 +109,6 @@ describe("@croco/ui-astryx", () => {
expect(html).toContain(problem.code);
expect(html).toContain("Service status");
expect(html).toContain('href="/status"');
+ expect(html).not.toContain("Ignore me");
});
});
diff --git a/scripts/package-entrypoint-smoke.mts b/scripts/package-entrypoint-smoke.mts
index e0aa007d7..8f3552d02 100644
--- a/scripts/package-entrypoint-smoke.mts
+++ b/scripts/package-entrypoint-smoke.mts
@@ -1006,6 +1006,10 @@ function pushConditionalTarget(
if (target === undefined && condition === "require") {
return;
}
+ if (typeof target === "string" && isStaticAssetTargetPath(target)) {
+ validateStaticAssetTarget(target, fieldName, packageInfo, diagnostics);
+ return;
+ }
if (condition === "types" && typeof target === "string" && isJsonTargetPath(target)) {
return;
}
diff --git a/scripts/tests/package-entrypoint-smoke.spec.ts b/scripts/tests/package-entrypoint-smoke.spec.ts
index 2c0ded936..dea69de94 100644
--- a/scripts/tests/package-entrypoint-smoke.spec.ts
+++ b/scripts/tests/package-entrypoint-smoke.spec.ts
@@ -50,6 +50,11 @@ describe("package-entrypoint-smoke.mts", () => {
types: "./dist/index.d.ts",
},
"./styles.css": "./dist/styles.css",
+ "./conditional-styles": {
+ import: "./dist/styles.css",
+ require: "./dist/styles.css",
+ types: "./dist/index.d.ts",
+ },
},
});
writeFileSync(join(root, "packages", "styled", "dist", "styles.css"), ".root {}\n");
@@ -57,7 +62,7 @@ describe("package-entrypoint-smoke.mts", () => {
const result = runScript(root);
expect(result.status).toBe(0);
- expect(result.stdout).toContain("✓ @croco/styled: esm 1, cjs 1, types 1");
+ expect(result.stdout).toContain("✓ @croco/styled: esm 1, cjs 1, types 2");
});
it("requires the root package manager pin for isolated consumers", () => {