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 ( +