diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 1fca9e45..eefbee21 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -10,78 +10,82 @@ on:
workflow_dispatch:
jobs:
- build:
+ dotnet:
+ name: .NET samples
runs-on: ubuntu-latest
steps:
- - name: Checkout code
- uses: actions/checkout@v2
+ - name: Checkout code
+ uses: actions/checkout@v4
- - name: Setup .NET
- uses: actions/setup-dotnet@v2
- with:
- dotnet-version: '10.0.x' # Use .NET 10
+ - name: Setup .NET
+ uses: actions/setup-dotnet@v4
+ with:
+ global-json-file: global.json
- - name: Restore dependencies
- run: dotnet restore Samples.slnx
+ - name: Restore dependencies
+ run: dotnet restore Samples.slnx
- - name: Build
- run: dotnet build Samples.slnx --configuration Release --no-restore
+ - name: Build Debug and generate proxies
+ run: dotnet build Samples.slnx --configuration Debug --no-restore
- - name: Run tests
- run: dotnet test Samples.slnx --configuration Release --no-build --verbosity normal
+ - name: Run focused Debug specifications
+ run: |
+ for project in \
+ Arc/React/Arc.React.Specs/Arc.React.Specs.csproj \
+ Chronicle/Backend/Backend.Specs/Backend.Specs.csproj \
+ Chronicle/CrossStore/CrossStore.Specs/CrossStore.Specs.csproj \
+ Chronicle/MultiTenancy/MultiTenancy.Specs/MultiTenancy.Specs.csproj \
+ Chronicle/OperationsDiagnosis/OperationsDiagnosis.Specs/OperationsDiagnosis.Specs.csproj \
+ Chronicle/Processing/Processing.Specs/Processing.Specs.csproj
+ do
+ dotnet test "$project" --configuration Debug --no-build --verbosity normal
+ done
- frontend-install:
- runs-on: ubuntu-latest
+ - name: Build Release without regenerating proxies
+ run: dotnet build Samples.slnx --configuration Release --no-restore -p:CratisProxiesOutputPath=
- steps:
- - name: Checkout code
- uses: actions/checkout@v2
-
- - name: Setup Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '20'
- - uses: actions/cache@v3
- id: yarn-cache
- with:
- path: |
- .yarn/cache
- **/node_modules
- **/.eslintcache
- **/yarn.lock
- key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}
- - name: Install dependencies
- run: yarn
-
- frontend-build:
+ frontend:
+ name: Frontend — ${{ matrix.project }}
runs-on: ubuntu-latest
- needs: frontend-install
strategy:
+ fail-fast: false
matrix:
- project: [Library]
+ project:
+ - Arc/React
+ - Library/Lending
+ - Library/Members
steps:
- - name: Checkout code
- uses: actions/checkout@v2
-
- - name: Setup Node.js
- uses: actions/setup-node@v3
- with:
- node-version: '20'
- - uses: actions/cache@v3
- id: yarn-cache
- with:
- path: |
- .yarn/cache
- **/node_modules
- **/.eslintcache
- **/yarn.lock
- key: ${{ runner.os }}-yarn-${{ hashFiles('**/package.json') }}
-
- - name: Install dependencies
- run: yarn
-
- - name: Build ${{ matrix.project }}
- run: yarn build
- working-directory: ${{ matrix.project }}
+ - name: Checkout code
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '23'
+
+ - name: Enable Corepack
+ run: corepack enable
+
+ - name: Install dependencies
+ run: yarn install
+
+ - name: Validate sample catalog
+ run: yarn samples:validate
+
+ - name: Lint ${{ matrix.project }}
+ working-directory: ${{ matrix.project }}
+ run: yarn lint:ci
+
+ - name: Compile ${{ matrix.project }}
+ working-directory: ${{ matrix.project }}
+ run: yarn compile
+
+ - name: Test ${{ matrix.project }}
+ working-directory: ${{ matrix.project }}
+ run: yarn test --passWithNoTests
+
+ - name: Build ${{ matrix.project }}
+ working-directory: ${{ matrix.project }}
+ run: yarn build
diff --git a/.github/workflows/update-dependencies.yml b/.github/workflows/update-dependencies.yml
index 3bca81ad..0568e9fc 100644
--- a/.github/workflows/update-dependencies.yml
+++ b/.github/workflows/update-dependencies.yml
@@ -70,7 +70,10 @@ jobs:
runs-on: ubuntu-latest
strategy:
matrix:
- folder: [Library]
+ folder:
+ - Arc/React
+ - Library/Lending
+ - Library/Members
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -80,7 +83,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: '20'
+ node-version: '23'
- name: Pull latest changes
run: git pull --rebase
diff --git a/Arc/React/.frontend/App.tsx b/Arc/React/.frontend/App.tsx
new file mode 100644
index 00000000..89f364e2
--- /dev/null
+++ b/Arc/React/.frontend/App.tsx
@@ -0,0 +1,18 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { Arc } from '@cratis/arc.react';
+import { DialogComponents } from '@cratis/arc.react/dialogs';
+import { CratisComponentsProvider } from '@cratis/components/Common';
+import { BusyIndicatorDialog, ConfirmationDialog } from '@cratis/components/Dialogs';
+import { Board } from '../Ideas/Board/Board';
+
+export const App = () => (
+
+
+
+
+
+
+
+);
diff --git a/Arc/React/.frontend/index.css b/Arc/React/.frontend/index.css
new file mode 100644
index 00000000..2c2e6740
--- /dev/null
+++ b/Arc/React/.frontend/index.css
@@ -0,0 +1,58 @@
+/* Copyright (c) Cratis. All rights reserved.
+ * Licensed under the MIT license. See LICENSE file in the project root for full license information.
+ */
+
+:root {
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ color: var(--text-color);
+ background: var(--surface-ground);
+ font-synthesis: none;
+ text-rendering: optimizeLegibility;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ min-height: 100%;
+ background: var(--surface-ground);
+}
+
+body {
+ min-width: 320px;
+ min-height: 100vh;
+ margin: 0;
+ background:
+ radial-gradient(circle at 86% 7%, color-mix(in srgb, var(--primary-color) 15%, transparent), transparent 32rem),
+ radial-gradient(circle at 8% 44%, color-mix(in srgb, var(--highlight-bg) 60%, transparent), transparent 36rem),
+ var(--surface-ground);
+}
+
+button,
+input,
+textarea {
+ font: inherit;
+}
+
+button,
+a {
+ -webkit-tap-highlight-color: transparent;
+}
+
+#root {
+ min-height: 100vh;
+}
+
+.visually-hidden {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
diff --git a/Arc/React/.frontend/index.html b/Arc/React/.frontend/index.html
new file mode 100644
index 00000000..8046e63b
--- /dev/null
+++ b/Arc/React/.frontend/index.html
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+ Idea Loom · Arc + React
+
+
+
+
+
+
diff --git a/Arc/React/.frontend/index.tsx b/Arc/React/.frontend/index.tsx
new file mode 100644
index 00000000..20c042f6
--- /dev/null
+++ b/Arc/React/.frontend/index.tsx
@@ -0,0 +1,24 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import '@cratis/components/tokens';
+import '@cratis/components/styles';
+import 'primeicons/primeicons.css';
+import 'primereact/resources/primereact.min.css';
+import 'primereact/resources/themes/lara-light-blue/theme.css';
+import 'reflect-metadata';
+import './index.css';
+import { Bindings } from '@cratis/arc.react.mvvm';
+import { configure as configureMobx } from 'mobx';
+import React from 'react';
+import ReactDOM from 'react-dom/client';
+import { App } from './App';
+
+Bindings.initialize();
+configureMobx({ enforceActions: 'never' });
+
+ReactDOM.createRoot(document.getElementById('root')!).render(
+
+
+
+);
diff --git a/Arc/React/.frontend/tsconfig.json b/Arc/React/.frontend/tsconfig.json
new file mode 100644
index 00000000..123d0166
--- /dev/null
+++ b/Arc/React/.frontend/tsconfig.json
@@ -0,0 +1,21 @@
+{
+ "extends": "../../../.frontend/tsconfig.base.json",
+ "compilerOptions": {
+ "allowImportingTsExtensions": true,
+ "baseUrl": "..",
+ "moduleResolution": "bundler",
+ "types": [
+ "chai",
+ "react",
+ "react-dom",
+ "vitest/globals"
+ ]
+ },
+ "include": [
+ "../**/*.ts",
+ "../**/*.tsx"
+ ],
+ "exclude": [
+ "vite.config.ts"
+ ]
+}
diff --git a/Arc/React/.frontend/tsconfig.node.json b/Arc/React/.frontend/tsconfig.node.json
new file mode 100644
index 00000000..bfe32424
--- /dev/null
+++ b/Arc/React/.frontend/tsconfig.node.json
@@ -0,0 +1,6 @@
+{
+ "extends": "../../../.frontend/tsconfig.node.json",
+ "include": [
+ "vite.config.ts"
+ ]
+}
diff --git a/Arc/React/.frontend/vite.config.ts b/Arc/React/.frontend/vite.config.ts
new file mode 100644
index 00000000..42d536b1
--- /dev/null
+++ b/Arc/React/.frontend/vite.config.ts
@@ -0,0 +1,51 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { EmitMetadataPlugin } from '@cratis/arc.vite';
+import react from '@vitejs/plugin-react';
+import { fileURLToPath } from 'node:url';
+import type { PluginOption } from 'vite';
+import { defineConfig } from 'vitest/config';
+
+const backend = 'http://localhost:5064';
+
+export default defineConfig({
+ root: fileURLToPath(new URL('./', import.meta.url)),
+ build: {
+ outDir: '../wwwroot',
+ emptyOutDir: true,
+ assetsDir: 'assets',
+ target: 'esnext',
+ modulePreload: false,
+ chunkSizeWarningLimit: 700,
+ },
+ plugins: [
+ react(),
+ // SAFETY: Arc and this app share Vite's plugin lifecycle; the cast only bridges their bundled Vite type identities.
+ EmitMetadataPlugin({
+ tsconfigPath: fileURLToPath(new URL('./tsconfig.json', import.meta.url)),
+ }) as unknown as PluginOption,
+ ],
+ server: {
+ host: true,
+ port: 5173,
+ open: false,
+ proxy: {
+ '/api': {
+ target: backend,
+ ws: true,
+ },
+ '/.cratis': {
+ target: backend,
+ ws: true,
+ },
+ },
+ },
+ test: {
+ globals: true,
+ environment: 'node',
+ include: ['../**/for_*/when_*/**/*.ts', '../**/for_*/when_*.ts'],
+ exclude: ['../wwwroot/**', '../bin/**', '../obj/**', '../node_modules/**'],
+ setupFiles: fileURLToPath(new URL('../../../.frontend/vitest.setup.ts', import.meta.url)),
+ },
+});
diff --git a/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj b/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj
new file mode 100644
index 00000000..c60d6e0f
--- /dev/null
+++ b/Arc/React/Arc.React.Specs/Arc.React.Specs.csproj
@@ -0,0 +1,26 @@
+
+
+ false
+ true
+ Arc.React.Specs
+
+ $(NoWarn);CA1001;IDE1006;SA1134
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+ CratisProxiesOutputPath=
+
+
+
diff --git a/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs b/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs
new file mode 100644
index 00000000..ed463a66
--- /dev/null
+++ b/Arc/React/Arc.React.Specs/for_IdeaStore/when_capturing_an_idea/with_valid_details.cs
@@ -0,0 +1,21 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Arc.React.Ideas.Board;
+using Cratis.Specifications;
+using Xunit;
+
+namespace Arc.React.Specs.for_IdeaStore.when_capturing_an_idea;
+
+public class with_valid_details : Specification
+{
+ readonly IdeaStore _store = new();
+ readonly IdeaId _ideaId = IdeaId.New();
+
+ void Because() => new CaptureIdea(_ideaId, "Make setup visible", "Show the shortest path from clone to a running slice.").Handle(_store);
+ void Destroy() => _store.Dispose();
+
+ [Fact] void should_add_one_idea() => _store.Current.Count.ShouldEqual(1);
+ [Fact] void should_keep_the_assigned_identifier() => _store.Current.Single().Id.ShouldEqual(_ideaId);
+ [Fact] void should_keep_the_title() => _store.Current.Single().Title.ShouldEqual((IdeaTitle)"Make setup visible");
+}
diff --git a/Arc/React/Arc.React.csproj b/Arc/React/Arc.React.csproj
new file mode 100644
index 00000000..866565f0
--- /dev/null
+++ b/Arc/React/Arc.React.csproj
@@ -0,0 +1,28 @@
+
+
+ Arc.React
+ Arc.React
+ true
+ true
+ true
+ Exe
+ $(NoWarn);CA1515
+ $(MSBuildThisFileDirectory)
+ 2
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Arc/React/Ideas/Board/Board.cs b/Arc/React/Ideas/Board/Board.cs
new file mode 100644
index 00000000..efddf1ef
--- /dev/null
+++ b/Arc/React/Ideas/Board/Board.cs
@@ -0,0 +1,87 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Reactive.Subjects;
+using System.Threading;
+using Cratis.Arc.Authorization;
+using Cratis.Arc.Commands.ModelBound;
+using Cratis.Arc.Queries.ModelBound;
+
+namespace Arc.React.Ideas.Board;
+
+///
+/// Captures a new idea in the current-state board.
+///
+/// The identifier assigned by the client.
+/// The concise idea title.
+/// The detail that makes the idea useful.
+[Command, AllowAnonymous]
+public record CaptureIdea(IdeaId Id, IdeaTitle Title, IdeaSummary Summary)
+{
+ ///
+ /// Stores the idea directly without appending an event.
+ ///
+ /// The current-state idea store.
+ public void Handle(IdeaStore store) => store.Capture(new(Id, Title, Summary));
+}
+
+///
+/// Represents an idea shown on the board.
+///
+/// The idea identifier.
+/// The concise idea title.
+/// The useful idea detail.
+[ReadModel, AllowAnonymous]
+public record Idea(IdeaId Id, IdeaTitle Title, IdeaSummary Summary)
+{
+ ///
+ /// Observes all captured ideas and pushes the current board on each change.
+ ///
+ /// The current-state idea store.
+ /// A live sequence containing the current ideas.
+ public static ISubject> ObserveIdeas(IdeaStore store) => store.Observe();
+}
+
+///
+/// Holds the current idea board in memory for this focused sample.
+///
+public sealed class IdeaStore : IDisposable
+{
+ readonly Lock _gate = new();
+ readonly BehaviorSubject> _ideas = new([]);
+
+ ///
+ /// Gets a snapshot of the current ideas.
+ ///
+ public IReadOnlyList Current
+ {
+ get
+ {
+ lock (_gate)
+ {
+ return [.. _ideas.Value];
+ }
+ }
+ }
+
+ ///
+ /// Captures an idea and publishes a fresh board snapshot.
+ ///
+ /// The idea to capture.
+ public void Capture(Idea idea)
+ {
+ lock (_gate)
+ {
+ _ideas.OnNext([idea, .. _ideas.Value]);
+ }
+ }
+
+ ///
+ /// Observes the current board and every subsequent change.
+ ///
+ /// The live idea sequence.
+ public ISubject> Observe() => _ideas;
+
+ ///
+ public void Dispose() => _ideas.Dispose();
+}
diff --git a/Arc/React/Ideas/Board/Board.css b/Arc/React/Ideas/Board/Board.css
new file mode 100644
index 00000000..6b223f14
--- /dev/null
+++ b/Arc/React/Ideas/Board/Board.css
@@ -0,0 +1,337 @@
+/* Copyright (c) Cratis. All rights reserved.
+ * Licensed under the MIT license. See LICENSE file in the project root for full license information.
+ */
+
+.idea-loom {
+ width: min(1180px, calc(100% - 2rem));
+ min-height: 100vh;
+ margin: 0 auto;
+ padding: 1.25rem 0 2rem;
+}
+
+.app-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.5rem 0 2.75rem;
+}
+
+.brand {
+ display: inline-flex;
+ gap: 0.7rem;
+ align-items: center;
+ color: var(--text-color);
+ font-weight: 750;
+ letter-spacing: -0.02em;
+ text-decoration: none;
+}
+
+.brand__mark {
+ display: grid;
+ width: 2.4rem;
+ height: 2.4rem;
+ place-items: center;
+ color: var(--primary-color-text);
+ background: var(--primary-color);
+ border-radius: 0.8rem;
+ box-shadow: 0 0.6rem 1.6rem color-mix(in srgb, var(--primary-color) 25%, transparent);
+ font-size: 0.78rem;
+ letter-spacing: -0.04em;
+}
+
+.architecture-pill {
+ display: inline-flex;
+ gap: 0.45rem;
+ align-items: center;
+ padding: 0.55rem 0.85rem;
+ color: var(--text-color-secondary);
+ background: color-mix(in srgb, var(--surface-card) 78%, transparent);
+ border: 1px solid var(--surface-border);
+ border-radius: 999px;
+ backdrop-filter: blur(14px);
+ font-size: 0.8rem;
+ font-weight: 650;
+}
+
+.architecture-pill i {
+ color: var(--primary-color);
+}
+
+.hero {
+ display: grid;
+ grid-template-columns: minmax(0, 1.3fr) minmax(17rem, 0.7fr);
+ gap: clamp(2rem, 8vw, 8rem);
+ align-items: end;
+ padding: clamp(1rem, 4vw, 3.5rem) 0 clamp(3.5rem, 8vw, 6.5rem);
+}
+
+.eyebrow {
+ display: inline-block;
+ margin-bottom: 0.8rem;
+ color: var(--primary-color);
+ font-size: 0.74rem;
+ font-weight: 800;
+ letter-spacing: 0.14em;
+ text-transform: uppercase;
+}
+
+.hero h1 {
+ max-width: 12ch;
+ margin: 0;
+ color: var(--text-color);
+ font-size: clamp(3.5rem, 8vw, 7.25rem);
+ font-weight: 780;
+ letter-spacing: -0.075em;
+ line-height: 0.88;
+}
+
+.hero h1 em {
+ color: var(--primary-color);
+ font-family: Georgia, "Times New Roman", serif;
+ font-weight: 500;
+}
+
+.hero__copy > p {
+ max-width: 38rem;
+ margin: 1.75rem 0 0;
+ color: var(--text-color-secondary);
+ font-size: clamp(1rem, 1.5vw, 1.18rem);
+ line-height: 1.7;
+}
+
+.hero__metrics {
+ display: grid;
+ gap: 0;
+ overflow: hidden;
+ background: color-mix(in srgb, var(--surface-card) 88%, transparent);
+ border: 1px solid var(--surface-border);
+ border-radius: 1.35rem;
+ box-shadow: 0 1.5rem 4rem color-mix(in srgb, var(--surface-900) 8%, transparent);
+ backdrop-filter: blur(18px);
+}
+
+.hero__metrics div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ padding: 1.15rem 1.35rem;
+ border-bottom: 1px solid var(--surface-border);
+}
+
+.hero__metrics div:last-child {
+ border-bottom: 0;
+}
+
+.hero__metrics strong {
+ color: var(--text-color);
+ font-size: 1.15rem;
+}
+
+.hero__metrics span {
+ color: var(--text-color-secondary);
+ font-size: 0.78rem;
+}
+
+.board-surface {
+ padding: clamp(1.25rem, 3vw, 2.25rem);
+ background: color-mix(in srgb, var(--surface-card) 92%, transparent);
+ border: 1px solid var(--surface-border);
+ border-radius: 1.6rem;
+ box-shadow: 0 1.8rem 5rem color-mix(in srgb, var(--surface-900) 9%, transparent);
+ backdrop-filter: blur(20px);
+}
+
+.board-surface__heading {
+ display: flex;
+ gap: 1rem;
+ align-items: flex-start;
+ justify-content: space-between;
+}
+
+.board-surface__heading h2 {
+ margin: 0;
+ color: var(--text-color);
+ font-size: clamp(1.65rem, 3vw, 2.4rem);
+ letter-spacing: -0.045em;
+}
+
+.search-box {
+ display: flex;
+ gap: 0.65rem;
+ align-items: center;
+ width: min(30rem, 100%);
+ margin: 1.6rem 0;
+ padding: 0.75rem 0.95rem;
+ color: var(--text-color-secondary);
+ background: var(--surface-ground);
+ border: 1px solid var(--surface-border);
+ border-radius: 0.9rem;
+ transition: border-color 150ms ease, box-shadow 150ms ease;
+}
+
+.search-box:focus-within {
+ border-color: var(--primary-color);
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary-color) 14%, transparent);
+}
+
+.search-box input {
+ width: 100%;
+ color: var(--text-color);
+ background: transparent;
+ border: 0;
+ outline: 0;
+}
+
+.search-box input::placeholder {
+ color: var(--text-color-secondary);
+}
+
+.idea-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.85rem;
+}
+
+.idea-card {
+ display: grid;
+ grid-template-columns: auto 1fr;
+ gap: 1rem;
+ min-height: 10rem;
+ padding: 1.15rem;
+ background: var(--surface-card);
+ border: 1px solid var(--surface-border);
+ border-radius: 1rem;
+ transition: border-color 160ms ease, transform 160ms ease, box-shadow 160ms ease;
+}
+
+.idea-card:hover {
+ border-color: color-mix(in srgb, var(--primary-color) 40%, var(--surface-border));
+ box-shadow: 0 0.9rem 2.3rem color-mix(in srgb, var(--surface-900) 7%, transparent);
+ transform: translateY(-2px);
+}
+
+.idea-card__number {
+ display: grid;
+ width: 2.5rem;
+ height: 2.5rem;
+ place-items: center;
+ color: var(--primary-color);
+ background: var(--highlight-bg);
+ border-radius: 0.75rem;
+ font-size: 0.73rem;
+ font-weight: 800;
+}
+
+.idea-card h3 {
+ margin: 0.25rem 0 0.6rem;
+ color: var(--text-color);
+ font-size: 1.1rem;
+ letter-spacing: -0.02em;
+}
+
+.idea-card p {
+ margin: 0;
+ color: var(--text-color-secondary);
+ line-height: 1.62;
+}
+
+.empty-state,
+.board-state {
+ display: grid;
+ min-height: 18rem;
+ place-items: center;
+ align-content: center;
+ padding: 2rem;
+ color: var(--text-color-secondary);
+ text-align: center;
+ background: color-mix(in srgb, var(--surface-ground) 68%, transparent);
+ border: 1px dashed var(--surface-border);
+ border-radius: 1rem;
+}
+
+.empty-state__icon {
+ display: grid;
+ width: 3.6rem;
+ height: 3.6rem;
+ margin-bottom: 1rem;
+ place-items: center;
+ color: var(--primary-color);
+ background: var(--highlight-bg);
+ border-radius: 1.1rem;
+ font-size: 1.35rem;
+}
+
+.empty-state h3 {
+ margin: 0 0 0.45rem;
+ color: var(--text-color);
+ font-size: 1.35rem;
+}
+
+.empty-state p {
+ max-width: 32rem;
+ margin: 0 0 1.25rem;
+ line-height: 1.6;
+}
+
+.empty-state button {
+ padding: 0.75rem 1rem;
+ color: var(--primary-color-text);
+ background: var(--primary-color);
+ border: 0;
+ border-radius: 0.75rem;
+ box-shadow: 0 0.7rem 1.7rem color-mix(in srgb, var(--primary-color) 22%, transparent);
+ cursor: pointer;
+ font-weight: 700;
+}
+
+.capture-idea__intro {
+ margin: 0 0 1.1rem;
+ color: var(--text-color-secondary);
+ line-height: 1.55;
+}
+
+.idea-loom footer {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.65rem 1.4rem;
+ padding: 1.4rem 0 0;
+ color: var(--text-color-secondary);
+ font-size: 0.75rem;
+}
+
+.idea-loom footer span::before {
+ margin-right: 0.5rem;
+ color: var(--primary-color);
+ content: "•";
+}
+
+@media (max-width: 760px) {
+ .idea-loom {
+ width: min(100% - 1rem, 1180px);
+ padding-top: 0.7rem;
+ }
+
+ .architecture-pill {
+ max-width: 11rem;
+ justify-content: center;
+ text-align: center;
+ }
+
+ .hero {
+ grid-template-columns: 1fr;
+ gap: 2.5rem;
+ padding-bottom: 3.5rem;
+ }
+
+ .hero h1 {
+ font-size: clamp(3.25rem, 18vw, 5rem);
+ }
+
+ .board-surface__heading {
+ flex-direction: column;
+ }
+
+ .idea-grid {
+ grid-template-columns: 1fr;
+ }
+}
diff --git a/Arc/React/Ideas/Board/Board.tsx b/Arc/React/Ideas/Board/Board.tsx
new file mode 100644
index 00000000..f6f0c62c
--- /dev/null
+++ b/Arc/React/Ideas/Board/Board.tsx
@@ -0,0 +1,121 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { useDialog } from '@cratis/arc.react/dialogs';
+import { withViewModel } from '@cratis/arc.react.mvvm';
+import { Toolbar, ToolbarButton } from '@cratis/components/Toolbar';
+import { BoardViewModel } from './BoardViewModel';
+import { CaptureIdeaDialog } from './CaptureIdeaDialog';
+import { Idea } from './Idea';
+import { ObserveIdeas } from './ObserveIdeas';
+import './Board.css';
+
+interface IdeaCardProps {
+ idea: Idea;
+ sequence: number;
+}
+
+const IdeaCard = ({ idea, sequence }: IdeaCardProps) => (
+
+ {String(sequence).padStart(2, '0')}
+
+
{idea.title}
+
{idea.summary}
+
+
+);
+
+export const Board = withViewModel(BoardViewModel, ({ viewModel }) => {
+ const [ideasResult] = ObserveIdeas.use();
+ const [CaptureDialog, showCaptureDialog] = useDialog(CaptureIdeaDialog);
+ const ideas = viewModel.filter(ideasResult.data ?? []);
+ const capturedCount = ideasResult.data?.length ?? 0;
+
+ return (
+
+
+
+
+
+
A focused Arc + React sample
+
Shape the next
small win.
+
+ Capture a useful idea, watch the live query update, and trace one strongly typed contract from C# to React.
+
+
+
+
{capturedCount}ideas captured
+
Liveobservable query
+
Directcurrent-state write
+
+
+
+
+
+
+ Working set
+
Ideas worth a conversation
+
+
+ { void showCaptureDialog(); }}
+ />
+
+
+
+
+
+ {ideasResult.isPerforming && !ideasResult.hasData && (
+ Connecting to the live board…
+ )}
+
+ {!ideasResult.isPerforming && capturedCount === 0 && (
+
+
+
Give the board its first spark
+
Capture one concrete improvement. Arc will validate it, run the command, and push the new read model here.
+
+
+ )}
+
+ {capturedCount > 0 && ideas.length === 0 && (
+ No ideas match “{viewModel.searchTerm}”. Try a broader phrase.
+ )}
+
+ {ideas.length > 0 && (
+
+ {ideas.map((idea, index) => (
+
+ ))}
+
+ )}
+
+
+
+
+
+ );
+});
diff --git a/Arc/React/Ideas/Board/BoardViewModel.ts b/Arc/React/Ideas/Board/BoardViewModel.ts
new file mode 100644
index 00000000..1a492a96
--- /dev/null
+++ b/Arc/React/Ideas/Board/BoardViewModel.ts
@@ -0,0 +1,25 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { injectable } from 'tsyringe';
+import { Idea } from './Idea';
+
+@injectable()
+export class BoardViewModel {
+ searchTerm = '';
+
+ setSearchTerm(value: string) {
+ this.searchTerm = value;
+ }
+
+ filter(ideas: Idea[]): Idea[] {
+ const normalizedSearch = this.searchTerm.trim().toLowerCase();
+ if (!normalizedSearch) {
+ return ideas;
+ }
+
+ return ideas.filter(idea =>
+ idea.title.toLowerCase().includes(normalizedSearch) ||
+ idea.summary.toLowerCase().includes(normalizedSearch));
+ }
+}
diff --git a/Arc/React/Ideas/Board/CaptureIdea.ts b/Arc/React/Ideas/Board/CaptureIdea.ts
new file mode 100644
index 00000000..6f3d3eec
--- /dev/null
+++ b/Arc/React/Ideas/Board/CaptureIdea.ts
@@ -0,0 +1,111 @@
+// @generated by Cratis. Source: Arc.React.Ideas.Board.CaptureIdea. Time: 2026-08-22T23:19:49.8284800Z. Hash: 156E2ECC7FA563E054A6FD024D6DDC4F143886560BE76A37C36B74F5058FF49A
+/*---------------------------------------------------------------------------------------------
+ * **DO NOT EDIT** - This file is an automatically generated file.
+ *--------------------------------------------------------------------------------------------*/
+
+/* eslint-disable sort-imports */
+/* eslint-disable @typescript-eslint/no-empty-interface */
+// eslint-disable-next-line header/header
+import { Command, CommandValidator } from '@cratis/arc/commands';
+import { useCommand, SetCommandValues, ClearCommandValues } from '@cratis/arc.react/commands';
+import { PropertyDescriptor } from '@cratis/arc/reflection';
+import { Guid } from '@cratis/fundamentals';
+
+/**
+ * Captures a new idea in the current-state board.
+ */
+export interface ICaptureIdea {
+
+ /**
+ * The identifier assigned by the client.
+ */
+ id?: Guid;
+
+ /**
+ * The detail that makes the idea useful.
+ */
+ summary?: string;
+
+ /**
+ * The concise idea title.
+ */
+ title?: string;
+}
+
+export class CaptureIdeaValidator extends CommandValidator {
+ constructor() {
+ super();
+ this.ruleFor(c => c.summary).notEmpty();
+ this.ruleFor(c => c.summary).maxLength(240);
+ this.ruleFor(c => c.title).notEmpty();
+ this.ruleFor(c => c.title).maxLength(72);
+ }
+}
+
+/**
+ * Captures a new idea in the current-state board.
+ */
+export class CaptureIdea extends Command implements ICaptureIdea {
+ readonly route: string = '/api/ideas/board';
+ readonly validation: CommandValidator = new CaptureIdeaValidator();
+ readonly treatWarningsAsErrors: boolean = false;
+ readonly roles: string[] = [];
+ readonly propertyDescriptors: PropertyDescriptor[] = [
+ new PropertyDescriptor('id', Guid, false),
+ new PropertyDescriptor('summary', String, false),
+ new PropertyDescriptor('title', String, false),
+ ];
+
+ private _id!: Guid;
+ private _summary!: string;
+ private _title!: string;
+
+ constructor() {
+ super(Object, false);
+ }
+
+ get requestParameters(): string[] {
+ return [
+ ];
+ }
+
+ /**
+ * The identifier assigned by the client.
+ */
+ get id(): Guid {
+ return this._id;
+ }
+
+ set id(value: Guid) {
+ this._id = value;
+ this.propertyChanged('id');
+ }
+ /**
+ * The detail that makes the idea useful.
+ */
+ get summary(): string {
+ return this._summary;
+ }
+
+ set summary(value: string) {
+ this._summary = value;
+ this.propertyChanged('summary');
+ }
+ /**
+ * The concise idea title.
+ */
+ get title(): string {
+ return this._title;
+ }
+
+ set title(value: string) {
+ this._title = value;
+ this.propertyChanged('title');
+ }
+
+ static use(initialValues?: ICaptureIdea): [CaptureIdea, SetCommandValues, ClearCommandValues] {
+ // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+ // @ts-ignore
+ return useCommand(CaptureIdea, initialValues);
+ }
+}
diff --git a/Arc/React/Ideas/Board/CaptureIdeaDialog.tsx b/Arc/React/Ideas/Board/CaptureIdeaDialog.tsx
new file mode 100644
index 00000000..75476875
--- /dev/null
+++ b/Arc/React/Ideas/Board/CaptureIdeaDialog.tsx
@@ -0,0 +1,33 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import { CommandDialog } from '@cratis/components/CommandDialog';
+import { InputTextField, TextAreaField } from '@cratis/components/CommandForm';
+import { Guid } from '@cratis/fundamentals';
+
+import { CaptureIdea } from './CaptureIdea';
+
+export const CaptureIdeaDialog = () => (
+
+ command={CaptureIdea}
+ title="Capture an idea"
+ okLabel="Add to board"
+ cancelLabel="Not now"
+ width="34rem"
+ initialValues={{ id: Guid.create(), title: '', summary: '' }}>
+
+ Keep it crisp. A strong title earns attention; a useful summary makes the next conversation easier.
+
+
+ value={command => command.title}
+ title="Title"
+ placeholder="Make local setup self-explanatory"
+ />
+
+ value={command => command.summary}
+ title="Why it matters"
+ placeholder="Describe the outcome, not the implementation."
+ rows={5}
+ />
+
+);
diff --git a/Arc/React/Ideas/Board/Idea.ts b/Arc/React/Ideas/Board/Idea.ts
new file mode 100644
index 00000000..69782ea4
--- /dev/null
+++ b/Arc/React/Ideas/Board/Idea.ts
@@ -0,0 +1,33 @@
+// @generated by Cratis. Source: Arc.React.Ideas.Board.Idea. Time: 2026-08-22T23:19:49.8499690Z. Hash: 6743C6D2B074DAA3A766C72BCE5AAD763719C1C339375148ACCB982DC62E8159
+/*---------------------------------------------------------------------------------------------
+ * **DO NOT EDIT** - This file is an automatically generated file.
+ *--------------------------------------------------------------------------------------------*/
+
+/* eslint-disable sort-imports */
+// eslint-disable-next-line header/header
+import { field } from '@cratis/fundamentals';
+import { Guid } from '@cratis/fundamentals';
+
+/**
+ * Represents an idea shown on the board.
+ */
+export class Idea {
+
+ /**
+ * The idea identifier.
+ */
+ @field(Guid)
+ id!: Guid;
+
+ /**
+ * The concise idea title.
+ */
+ @field(String)
+ title!: string;
+
+ /**
+ * The useful idea detail.
+ */
+ @field(String)
+ summary!: string;
+}
diff --git a/Arc/React/Ideas/Board/IdeaId.cs b/Arc/React/Ideas/Board/IdeaId.cs
new file mode 100644
index 00000000..b3c1e723
--- /dev/null
+++ b/Arc/React/Ideas/Board/IdeaId.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Concepts;
+
+namespace Arc.React.Ideas.Board;
+
+///
+/// Represents the identifier of an idea stored as current state.
+///
+/// The underlying identifier.
+public record IdeaId(Guid Value) : ConceptAs(Value)
+{
+ ///
+ /// Represents an identifier that has not been set.
+ ///
+ public static readonly IdeaId NotSet = new(Guid.Empty);
+
+ ///
+ /// Converts a to an .
+ ///
+ /// The value to convert.
+ public static implicit operator IdeaId(Guid value) => new(value);
+
+ ///
+ /// Creates a new idea identifier.
+ ///
+ /// A new idea identifier.
+ public static IdeaId New() => new(Guid.NewGuid());
+}
diff --git a/Arc/React/Ideas/Board/IdeaSummary.cs b/Arc/React/Ideas/Board/IdeaSummary.cs
new file mode 100644
index 00000000..3d5f6d49
--- /dev/null
+++ b/Arc/React/Ideas/Board/IdeaSummary.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Validation;
+using Cratis.Concepts;
+using FluentValidation;
+
+namespace Arc.React.Ideas.Board;
+
+///
+/// Represents the useful detail that explains an idea.
+///
+/// The summary text.
+public record IdeaSummary(string Value) : ConceptAs(Value)
+{
+ ///
+ /// Represents a summary that has not been set.
+ ///
+ public static readonly IdeaSummary NotSet = new(string.Empty);
+
+ ///
+ /// Converts a string to an .
+ ///
+ /// The value to convert.
+ public static implicit operator IdeaSummary(string value) => new(value);
+}
+
+///
+/// Validates the invariant carried by every .
+///
+public class IdeaSummaryValidator : ConceptValidator
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public IdeaSummaryValidator() => RuleFor(_ => _.Value).NotEmpty().MaximumLength(240);
+}
diff --git a/Arc/React/Ideas/Board/IdeaTitle.cs b/Arc/React/Ideas/Board/IdeaTitle.cs
new file mode 100644
index 00000000..52c8a9ac
--- /dev/null
+++ b/Arc/React/Ideas/Board/IdeaTitle.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Cratis.Arc.Validation;
+using Cratis.Concepts;
+using FluentValidation;
+
+namespace Arc.React.Ideas.Board;
+
+///
+/// Represents the concise title of an idea.
+///
+/// The title text.
+public record IdeaTitle(string Value) : ConceptAs(Value)
+{
+ ///
+ /// Represents a title that has not been set.
+ ///
+ public static readonly IdeaTitle NotSet = new(string.Empty);
+
+ ///
+ /// Converts a string to an .
+ ///
+ /// The value to convert.
+ public static implicit operator IdeaTitle(string value) => new(value);
+}
+
+///
+/// Validates the invariant carried by every .
+///
+public class IdeaTitleValidator : ConceptValidator
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public IdeaTitleValidator() => RuleFor(_ => _.Value).NotEmpty().MaximumLength(72);
+}
diff --git a/Arc/React/Ideas/Board/ObserveIdeas.ts b/Arc/React/Ideas/Board/ObserveIdeas.ts
new file mode 100644
index 00000000..4dfe5bc5
--- /dev/null
+++ b/Arc/React/Ideas/Board/ObserveIdeas.ts
@@ -0,0 +1,82 @@
+// @generated by Cratis. Source: Arc.React.Ideas.Board.Idea. Time: 2026-08-22T23:19:49.8471330Z. Hash: DC2B921FBE639334DAB5CEDAE695A76A1F32B537E6883F25B658514229493CE1
+/*---------------------------------------------------------------------------------------------
+ * **DO NOT EDIT** - This file is an automatically generated file.
+ *--------------------------------------------------------------------------------------------*/
+
+/* eslint-disable sort-imports */
+// eslint-disable-next-line header/header
+import { ObservableQueryFor, QueryResultWithState, Sorting, Paging, ChangeSet } from '@cratis/arc/queries';
+import { useObservableQuery, useObservableQueryWithPaging, useSuspenseObservableQuery, useSuspenseObservableQueryWithPaging, useChangeStream, SetSorting, SetPage, SetPageSize, ObservableQueryWhen } from '@cratis/arc.react/queries';
+import { ParameterDescriptor } from '@cratis/arc/reflection';
+import { Idea } from './Idea';
+
+class ObserveIdeasSortBy {
+
+ constructor(readonly query: ObserveIdeas) {
+ }
+
+}
+
+class ObserveIdeasSortByWithoutQuery {
+
+}
+
+
+/**
+ * Observes all captured ideas and pushes the current board on each change.
+ */
+export class ObserveIdeas extends ObservableQueryFor {
+ readonly route: string = '/api/ideas/board/observe-ideas';
+ readonly queryName: string = 'Arc.React.Ideas.Board.Idea.ObserveIdeas';
+ readonly treatWarningsAsErrors: boolean = false;
+ readonly roles: string[] = [];
+ readonly defaultValue: Idea[] = [];
+ private readonly _sortBy: ObserveIdeasSortBy;
+ private static readonly _sortBy: ObserveIdeasSortByWithoutQuery = new ObserveIdeasSortByWithoutQuery();
+
+ constructor() {
+ super(Idea, true);
+ this._sortBy = new ObserveIdeasSortBy(this);
+ }
+
+ get requiredRequestParameters(): string[] {
+ return [
+ ];
+ }
+
+ readonly parameterDescriptors: ParameterDescriptor[] = [
+ ];
+
+
+ get sortBy(): ObserveIdeasSortBy {
+ return this._sortBy;
+ }
+
+ static get sortBy(): ObserveIdeasSortByWithoutQuery {
+ return this._sortBy;
+ }
+
+ static use(sorting?: Sorting): [QueryResultWithState, SetSorting] {
+ return useObservableQuery(ObserveIdeas, undefined, sorting);
+ }
+
+ static useWithPaging(pageSize: number, sorting?: Sorting): [QueryResultWithState, SetSorting, SetPage, SetPageSize] {
+ return useObservableQueryWithPaging(ObserveIdeas, new Paging(0, pageSize), undefined, sorting);
+ }
+
+ static useSuspense(sorting?: Sorting): [QueryResultWithState, SetSorting] {
+ return useSuspenseObservableQuery(ObserveIdeas, undefined, sorting);
+ }
+
+ static useSuspenseWithPaging(pageSize: number, sorting?: Sorting): [QueryResultWithState, SetSorting, SetPage, SetPageSize] {
+ return useSuspenseObservableQueryWithPaging(ObserveIdeas, new Paging(0, pageSize), undefined, sorting);
+ }
+
+ static useChangeStream(getKey?: (item: Idea) => unknown, sorting?: Sorting): ChangeSet {
+ return useChangeStream(ObserveIdeas, undefined, getKey, sorting);
+ }
+
+ static when(condition: boolean): ObservableQueryWhen {
+ return new ObservableQueryWhen(ObserveIdeas, condition);
+ }
+}
diff --git a/Arc/React/Ideas/Board/for_BoardViewModel/when_filtering_ideas/by_title_or_summary.ts b/Arc/React/Ideas/Board/for_BoardViewModel/when_filtering_ideas/by_title_or_summary.ts
new file mode 100644
index 00000000..4e8ab892
--- /dev/null
+++ b/Arc/React/Ideas/Board/for_BoardViewModel/when_filtering_ideas/by_title_or_summary.ts
@@ -0,0 +1,30 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import 'chai';
+import { beforeEach, describe, it } from 'vitest';
+import { BoardViewModel } from '../../BoardViewModel';
+import { Idea } from '../../Idea';
+
+describe('when filtering ideas by title or summary', () => {
+ let viewModel: BoardViewModel;
+ let ideas: Idea[];
+
+ beforeEach(() => {
+ viewModel = new BoardViewModel();
+ ideas = [
+ Object.assign(new Idea(), { title: 'Shorten setup', summary: 'Make the first run obvious.' }),
+ Object.assign(new Idea(), { title: 'Polish empty states', summary: 'Help new users find their next action.' }),
+ ];
+ viewModel.setSearchTerm('FIRST RUN');
+ });
+
+ it('should match without regard to casing', () => {
+ viewModel.filter(ideas).should.have.lengthOf(1);
+ });
+
+ it('should match text carried by the summary', () => {
+ const matchingIdeas = viewModel.filter(ideas);
+ matchingIdeas[0].title.should.equal('Shorten setup');
+ });
+});
diff --git a/Arc/React/Ideas/Board/index.ts b/Arc/React/Ideas/Board/index.ts
new file mode 100644
index 00000000..9bd73dde
--- /dev/null
+++ b/Arc/React/Ideas/Board/index.ts
@@ -0,0 +1,3 @@
+export * from './CaptureIdea';
+export * from './Idea';
+export * from './ObserveIdeas';
diff --git a/Arc/React/Program.cs b/Arc/React/Program.cs
new file mode 100644
index 00000000..ed732628
--- /dev/null
+++ b/Arc/React/Program.cs
@@ -0,0 +1,24 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using Arc.React.Ideas.Board;
+
+var builder = WebApplication.CreateBuilder(args);
+
+builder.AddCratisArc(options =>
+{
+ options.GeneratedApis.RoutePrefix = "api";
+ options.GeneratedApis.IncludeCommandNameInRoute = false;
+ options.GeneratedApis.SegmentsToSkipForRoute = 2;
+});
+builder.Services.AddSingleton();
+
+var app = builder.Build();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+app.UseWebSockets();
+app.UseCratisArc();
+app.MapFallbackToFile("index.html");
+
+await app.RunAsync();
diff --git a/Arc/React/Properties/launchSettings.json b/Arc/React/Properties/launchSettings.json
new file mode 100644
index 00000000..738b605b
--- /dev/null
+++ b/Arc/React/Properties/launchSettings.json
@@ -0,0 +1,14 @@
+{
+ "$schema": "http://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "Arc.React": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": false,
+ "applicationUrl": "http://localhost:5064",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/Arc/React/README.md b/Arc/React/README.md
new file mode 100644
index 00000000..6acd6b4f
--- /dev/null
+++ b/Arc/React/README.md
@@ -0,0 +1,158 @@
+
+
+# Idea Loom — Arc + React, without Chronicle
+
+> A small, polished full-stack slice that shows what **Cratis Arc** gives you before event sourcing enters the picture: model-bound CQRS, validation, generated TypeScript contracts, live queries, React 19, Arc React MVVM, and Cratis Components.
+
+Idea Loom captures short improvement ideas on a live board. The command writes directly to an in-memory current-state store. The query observes that store and pushes every change to React. There are **no events, projections, event logs, or Chronicle packages** in this sample—deliberately.
+
+## What you will see
+
+- A focused `CaptureIdea` model-bound command with no controller or handler class
+- `IdeaId`, `IdeaTitle`, and `IdeaSummary` as Fundamentals `ConceptAs` values
+- Concept validators shared by the backend pipeline and generated frontend validation
+- A model-bound `Idea` read model with the live `ObserveIdeas` query
+- TypeScript command, query, and read-model contracts generated by a Debug build
+- React 19 with Arc React, a small MVVM search interaction, and a Cratis `CommandDialog`
+- A restrained specification set around the current-state write and view-model behavior
+
+## The slice at a glance
+
+```mermaid
+flowchart LR
+ UI[React 19 board] --> Dialog[Cratis CommandDialog]
+ Dialog --> Proxy[Generated CaptureIdea proxy]
+ Proxy --> Arc[Arc command pipeline]
+ Arc --> Values[ConceptAs validators]
+ Values --> Command[CaptureIdea.Handle]
+ Command --> Store[(In-memory IdeaStore)]
+ Store --> Subject[Live BehaviorSubject]
+ Subject --> Query[Idea.ObserveIdeas]
+ Query --> QueryProxy[Generated ObserveIdeas proxy]
+ QueryProxy --> UI
+
+ Chronicle{{Chronicle}}
+ Chronicle -. deliberately not used .- Command
+```
+
+The write path stores current state directly. Arc still owns the command/query boundary and full-stack contract. If the domain later needs history, replay, or facts for other consumers, Chronicle can replace the write path without changing the React query contract.
+
+## Prerequisites
+
+Requires .NET 10, Node.js 23 or newer, and the repository-pinned Yarn 4.5.3. Install the frontend dependencies once from the Samples root:
+
+```bash
+corepack enable
+yarn install
+```
+
+## Run it
+
+All commands below start at the **Samples repository root**.
+
+### 1. Build the backend and generate contracts
+
+```bash
+dotnet build Arc/React/Arc.React.csproj -c Debug
+```
+
+A Debug build writes the generated contracts beside the C# slice:
+
+- `Ideas/Board/CaptureIdea.ts`
+- `Ideas/Board/Idea.ts`
+- `Ideas/Board/ObserveIdeas.ts`
+- `Ideas/Board/index.ts`
+
+Never edit those files. Change the C# model and run the Debug build again.
+
+### 2. Start the backend
+
+```bash
+dotnet run --project Arc/React/Arc.React.csproj --no-build
+```
+
+Arc listens on `http://localhost:5064`.
+
+### 3. Start React
+
+In a second terminal:
+
+```bash
+yarn vite --config Arc/React/.frontend/vite.config.ts
+```
+
+Open `http://localhost:5173`, capture an idea, and watch the observable query update the board without a manual refresh. Open a second browser tab to see both clients update together.
+
+> The store is intentionally in memory. Restarting the backend clears the board.
+
+## Build and verify
+
+```bash
+# Regenerate TypeScript contracts and build the backend
+dotnet build Arc/React/Arc.React.csproj -c Debug
+
+# Verify the release build without touching generated contracts
+dotnet build Arc/React/Arc.React.csproj -c Release -p:CratisProxiesOutputPath=
+
+# Run the focused backend specifications
+dotnet test Arc/React/Arc.React.Specs/Arc.React.Specs.csproj -c Debug
+
+# Type-check, lint, specify, and bundle React
+yarn tsc -b Arc/React/.frontend/tsconfig.json
+yarn eslint --config Arc/React/eslint.config.mjs Arc/React
+yarn vitest run --config Arc/React/.frontend/vite.config.ts
+yarn vite build --config Arc/React/.frontend/vite.config.ts
+```
+
+To serve the production frontend from ASP.NET Core after those builds:
+
+```bash
+dotnet run --project Arc/React/Arc.React.csproj -c Release --no-build
+```
+
+Then open `http://localhost:5064`.
+
+## Code tour
+
+| Path | Why it matters |
+| --- | --- |
+| `Program.cs` | Adds standalone Arc, registers the current-state store, serves the built React app, and contains no Chronicle wiring. |
+| `Ideas/Board/IdeaId.cs` | Shows a non-event-source identifier as a strongly typed `ConceptAs`. |
+| `Ideas/Board/IdeaTitle.cs` | Carries the title and its reusable `NotEmpty`/length invariant. |
+| `Ideas/Board/IdeaSummary.cs` | Carries the summary and its reusable invariant. |
+| `Ideas/Board/Board.cs` | The complete backend slice: model-bound command, read model/query, and direct current-state store. |
+| `Ideas/Board/CaptureIdeaDialog.tsx` | Uses Cratis `CommandDialog` and typed command fields—never a raw PrimeReact dialog. |
+| `Ideas/Board/BoardViewModel.ts` | Keeps search behavior out of JSX and stays directly specifiable without React. |
+| `Ideas/Board/Board.tsx` | Composes the generated observable query, MVVM interaction, dialog, and small UI. |
+| `.frontend/vite.config.ts` | Builds the SPA and proxies Arc HTTP plus live-query connections during development. |
+
+## Why `ConceptAs` is doing real work
+
+The command does not accept anonymous `Guid` and `string` values. It accepts `IdeaId`, `IdeaTitle`, and `IdeaSummary`. That gives each value a domain name, prevents accidental swaps, and lets title/summary invariants follow the value everywhere Arc sees it. The proxy generator then turns those contracts into the correct TypeScript primitives and mirrors the validators in `CaptureIdeaValidator`.
+
+`IdeaId` is a `ConceptAs` here because this sample has no event source. In an event-sourced Chronicle slice, the aggregate identity would instead derive from `EventSourceId`.
+
+## Why no Chronicle?
+
+This sample makes the boundary visible:
+
+| Arc provides | Chronicle would add |
+| --- | --- |
+| Commands, queries, validation, authorization boundary | Durable facts and event streams |
+| Generated C# → TypeScript contracts | Projections rebuilt from history |
+| React hooks and live-query transport | Reactors, replay, temporal history |
+| Freedom to write to a current-state store | Event-sourced write semantics |
+
+The in-memory store is ideal for keeping this example tiny, not for durable production data. A bounded settings/reference-data surface could use the same pattern with MongoDB or EF Core.
+
+## Ideas to try
+
+1. **Exercise validation.** Try an empty title or a summary longer than 240 characters and inspect the inline command-form feedback.
+2. **Prove the live query.** Open two tabs, capture an idea in one, and watch the other update.
+3. **Change a concept.** Reduce the title limit, rebuild Debug, and inspect the regenerated `CaptureIdeaValidator`.
+4. **Add another current-state command.** Introduce `RefineIdea` and keep the write direct—still no Chronicle.
+5. **Swap persistence.** Replace `IdeaStore` with Arc MongoDB while preserving `CaptureIdea`, `Idea`, and the React surface.
+6. **Compare architectures.** Copy the slice into a separate experiment, add Chronicle there, and move only the write path to an event plus projection.
diff --git a/Arc/React/eslint.config.mjs b/Arc/React/eslint.config.mjs
new file mode 100644
index 00000000..03165921
--- /dev/null
+++ b/Arc/React/eslint.config.mjs
@@ -0,0 +1,48 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+import js from '@eslint/js';
+import typescriptEslint from '@typescript-eslint/eslint-plugin';
+import typescriptParser from '@typescript-eslint/parser';
+import header from 'eslint-plugin-header';
+import react from 'eslint-plugin-react';
+import globals from 'globals';
+
+export default [
+ {
+ ignores: ['bin/**', 'obj/**', 'wwwroot/**', 'node_modules/**'],
+ },
+ js.configs.recommended,
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parser: typescriptParser,
+ parserOptions: {
+ ecmaFeatures: { jsx: true },
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ },
+ globals: {
+ ...globals.browser,
+ ...globals.node,
+ },
+ },
+ plugins: {
+ '@typescript-eslint': typescriptEslint,
+ header,
+ react,
+ },
+ rules: {
+ ...typescriptEslint.configs.recommended.rules,
+ '@typescript-eslint/no-explicit-any': 'error',
+ '@typescript-eslint/no-unused-vars': ['error', { ignoreRestSiblings: true }],
+ 'header/header': 'off',
+ 'no-undef': 'off',
+ 'react/display-name': 'off',
+ 'react/react-in-jsx-scope': 'off',
+ },
+ settings: {
+ react: { version: 'detect' },
+ },
+ },
+];
diff --git a/Chronicle/Quickstart/Console/MainMenuItem.cs b/Arc/React/global.d.ts
similarity index 53%
rename from Chronicle/Quickstart/Console/MainMenuItem.cs
rename to Arc/React/global.d.ts
index f6e8ad2e..fc283845 100644
--- a/Chronicle/Quickstart/Console/MainMenuItem.cs
+++ b/Arc/React/global.d.ts
@@ -1,9 +1,10 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
-namespace Quickstart;
+///
-public record MainMenuItem(MainMenuCommand Command, string Text)
-{
- public override string ToString() => Text;
+interface Object {
+ readonly should: Chai.Assertion;
}
+
+declare module '*.css';
diff --git a/Arc/React/package.json b/Arc/React/package.json
new file mode 100644
index 00000000..ad1c1a3a
--- /dev/null
+++ b/Arc/React/package.json
@@ -0,0 +1,32 @@
+{
+ "name": "arc-react-sample",
+ "private": true,
+ "version": "0.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "yarn g:dev",
+ "build": "yarn g:build",
+ "lint": "yarn g:lint",
+ "lint:ci": "yarn g:lint:ci",
+ "test": "yarn g:test",
+ "compile": "yarn g:compile",
+ "ci": "yarn test && yarn lint:ci && yarn compile && yarn build"
+ },
+ "dependencies": {
+ "@cratis/arc": "20.47.1",
+ "@cratis/arc.react": "20.47.1",
+ "@cratis/arc.react.mvvm": "20.47.1",
+ "@cratis/arc.vite": "20.47.1",
+ "@cratis/components": "^2.4.1",
+ "@cratis/fundamentals": "7.18.1",
+ "mobx": "6.16.1",
+ "mobx-react": "9.2.2",
+ "primeicons": "7.0.0",
+ "primereact": "10.9.8",
+ "react": "19.2.7",
+ "react-dom": "19.2.7",
+ "reflect-metadata": "0.2.2",
+ "ts-deepmerge": "8.0.0",
+ "tsyringe": "4.10.0"
+ }
+}
diff --git a/Arc/React/tsconfig.json b/Arc/React/tsconfig.json
new file mode 100644
index 00000000..13774bc8
--- /dev/null
+++ b/Arc/React/tsconfig.json
@@ -0,0 +1,3 @@
+{
+ "extends": "./.frontend/tsconfig.json"
+}
diff --git a/Chronicle/Backend/AssemblyInfo.cs b/Chronicle/Backend/AssemblyInfo.cs
new file mode 100644
index 00000000..aefbfb2e
--- /dev/null
+++ b/Chronicle/Backend/AssemblyInfo.cs
@@ -0,0 +1,6 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+using System.Runtime.CompilerServices;
+
+[assembly: InternalsVisibleTo("Backend.Specs")]
diff --git a/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj b/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj
new file mode 100644
index 00000000..e231972a
--- /dev/null
+++ b/Chronicle/Backend/Backend.Specs/Backend.Specs.csproj
@@ -0,0 +1,22 @@
+
+
+ Chronicle.Backend.Specs
+ false
+
+
+
+
+
+
+
+
+
+ all
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs b/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs
new file mode 100644
index 00000000..35e2aa9d
--- /dev/null
+++ b/Chronicle/Backend/Backend.Specs/GlobalUsings.Specs.cs
@@ -0,0 +1,11 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+global using System.Collections.Immutable;
+
+global using Cratis.Chronicle.Events;
+global using Cratis.Chronicle.EventSequences;
+global using Cratis.Execution;
+global using Cratis.Specifications;
+global using NSubstitute;
+global using Xunit;
diff --git a/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs b/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs
new file mode 100644
index 00000000..c38e0c52
--- /dev/null
+++ b/Chronicle/Backend/Backend.Specs/for_Timeline/when_reading_history.cs
@@ -0,0 +1,37 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Chronicle.Backend.Specs.for_Timeline;
+
+public class when_reading_history : Specification
+{
+ const string FirstEntry = "The first fact.";
+ const string SecondEntry = "The next fact.";
+ IEventLog _eventLog;
+ Timeline _timeline;
+ TimelineId _timelineId;
+ IReadOnlyList _history;
+
+ void Establish()
+ {
+ _eventLog = Substitute.For();
+ _eventLog
+ .GetForEventSourceIdAndEventTypes(
+ Arg.Any(),
+ Arg.Any>())
+ .Returns(Task.FromResult>(
+ ImmutableList.Create(
+ AppendedEvent.EmptyWithContent(new TimelineEntryRecorded(FirstEntry)),
+ AppendedEvent.EmptyWithContent(new TimelineEntryRecorded(SecondEntry)))));
+ _timeline = new(_eventLog);
+ _timelineId = TimelineId.New();
+ }
+
+ async Task Because() => _history = await _timeline.GetHistory(_timelineId);
+
+ [Fact] void should_return_the_recorded_entries() => _history.Select(_ => _.Text).ShouldContainOnly([FirstEntry, SecondEntry]);
+ [Fact]
+ async Task should_query_with_the_typed_event_source_identifier() => await _eventLog.Received(1).GetForEventSourceIdAndEventTypes(
+ (EventSourceId)_timelineId,
+ Arg.Is>(types => types.Single() == typeof(TimelineEntryRecorded).GetEventType()));
+}
diff --git a/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs b/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs
new file mode 100644
index 00000000..ebf2f350
--- /dev/null
+++ b/Chronicle/Backend/Backend.Specs/for_Timeline/when_recording_an_entry.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Cratis. All rights reserved.
+// Licensed under the MIT license. See LICENSE file in the project root for full license information.
+
+namespace Chronicle.Backend.Specs.for_Timeline;
+
+public class when_recording_an_entry : Specification
+{
+ const string EntryText = "Chronicle keeps the facts.";
+ IEventLog _eventLog;
+ Timeline _timeline;
+ TimelineId _timelineId;
+ AppendResult _result;
+
+ void Establish()
+ {
+ _eventLog = Substitute.For();
+ _eventLog
+ .Append(Arg.Any(), Arg.Any