NeonClary commited on
Commit
ab4c488
·
unverified ·
2 Parent(s): 9722de4622120d

Merge pull request #1 from NeonGeckoCom/dev-cursor

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .cursor/rules/docker-build.mdc +87 -0
  2. .dockerignore +61 -0
  3. .env.example +11 -0
  4. .gitattributes +1 -0
  5. .gitignore +16 -0
  6. Dockerfile +48 -0
  7. README.md +274 -1
  8. backend/app/__init__.py +0 -0
  9. backend/app/api/__init__.py +0 -0
  10. backend/app/api/decidron.py +95 -0
  11. backend/app/config.py +28 -0
  12. backend/app/data/__init__.py +0 -0
  13. backend/app/data/decidron/default_network.json +16 -0
  14. backend/app/data/decidron/sample_commands.json +20 -0
  15. backend/app/decidron/__init__.py +7 -0
  16. backend/app/decidron/diagrams.py +48 -0
  17. backend/app/decidron/engine.py +210 -0
  18. backend/app/decidron/models.py +106 -0
  19. backend/app/decidron/network.py +101 -0
  20. backend/app/decidron/stats.py +62 -0
  21. backend/app/main.py +51 -0
  22. backend/app/utils/__init__.py +0 -0
  23. backend/dev.ps1 +28 -0
  24. backend/dev.sh +24 -0
  25. backend/requirements.txt +6 -0
  26. docker-compose.yml +13 -0
  27. frontend/.gitignore +23 -0
  28. frontend/package-lock.json +0 -0
  29. frontend/package.json +34 -0
  30. frontend/public/favicon.ico +0 -0
  31. frontend/public/index.html +14 -0
  32. frontend/public/manifest.json +25 -0
  33. frontend/public/neon-logo.png +3 -0
  34. frontend/public/robots.txt +2 -0
  35. frontend/src/App.js +43 -0
  36. frontend/src/components/Header.js +34 -0
  37. frontend/src/components/SettingsMenu.js +88 -0
  38. frontend/src/components/decidron/CommandBuilder.js +160 -0
  39. frontend/src/components/decidron/DecidronSimulator.js +191 -0
  40. frontend/src/components/decidron/HistoryTimeline.js +29 -0
  41. frontend/src/components/decidron/NetworkDiagram.js +97 -0
  42. frontend/src/components/decidron/ResultsPanel.js +41 -0
  43. frontend/src/components/decidron/SensorInputForm.js +74 -0
  44. frontend/src/components/decidron/StatsTable.js +47 -0
  45. frontend/src/index.js +10 -0
  46. frontend/src/styles/ccai.css +1884 -0
  47. frontend/src/styles/components.css +1455 -0
  48. frontend/src/styles/decidron.css +236 -0
  49. frontend/src/styles/layout.css +176 -0
  50. frontend/src/styles/variables.css +108 -0
.cursor/rules/docker-build.mdc ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ description: Docker build cache conventions for CCAI-Vibe-Demo
3
+ globs: Dockerfile,docker-compose*.yml,.dockerignore,backend/requirements.txt,frontend/package*.json
4
+ alwaysApply: false
5
+ ---
6
+
7
+ # Docker build cache conventions
8
+
9
+ This project uses **BuildKit cache mounts** so `npm ci` and `pip install`
10
+ skip the network when only source files change. Don't replace them
11
+ with plain `RUN` commands - that reintroduces full re-downloads on every
12
+ dependency change.
13
+
14
+ ## Required Dockerfile pattern
15
+
16
+ The Dockerfile MUST start with the BuildKit syntax directive:
17
+
18
+ ```dockerfile
19
+ # syntax=docker/dockerfile:1.7
20
+ ```
21
+
22
+ ### Frontend stage (root user, node:22-alpine)
23
+
24
+ ```dockerfile
25
+ COPY frontend/package.json frontend/package-lock.json ./
26
+ RUN --mount=type=cache,target=/root/.npm \
27
+ npm ci
28
+ ```
29
+
30
+ ### Backend stage (USER user, uid 1000, python:3.12-slim)
31
+
32
+ ```dockerfile
33
+ COPY --chown=user backend/requirements.txt ./
34
+ RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
35
+ pip install --user -r requirements.txt
36
+ ```
37
+
38
+ The `uid=1000,gid=1000` flags are mandatory because the cache target
39
+ sits inside the unprivileged user's home dir. Without them the cache
40
+ is created root-owned and pip cannot write to it.
41
+
42
+ ## Don'ts
43
+
44
+ - **Don't** pass `--no-cache-dir` to `pip install`. It defeats the
45
+ cache mount. The mount lives outside the image, so wheels never
46
+ bloat the final layer either way.
47
+ - **Don't** `COPY .` blindly. Layers must be copy-deps-first,
48
+ copy-source-second so a source edit doesn't bust the deps layer.
49
+ - **Don't** add new files outside `backend/`, `frontend/` or the
50
+ lockfiles to a `COPY` line without first checking `.dockerignore`.
51
+
52
+ ## Build context (`.dockerignore`)
53
+
54
+ The ignore list excludes `.git/`, `node_modules/`, `frontend/build/`,
55
+ `agent-transcripts/`, `__pycache__`, secrets (`.env*`),
56
+ `docker-compose.override.yml`, `backend/tests/`, and editor metadata.
57
+ If you need a previously-ignored path inside the image, add a narrow
58
+ `!path/to/keep` exception rather than removing the broad ignore.
59
+
60
+ ## Verifying the cache works
61
+
62
+ After a Dockerfile or deps-file change, run two builds back to back:
63
+
64
+ ```powershell
65
+ docker compose build
66
+ docker compose build # second time
67
+ ```
68
+
69
+ The second build's `npm ci` / `pip install` step should print a
70
+ `CACHED` line (when nothing in the deps file changed) or a
71
+ near-instant `=> [internal] load build context` finish (when only
72
+ source files changed). If it re-downloads, the cache mount is
73
+ mis-configured.
74
+
75
+ If a build ever errors with `the --mount option requires BuildKit`,
76
+ opt in explicitly:
77
+
78
+ ```powershell
79
+ $env:DOCKER_BUILDKIT=1; docker compose build
80
+ ```
81
+
82
+ ## HuggingFace Spaces compatibility
83
+
84
+ HuggingFace's Docker Space builder honors the `# syntax=` directive
85
+ and BuildKit cache mounts. Older builders silently treat the mounts
86
+ as no-ops, so this Dockerfile remains forward-compatible with any
87
+ plain-Docker environment.
.dockerignore ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Build context exclusions for `docker build` / `docker compose build`.
2
+ # Trimming the context speeds the daemon transfer and keeps cache hashes
3
+ # stable: changes to ignored paths do NOT invalidate Dockerfile layers.
4
+
5
+ # ── Git / VCS metadata ───────────────────────────────────────────────
6
+ .git
7
+ .gitignore
8
+ .gitattributes
9
+ .github
10
+
11
+ # ── Cursor / agent transcripts (local IDE state, never needed in image)
12
+ .cursor
13
+ agent-transcripts
14
+ .commit-msg.txt
15
+
16
+ # ── Editor / OS junk ─────────────────────────────────────────────────
17
+ .vscode
18
+ .idea
19
+ .DS_Store
20
+ Thumbs.db
21
+ *.swp
22
+
23
+ # ── Secrets / env (image gets env at runtime via docker-compose)
24
+ .env
25
+ .env.*
26
+ *.env
27
+ !**/.env.example
28
+
29
+ # ── Python local caches ──────────────────────────────────────────────
30
+ **/__pycache__
31
+ *.pyc
32
+ *.pyo
33
+ *.pyd
34
+ *.egg-info
35
+ .pytest_cache
36
+ .mypy_cache
37
+ .ruff_cache
38
+ .venv
39
+ venv
40
+
41
+ # ── Node local caches & build outputs ────────────────────────────────
42
+ # Lockfiles ARE included on purpose so `npm ci` is reproducible.
43
+ **/node_modules
44
+ frontend/build
45
+ frontend/.next
46
+ frontend/.cache
47
+ **/.cache
48
+ npm-debug.log*
49
+ yarn-debug.log*
50
+ yarn-error.log*
51
+
52
+ # ── Local-only docker compose overrides ──────────────────────────────
53
+ # The base docker-compose.yml is needed for `docker compose build` from
54
+ # the build dir, but the local override (which references absolute host
55
+ # paths to a secrets file outside this repo) shouldn't end up in the
56
+ # image build context.
57
+ docker-compose.override.yml
58
+
59
+ # ── Tests aren't shipped in the runtime image ────────────────────────
60
+ # (Re-enable if you want to run pytest inside the deployed container.)
61
+ backend/tests
.env.example ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Decidron Network Simulator config.
2
+ #
3
+ # The deterministic simulator requires no API keys. The only settings
4
+ # are CORS (for CRA dev on :3000 talking to the backend on :7860) and an
5
+ # optional host port override for docker compose.
6
+
7
+ # Comma-separated allowed CORS origins.
8
+ CORS_ORIGINS=http://localhost:3000,http://localhost:7860
9
+
10
+ # Host port to publish the container on (container always listens on 7860).
11
+ # CCAI_HOST_PORT=7860
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ node_modules/
5
+ frontend/build/
6
+ backend/static/
7
+ frontend/.env.development
8
+ .DS_Store
9
+ *.egg-info/
10
+ dist/
11
+ .venv/
12
+ venv/
13
+ *.log
14
+ docker-compose.override.yml
15
+ docker-compose.dev-cursor.yml
16
+ .commit-msg.txt
Dockerfile ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # syntax=docker/dockerfile:1.7
2
+ #
3
+ # BuildKit cache mounts speed up rebuilds by persisting npm and pip
4
+ # package caches across builds (outside the image - they don't bloat
5
+ # the final layer). The first `docker compose build` is unchanged;
6
+ # subsequent rebuilds skip the network for any unchanged dependency.
7
+ #
8
+ # The `# syntax=` directive auto-opts into BuildKit on Docker Desktop
9
+ # 20.10+; HuggingFace Spaces also supports this directive. Older
10
+ # builders silently treat the cache mounts as no-ops, so this file
11
+ # remains forward-compatible.
12
+
13
+ FROM node:22-alpine AS frontend-build
14
+ WORKDIR /app/frontend
15
+
16
+ # Copy lockfile + manifest first so the layer hash only changes when
17
+ # deps actually change, not on every source edit.
18
+ COPY frontend/package.json frontend/package-lock.json ./
19
+ RUN --mount=type=cache,target=/root/.npm \
20
+ npm ci
21
+
22
+ COPY frontend/ ./
23
+ ENV REACT_APP_API_URL=
24
+ RUN npm run build
25
+
26
+
27
+ FROM python:3.12-slim
28
+
29
+ RUN useradd -m -u 1000 user
30
+ USER user
31
+ ENV HOME=/home/user \
32
+ PATH=/home/user/.local/bin:$PATH \
33
+ PYTHONUNBUFFERED=1
34
+
35
+ WORKDIR $HOME/app
36
+
37
+ # Lockfile-equivalent first; cache mount targets the unprivileged
38
+ # user's pip cache (uid/gid 1000 matches the `user` account).
39
+ COPY --chown=user backend/requirements.txt ./
40
+ RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
41
+ pip install --user -r requirements.txt
42
+
43
+ COPY --chown=user backend/ ./
44
+ COPY --chown=user --from=frontend-build /app/frontend/build ./static
45
+
46
+ EXPOSE 7860
47
+
48
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md CHANGED
@@ -1 +1,274 @@
1
- # decidron-simulator
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Decidron Network Simulator
3
+ emoji: 🔗
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ ---
10
+
11
+ # Decidron Network Simulator
12
+
13
+ A deterministic, browser-based simulator for **Decidron networks** — the
14
+ coupled machine-learning decision units described in US Patent
15
+ [US11989636B1](https://patents.google.com/patent/US11989636B1/en)
16
+ ("System and method for persuadable collaborative conversational AI",
17
+ Gasper & Leeds).
18
+
19
+ You define simulated **sensor inputs** and **processing commands**
20
+ (IF a sensor matches THEN emit an output / drive an actuator / modify the
21
+ network), run the simulation, and watch the **network diagram**,
22
+ **performance statistics**, and **topology-change history** update live.
23
+
24
+ > This project started from Neon.ai's private `CCAI-Vibe-Demo` app and
25
+ > reuses its FastAPI + React scaffold, Docker setup, and visual branding.
26
+ > The CCAI multi-LLM orchestration was intentionally removed — v1 of the
27
+ > simulator is purely rule-based and needs no API keys or LLM. See
28
+ > [Relationship to CCAI](#relationship-to-ccai-and-the-patent).
29
+
30
+ ## Patent context (brief)
31
+
32
+ A **Decidron** is a machine-learning unit (MLU) with collaborative
33
+ protocols that can be **coupled/networked** with other Decidrons. The
34
+ patent highlights that "a MLU network coupled with real-time sensors and
35
+ actuators can operate industrial controls ... and increased reliability
36
+ through parallel pathways." A Decidron records its **experience**, fires
37
+ **actions**, scores **results**, and can **modify its own topology**.
38
+
39
+ This simulator models those concepts deterministically:
40
+
41
+ | Patent concept | Simulator |
42
+ | --- | --- |
43
+ | Coupled MLU node / coupling | `decidron` node / directed edge |
44
+ | Sensors and actuators | `sensor` / `actuator` nodes |
45
+ | Experience Chains (ECDS) | per-node experience log (sensor received → rule fired → actuator acted) |
46
+ | Select & perform action, results scored | command output (+ optional actuator) + performance stats |
47
+ | Dynamic/extensible topology | `network_ops` applied when a command fires |
48
+ | Parallel pathways / reliability | redundant couplings in the seed network |
49
+
50
+ ## Prerequisites
51
+
52
+ - **Docker** (recommended), or
53
+ - **Python 3.12+** and **Node 22+** for local dev.
54
+
55
+ ## Start the application
56
+
57
+ ### Docker (recommended)
58
+
59
+ ```bash
60
+ cp .env.example .env # no secrets needed; defaults are fine
61
+ docker compose up --build
62
+ # open http://localhost:7860
63
+ ```
64
+
65
+ The container builds the React frontend, serves it as static files from
66
+ FastAPI, and listens on port 7860. Override the host port with
67
+ `CCAI_HOST_PORT` in `.env` if 7860 is taken.
68
+
69
+ ### Local development
70
+
71
+ Backend (FastAPI on :7860):
72
+
73
+ ```bash
74
+ cd backend
75
+ python -m venv .venv
76
+ .venv/Scripts/activate # Windows; use source .venv/bin/activate on macOS/Linux
77
+ pip install -r requirements.txt
78
+ uvicorn app.main:app --port 7860
79
+ ```
80
+
81
+ #### Hot reload (development)
82
+
83
+ Do **not** rely on `uvicorn --reload` on Windows: it detects file changes
84
+ but frequently fails to actually restart the worker, so it silently keeps
85
+ serving stale code. Instead use the bundled dev scripts, which wrap
86
+ uvicorn in `watchfiles` for a clean full restart on every change:
87
+
88
+ ```powershell
89
+ cd backend
90
+ .\dev.ps1 # Windows
91
+ ```
92
+
93
+ ```bash
94
+ cd backend
95
+ ./dev.sh # macOS/Linux/Git Bash
96
+ ```
97
+
98
+ Both default to `app.main:app` on `127.0.0.1:7860`, watching `./app`.
99
+ `watchfiles` ships with `uvicorn[standard]`. (On non-Windows hosts plain
100
+ `uvicorn --reload` works too, but the scripts behave identically.)
101
+
102
+ Frontend (CRA dev server on :3000, proxying to the backend):
103
+
104
+ ```bash
105
+ cd frontend
106
+ npm install
107
+ # point the dev server at the backend:
108
+ echo "REACT_APP_API_URL=http://localhost:7860" > .env.development
109
+ npm start
110
+ ```
111
+
112
+ ## Web UI workflow
113
+
114
+ 1. **View the initial network diagram** — sensors (blue boxes) feed
115
+ coupled Decidrons (purple hexagons) that drive actuators (green
116
+ triangles).
117
+ 2. **Enter a simulated sensor input** — pick a channel (e.g.
118
+ `Temperature Sensor`), enter a value (e.g. `97`), and click **Queue**.
119
+ 3. **Create processing commands** — define IF (sensor + match) THEN
120
+ (output, optional actuator). The match is an `operator:value` rule.
121
+ 4. **Run the simulation** — click **Run Simulation**; queued inputs are
122
+ evaluated against all commands.
123
+ 5. **Read the results and statistics** — matched commands, emitted
124
+ outputs, and per-sensor/command/output metrics appear on the right.
125
+ 6. **Review the network-change diagram** — if a command applied
126
+ `network_ops`, a new version is snapshotted; select any version in the
127
+ **Network Modification History** to view that topology in the diagram.
128
+
129
+ ## Processing command format
130
+
131
+ A command is JSON:
132
+
133
+ ```json
134
+ {
135
+ "name": "HighTempAlert",
136
+ "sensor": "temperature",
137
+ "match": "gte:85",
138
+ "output": "Trigger failsafe review",
139
+ "actuator": "actuator-1",
140
+ "network_ops": [
141
+ { "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
142
+ { "op": "add_edge", "from": "temperature", "to": "D3" },
143
+ { "op": "remove_edge", "from": "D1", "to": "D2" }
144
+ ]
145
+ }
146
+ ```
147
+
148
+ - `sensor` — the sensor channel/node id this command listens to.
149
+ - `match` — `operator:value`, where operator is one of `contains`,
150
+ `equals`, `startswith`, `endswith`, `regex`, `gt`, `gte`, `lt`, `lte`.
151
+ A bare value (no operator) is treated as `contains`.
152
+ - `output` — text emitted when the rule fires.
153
+ - `actuator` *(optional)* — id of an actuator node to drive.
154
+ - `network_ops` *(optional)* — topology changes: `add_node`, `add_edge`,
155
+ `remove_edge` (edges use `from`/`to`).
156
+
157
+ ## API reference
158
+
159
+ All endpoints are under `/api/decidron`.
160
+
161
+ | Method | Path | Purpose |
162
+ | --- | --- | --- |
163
+ | GET | `/api/decidron/network` | Current network + vis-network diagram JSON |
164
+ | POST | `/api/decidron/sensors/input` | Queue a simulated sensor input |
165
+ | GET | `/api/decidron/commands` | List processing commands |
166
+ | POST | `/api/decidron/commands` | Create a processing command |
167
+ | POST | `/api/decidron/run` | Process queued (or supplied) inputs |
168
+ | GET | `/api/decidron/stats` | Performance statistics |
169
+ | GET | `/api/decidron/history` | Topology-change snapshots |
170
+ | POST | `/api/decidron/reset` | Reset to the seed network |
171
+
172
+ ### curl examples
173
+
174
+ ```bash
175
+ # View the initial network
176
+ curl http://localhost:7860/api/decidron/network
177
+
178
+ # Queue a sensor reading and run
179
+ curl -X POST http://localhost:7860/api/decidron/sensors/input \
180
+ -H 'Content-Type: application/json' \
181
+ -d '{"channel":"temperature","value":"97"}'
182
+
183
+ curl -X POST http://localhost:7860/api/decidron/run \
184
+ -H 'Content-Type: application/json' -d '{}'
185
+
186
+ # Create a command
187
+ curl -X POST http://localhost:7860/api/decidron/commands \
188
+ -H 'Content-Type: application/json' \
189
+ -d '{"name":"LowTemp","sensor":"temperature","match":"lt:0","output":"Freeze warning"}'
190
+
191
+ # Run with inline inputs (bypasses the queue)
192
+ curl -X POST http://localhost:7860/api/decidron/run \
193
+ -H 'Content-Type: application/json' \
194
+ -d '{"inputs":[{"channel":"temperature","value":"-5"}]}'
195
+
196
+ # Stats and history
197
+ curl http://localhost:7860/api/decidron/stats
198
+ curl http://localhost:7860/api/decidron/history
199
+ ```
200
+
201
+ ## Config & seed files
202
+
203
+ - [`backend/app/config.py`](backend/app/config.py) — minimal settings
204
+ (`CORS_ORIGINS`).
205
+ - [`backend/app/data/decidron/default_network.json`](backend/app/data/decidron/default_network.json)
206
+ — the starter network (sensor → coupled Decidrons → actuator, with a
207
+ redundant coupling).
208
+ - [`backend/app/data/decidron/sample_commands.json`](backend/app/data/decidron/sample_commands.json)
209
+ — example commands (one drives an actuator, one reconfigures the
210
+ topology). Edit these to change the simulation's starting state.
211
+
212
+ ## Adding files in Cursor
213
+
214
+ - **Open the repo as a workspace**: File → Open Folder → `decidron-simulator`.
215
+ - **New file**: right-click a folder in the Explorer → New File.
216
+ - **Drag & drop** files from your file manager into the Explorer.
217
+ - **Ask Cursor in chat**: e.g. "Add `backend/app/decidron/alerts.py`".
218
+ - **Terminal**: `New-Item backend/app/decidron/alerts.py` (PowerShell) or
219
+ `touch backend/app/decidron/alerts.py` (bash).
220
+
221
+ ## Relationship to CCAI and the patent
222
+
223
+ v1 deliberately omits the patent's full pipeline: ECDS context/memory
224
+ augmentation, the embedded CCAI per Decidron, Theory-of-Mind,
225
+ persuadability, and action-reinforcement learning. A future version
226
+ could make Decidrons LLM-driven by porting the orchestration and LLM
227
+ clients from the `CCAI-Vibe-Demo` reference app. The simulator's logic is
228
+ isolated under `backend/app/decidron/` and
229
+ `frontend/src/components/decidron/` to keep that door open.
230
+
231
+ ## Project layout
232
+
233
+ ```
234
+ backend/
235
+ app/
236
+ main.py # FastAPI app: CORS, /api/health, SPA serving
237
+ config.py # minimal settings
238
+ api/decidron.py # REST router
239
+ decidron/ # simulation engine (models, network, engine, stats, diagrams)
240
+ data/decidron/ # seed network + sample commands
241
+ frontend/
242
+ src/
243
+ App.js # shell
244
+ components/Header.js # branded header
245
+ components/decidron/ # simulator UI (diagram, forms, tables, history)
246
+ utils/api.js # API client
247
+ styles/ # branding + simulator CSS
248
+ Dockerfile, docker-compose.yml
249
+ ```
250
+
251
+ ## Hugging Face Space (BrainForge)
252
+
253
+ Hosted as a Docker Space on [BrainForge/decidron-simulator](https://huggingface.co/spaces/BrainForge/decidron-simulator) (`app_port: 7860`, `cpu-basic`). No API keys or secrets are required.
254
+
255
+ Deploy from the **`master`** branch (merge `dev-cursor` into `master` on GitHub first):
256
+
257
+ ```bash
258
+ git remote add hf https://huggingface.co/spaces/BrainForge/decidron-simulator
259
+ git checkout master && git pull origin master
260
+ git push hf master:main
261
+ ```
262
+
263
+ The Space builds from the root `Dockerfile` (React frontend → `backend/static`, FastAPI on port 7860).
264
+
265
+ ## Troubleshooting
266
+
267
+ - **Port 7860 in use** — set `CCAI_HOST_PORT` in `.env`, or run uvicorn
268
+ on another port.
269
+ - **Frontend can't reach the API in dev** — ensure
270
+ `REACT_APP_API_URL=http://localhost:7860` is set in
271
+ `frontend/.env.development` and the backend is running.
272
+ - **CORS errors** — add your origin to `CORS_ORIGINS` in `.env`.
273
+ - **Diagram doesn't render** — vis-network needs the bundled assets from
274
+ `npm install`; re-run it if you cloned without building.
backend/app/__init__.py ADDED
File without changes
backend/app/api/__init__.py ADDED
File without changes
backend/app/api/decidron.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """REST API for the Decidron network simulator."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from fastapi import APIRouter, HTTPException
6
+ from pydantic import BaseModel
7
+
8
+ from app.decidron.engine import engine
9
+ from app.decidron.diagrams import to_vis_network
10
+ from app.decidron.models import ProcessingCommand, RunResult, SensorInput
11
+
12
+ router = APIRouter(prefix="/decidron", tags=["decidron"])
13
+
14
+
15
+ def _network_payload() -> dict:
16
+ nodes = engine.network.nodes_list()
17
+ return {
18
+ "version": engine.network.version,
19
+ "nodes": [n.model_dump() for n in nodes],
20
+ "edges": [e.model_dump(by_alias=True) for e in engine.network.edges],
21
+ "diagram": to_vis_network(nodes, engine.network.edges),
22
+ }
23
+
24
+
25
+ @router.get("/network")
26
+ async def get_network() -> dict:
27
+ return _network_payload()
28
+
29
+
30
+ @router.post("/sensors/input")
31
+ async def submit_sensor_input(sensor_input: SensorInput) -> dict:
32
+ engine.add_sensor_input(sensor_input)
33
+ return {
34
+ "queued": sensor_input.model_dump(),
35
+ "pending_count": len(engine.pending_inputs),
36
+ }
37
+
38
+
39
+ @router.get("/commands")
40
+ async def list_commands() -> list[ProcessingCommand]:
41
+ return engine.list_commands()
42
+
43
+
44
+ @router.post("/commands")
45
+ async def create_command(command: ProcessingCommand) -> ProcessingCommand:
46
+ return engine.add_command(command)
47
+
48
+
49
+ @router.delete("/commands/{name}")
50
+ async def delete_command(name: str) -> dict:
51
+ if not engine.remove_command(name):
52
+ raise HTTPException(status_code=404, detail=f"No command named '{name}'")
53
+ return {"deleted": name}
54
+
55
+
56
+ class RunRequest(BaseModel):
57
+ inputs: list[SensorInput] | None = None
58
+
59
+
60
+ @router.post("/run")
61
+ async def run_simulation(req: RunRequest | None = None) -> dict:
62
+ inputs = req.inputs if req else None
63
+ results: list[RunResult] = engine.run(inputs)
64
+ return {
65
+ "results": [r.model_dump() for r in results],
66
+ "matches": sum(1 for r in results if r.matched),
67
+ "network": _network_payload(),
68
+ }
69
+
70
+
71
+ @router.get("/stats")
72
+ async def get_stats() -> dict:
73
+ return engine.stats.as_dict()
74
+
75
+
76
+ @router.get("/history")
77
+ async def get_history() -> dict:
78
+ return {
79
+ "version": engine.network.version,
80
+ "snapshots": [
81
+ {
82
+ "version": s.version,
83
+ "reason": s.reason,
84
+ "timestamp": s.timestamp,
85
+ "diagram": to_vis_network(s.nodes, s.edges),
86
+ }
87
+ for s in engine.network.history
88
+ ],
89
+ }
90
+
91
+
92
+ @router.post("/reset")
93
+ async def reset_simulation() -> dict:
94
+ engine.reset()
95
+ return _network_payload()
backend/app/config.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from pydantic_settings import BaseSettings
6
+
7
+ _PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
8
+ _ENV_FILE = _PROJECT_ROOT / ".env"
9
+
10
+
11
+ class Settings(BaseSettings):
12
+ """Minimal app settings for the Decidron simulator.
13
+
14
+ The deterministic simulator needs no LLM provider credentials; the
15
+ only configurable surface today is CORS for local dev where the CRA
16
+ dev server (:3000) talks to the FastAPI backend (:7860).
17
+ """
18
+
19
+ cors_origins: str = "http://localhost:3000,http://localhost:7860"
20
+
21
+ model_config = {"env_file": str(_ENV_FILE), "env_file_encoding": "utf-8"}
22
+
23
+ @property
24
+ def cors_origin_list(self) -> list[str]:
25
+ return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
26
+
27
+
28
+ settings = Settings()
backend/app/data/__init__.py ADDED
File without changes
backend/app/data/decidron/default_network.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "nodes": [
3
+ { "id": "temperature", "label": "Temperature Sensor", "type": "sensor" },
4
+ { "id": "D1", "label": "Decidron D1", "type": "decidron" },
5
+ { "id": "D2", "label": "Decidron D2", "type": "decidron" },
6
+ { "id": "actuator-1", "label": "Failsafe Actuator", "type": "actuator" }
7
+ ],
8
+ "edges": [
9
+ { "from": "temperature", "to": "D1", "label": "senses" },
10
+ { "from": "temperature", "to": "D2", "label": "senses (redundant)" },
11
+ { "from": "D1", "to": "D2", "label": "coupling" },
12
+ { "from": "D2", "to": "D1", "label": "coupling" },
13
+ { "from": "D1", "to": "actuator-1", "label": "drives" },
14
+ { "from": "D2", "to": "actuator-1", "label": "drives (redundant)" }
15
+ ]
16
+ }
backend/app/data/decidron/sample_commands.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "name": "HighTempAlert",
4
+ "sensor": "temperature",
5
+ "match": "gte:85",
6
+ "output": "Trigger failsafe review",
7
+ "actuator": "actuator-1"
8
+ },
9
+ {
10
+ "name": "CriticalTempReconfigure",
11
+ "sensor": "temperature",
12
+ "match": "gte:95",
13
+ "output": "Spin up redundant Decidron D3 and couple it to the failsafe actuator",
14
+ "network_ops": [
15
+ { "op": "add_node", "node": { "id": "D3", "label": "Decidron D3", "type": "decidron" } },
16
+ { "op": "add_edge", "from": "temperature", "to": "D3" },
17
+ { "op": "add_edge", "from": "D3", "to": "actuator-1" }
18
+ ]
19
+ }
20
+ ]
backend/app/decidron/__init__.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ """Decidron network simulator package.
2
+
3
+ A deterministic, LLM-free simulation of the patent's (US11989636B1)
4
+ Decidron network: machine-learning decision units (Decidrons) coupled
5
+ with sensors and actuators, that record experience, fire rule-based
6
+ actions, and can modify their own topology.
7
+ """
backend/app/decidron/diagrams.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convert Decidron network state into vis-network diagram JSON.
2
+
3
+ Uses ``networkx`` as the server-side graph representation and emits
4
+ ``{nodes, edges}`` shaped for the vis-network frontend, styled by node
5
+ type (sensor / decidron / actuator).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import networkx as nx
11
+
12
+ from .models import DecidronNode, Edge
13
+
14
+ _GROUP_STYLE = {
15
+ "sensor": {"shape": "box", "color": "#3b82f6"},
16
+ "decidron": {"shape": "hexagon", "color": "#8b5cf6"},
17
+ "actuator": {"shape": "triangle", "color": "#10b981"},
18
+ }
19
+
20
+
21
+ def build_graph(nodes: list[DecidronNode], edges: list[Edge]) -> nx.DiGraph:
22
+ g = nx.DiGraph()
23
+ for n in nodes:
24
+ g.add_node(n.id, label=n.label, type=n.type, experience=len(n.experience))
25
+ for e in edges:
26
+ g.add_edge(e.source, e.target, label=e.label or "")
27
+ return g
28
+
29
+
30
+ def to_vis_network(nodes: list[DecidronNode], edges: list[Edge]) -> dict:
31
+ """Return a vis-network ``{nodes, edges}`` payload."""
32
+ g = build_graph(nodes, edges)
33
+ vis_nodes = []
34
+ for node_id, data in g.nodes(data=True):
35
+ ntype = data.get("type", "decidron")
36
+ style = _GROUP_STYLE.get(ntype, {})
37
+ vis_nodes.append({
38
+ "id": node_id,
39
+ "label": data.get("label", node_id),
40
+ "group": ntype,
41
+ "title": f"{ntype} - {data.get('experience', 0)} experience events",
42
+ **style,
43
+ })
44
+ vis_edges = [
45
+ {"from": s, "to": t, "label": d.get("label", ""), "arrows": "to"}
46
+ for s, t, d in g.edges(data=True)
47
+ ]
48
+ return {"nodes": vis_nodes, "edges": vis_edges}
backend/app/decidron/engine.py ADDED
@@ -0,0 +1,210 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic Decidron processing engine.
2
+
3
+ Ties the network topology, statistics, user commands, and queued sensor
4
+ inputs together. On each run it matches commands against sensor inputs
5
+ and, for every match:
6
+
7
+ 1. records ECDS-like experience events on the involved nodes
8
+ (sensor received -> decidron fired -> actuator acted),
9
+ 2. emits the command output and drives the target actuator (if any),
10
+ 3. applies topology ``network_ops`` (snapshotting history),
11
+ 4. updates performance statistics.
12
+
13
+ No LLM is involved -- v1 is purely rule-based.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import re
20
+ import time
21
+ from pathlib import Path
22
+
23
+ from .models import (
24
+ ExperienceEvent,
25
+ ProcessingCommand,
26
+ RunResult,
27
+ SensorInput,
28
+ )
29
+ from .network import NetworkStore
30
+ from .stats import StatsCollector
31
+
32
+ _DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
33
+ _SAMPLE_COMMANDS = _DATA_DIR / "sample_commands.json"
34
+
35
+
36
+ def _matches(rule: str, value: str) -> bool:
37
+ """Evaluate an ``operator:value`` match rule against a sensor value."""
38
+ if ":" in rule:
39
+ op, _, target = rule.partition(":")
40
+ op = op.strip().lower()
41
+ target = target.strip()
42
+ else:
43
+ op, target = "contains", rule.strip()
44
+
45
+ v = value.strip()
46
+
47
+ def _nums():
48
+ try:
49
+ return float(v), float(target)
50
+ except (TypeError, ValueError):
51
+ return None, None
52
+
53
+ if op == "contains":
54
+ return target.lower() in v.lower()
55
+ if op == "equals":
56
+ return v.lower() == target.lower()
57
+ if op == "startswith":
58
+ return v.lower().startswith(target.lower())
59
+ if op == "endswith":
60
+ return v.lower().endswith(target.lower())
61
+ if op == "regex":
62
+ try:
63
+ return re.search(target, v) is not None
64
+ except re.error:
65
+ return False
66
+ if op in {"gt", "lt", "gte", "lte"}:
67
+ a, b = _nums()
68
+ if a is None:
69
+ return False
70
+ return {
71
+ "gt": a > b,
72
+ "lt": a < b,
73
+ "gte": a >= b,
74
+ "lte": a <= b,
75
+ }[op]
76
+ # Unknown operator -> no match.
77
+ return False
78
+
79
+
80
+ class DecidronEngine:
81
+ def __init__(self) -> None:
82
+ self.network = NetworkStore()
83
+ self.stats = StatsCollector()
84
+ self.commands: dict[str, ProcessingCommand] = {}
85
+ self.pending_inputs: list[SensorInput] = []
86
+ self.input_log: list[SensorInput] = []
87
+ self.run_results: list[RunResult] = []
88
+ self._load_sample_commands()
89
+
90
+ def _load_sample_commands(self) -> None:
91
+ if _SAMPLE_COMMANDS.is_file():
92
+ for c in json.loads(_SAMPLE_COMMANDS.read_text(encoding="utf-8")):
93
+ cmd = ProcessingCommand(**c)
94
+ self.commands[cmd.name] = cmd
95
+
96
+ # -- commands --------------------------------------------------------
97
+
98
+ def list_commands(self) -> list[ProcessingCommand]:
99
+ return list(self.commands.values())
100
+
101
+ def add_command(self, command: ProcessingCommand) -> ProcessingCommand:
102
+ self.commands[command.name] = command
103
+ return command
104
+
105
+ def remove_command(self, name: str) -> bool:
106
+ return self.commands.pop(name, None) is not None
107
+
108
+ # -- sensor inputs ---------------------------------------------------
109
+
110
+ def add_sensor_input(self, sensor_input: SensorInput) -> SensorInput:
111
+ self.pending_inputs.append(sensor_input)
112
+ return sensor_input
113
+
114
+ # -- experience (ECDS-like) -----------------------------------------
115
+
116
+ def _record(self, node_id: str, event: ExperienceEvent) -> None:
117
+ node = self.network.nodes.get(node_id)
118
+ if node is not None:
119
+ node.experience.append(event)
120
+
121
+ def _decidrons_for_sensor(self, sensor_node_id: str | None) -> list[str]:
122
+ """Decidron nodes coupled downstream of a sensor (fallback: all)."""
123
+ if sensor_node_id is not None:
124
+ coupled = [
125
+ nid for nid in self.network.neighbors_out(sensor_node_id)
126
+ if self.network.nodes.get(nid) and self.network.nodes[nid].type == "decidron"
127
+ ]
128
+ if coupled:
129
+ return coupled
130
+ return [n.id for n in self.network.nodes_list() if n.type == "decidron"]
131
+
132
+ # -- run -------------------------------------------------------------
133
+
134
+ def run(self, inputs: list[SensorInput] | None = None) -> list[RunResult]:
135
+ batch = inputs if inputs is not None else list(self.pending_inputs)
136
+ results: list[RunResult] = []
137
+
138
+ for si in batch:
139
+ sensor_node = self.network.find_sensor_node(si.channel)
140
+ if sensor_node is not None:
141
+ self._record(sensor_node.id, ExperienceEvent(
142
+ kind="sensor_received",
143
+ detail=f"received '{si.value}' on channel '{si.channel}'",
144
+ sensor=si.channel,
145
+ value=si.value,
146
+ ))
147
+
148
+ for cmd in self.commands.values():
149
+ if cmd.sensor.lower() != si.channel.lower():
150
+ continue
151
+ start = time.perf_counter()
152
+ matched = _matches(cmd.match, si.value)
153
+ ops_applied = 0
154
+ output = None
155
+ actuator = None
156
+
157
+ if matched:
158
+ output = cmd.output
159
+ for nid in self._decidrons_for_sensor(
160
+ sensor_node.id if sensor_node else None
161
+ ):
162
+ self._record(nid, ExperienceEvent(
163
+ kind="rule_fired",
164
+ detail=f"command '{cmd.name}' fired -> {cmd.output}",
165
+ command=cmd.name,
166
+ sensor=si.channel,
167
+ value=si.value,
168
+ ))
169
+ if cmd.actuator and cmd.actuator in self.network.nodes:
170
+ actuator = cmd.actuator
171
+ self._record(cmd.actuator, ExperienceEvent(
172
+ kind="actuator_acted",
173
+ detail=f"actuated by '{cmd.name}': {cmd.output}",
174
+ command=cmd.name,
175
+ ))
176
+ if cmd.network_ops:
177
+ ops_applied = self.network.apply_ops(
178
+ cmd.network_ops, reason=f"command '{cmd.name}' fired"
179
+ )
180
+
181
+ latency_ms = (time.perf_counter() - start) * 1000.0
182
+ self.stats.record(si.channel, cmd.name, output, matched, latency_ms)
183
+ result = RunResult(
184
+ command=cmd.name,
185
+ sensor=si.channel,
186
+ value=si.value,
187
+ matched=matched,
188
+ output=output,
189
+ actuator=actuator,
190
+ network_ops_applied=ops_applied,
191
+ latency_ms=round(latency_ms, 4),
192
+ )
193
+ results.append(result)
194
+
195
+ self.input_log.append(si)
196
+
197
+ if inputs is None:
198
+ self.pending_inputs.clear()
199
+ self.run_results.extend(results)
200
+ return results
201
+
202
+ def reset(self) -> None:
203
+ self.network.reset()
204
+ self.stats.reset()
205
+ self.pending_inputs.clear()
206
+ self.input_log.clear()
207
+ self.run_results.clear()
208
+
209
+
210
+ engine = DecidronEngine()
backend/app/decidron/models.py ADDED
@@ -0,0 +1,106 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic schemas for the Decidron simulator.
2
+
3
+ These model the patent's core objects in a deliberately simplified,
4
+ deterministic form:
5
+
6
+ - ``DecidronNode`` -> a coupled MLU node (decidron) or a sensor/actuator.
7
+ - ``Edge`` -> a coupling between nodes.
8
+ - ``ExperienceEvent`` -> a simplified ECDS entry recorded on a node.
9
+ - ``ProcessingCommand`` -> a user-defined IF (sensor matches) THEN
10
+ (emit output / drive actuator / modify network) rule.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import time
16
+ from typing import Literal, Optional
17
+
18
+ from pydantic import BaseModel, ConfigDict, Field
19
+
20
+ NodeType = Literal["decidron", "sensor", "actuator"]
21
+ NetworkOpType = Literal["add_edge", "remove_edge", "add_node"]
22
+ ExperienceKind = Literal["sensor_received", "rule_fired", "actuator_acted"]
23
+
24
+
25
+ class ExperienceEvent(BaseModel):
26
+ """A simplified ECDS (Experience Chain Data Structure) entry.
27
+
28
+ The patent's signature behavior is that each unit records its
29
+ experience; in v1 we capture the salient facts of each run.
30
+ """
31
+
32
+ kind: ExperienceKind
33
+ detail: str
34
+ command: Optional[str] = None
35
+ sensor: Optional[str] = None
36
+ value: Optional[str] = None
37
+ timestamp: float = Field(default_factory=time.time)
38
+
39
+
40
+ class DecidronNode(BaseModel):
41
+ id: str
42
+ label: str
43
+ type: NodeType = "decidron"
44
+ experience: list[ExperienceEvent] = Field(default_factory=list)
45
+
46
+
47
+ class Edge(BaseModel):
48
+ """A directed coupling between two nodes (``from`` -> ``to``)."""
49
+
50
+ model_config = ConfigDict(populate_by_name=True)
51
+
52
+ source: str = Field(alias="from")
53
+ target: str = Field(alias="to")
54
+ label: Optional[str] = None
55
+
56
+
57
+ class NetworkOp(BaseModel):
58
+ """A topology modification applied when a command fires."""
59
+
60
+ model_config = ConfigDict(populate_by_name=True)
61
+
62
+ op: NetworkOpType
63
+ source: Optional[str] = Field(default=None, alias="from")
64
+ target: Optional[str] = Field(default=None, alias="to")
65
+ node: Optional[DecidronNode] = None
66
+
67
+
68
+ class SensorInput(BaseModel):
69
+ channel: str
70
+ value: str
71
+ timestamp: float = Field(default_factory=time.time)
72
+
73
+
74
+ class ProcessingCommand(BaseModel):
75
+ """A user-defined processing rule.
76
+
77
+ ``match`` is an ``operator:value`` string, e.g. ``contains:85``,
78
+ ``equals:on``, ``gt:80``. A bare value is treated as ``contains``.
79
+ """
80
+
81
+ name: str
82
+ sensor: str
83
+ match: str
84
+ output: str
85
+ actuator: Optional[str] = None
86
+ network_ops: list[NetworkOp] = Field(default_factory=list)
87
+
88
+
89
+ class NetworkSnapshot(BaseModel):
90
+ version: int
91
+ reason: str
92
+ nodes: list[DecidronNode]
93
+ edges: list[Edge]
94
+ timestamp: float = Field(default_factory=time.time)
95
+
96
+
97
+ class RunResult(BaseModel):
98
+ command: str
99
+ sensor: str
100
+ value: str
101
+ matched: bool
102
+ output: Optional[str] = None
103
+ actuator: Optional[str] = None
104
+ network_ops_applied: int = 0
105
+ latency_ms: float = 0.0
106
+ timestamp: float = Field(default_factory=time.time)
backend/app/decidron/network.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """In-memory Decidron network state with version history.
2
+
3
+ Holds the live topology (nodes + directed couplings), applies topology
4
+ modifications, and snapshots the network whenever it changes so the UI
5
+ can show before/after diagrams.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from pathlib import Path
12
+
13
+ from .models import DecidronNode, Edge, NetworkOp, NetworkSnapshot
14
+
15
+ _DATA_DIR = Path(__file__).resolve().parent.parent / "data" / "decidron"
16
+ _DEFAULT_NETWORK = _DATA_DIR / "default_network.json"
17
+
18
+
19
+ class NetworkStore:
20
+ def __init__(self) -> None:
21
+ self.nodes: dict[str, DecidronNode] = {}
22
+ self.edges: list[Edge] = []
23
+ self.version: int = 0
24
+ self.history: list[NetworkSnapshot] = []
25
+ self.load_default()
26
+
27
+ def load_default(self) -> None:
28
+ raw = json.loads(_DEFAULT_NETWORK.read_text(encoding="utf-8"))
29
+ self.nodes = {n["id"]: DecidronNode(**n) for n in raw.get("nodes", [])}
30
+ self.edges = [Edge(**e) for e in raw.get("edges", [])]
31
+ self.version = 0
32
+ self.history = []
33
+ self._snapshot("initial network")
34
+
35
+ def reset(self) -> None:
36
+ self.load_default()
37
+
38
+ # -- queries ---------------------------------------------------------
39
+
40
+ def neighbors_out(self, node_id: str) -> list[str]:
41
+ """Nodes reachable from ``node_id`` via an outgoing coupling."""
42
+ return [e.target for e in self.edges if e.source == node_id]
43
+
44
+ def nodes_list(self) -> list[DecidronNode]:
45
+ return list(self.nodes.values())
46
+
47
+ def find_sensor_node(self, channel: str) -> DecidronNode | None:
48
+ """Match a sensor channel to a sensor node by id or label."""
49
+ for node in self.nodes.values():
50
+ if node.type != "sensor":
51
+ continue
52
+ if node.id == channel or node.label.lower() == channel.lower():
53
+ return node
54
+ return None
55
+
56
+ # -- mutations -------------------------------------------------------
57
+
58
+ def _snapshot(self, reason: str) -> None:
59
+ self.history.append(
60
+ NetworkSnapshot(
61
+ version=self.version,
62
+ reason=reason,
63
+ nodes=[n.model_copy(deep=True) for n in self.nodes.values()],
64
+ edges=[e.model_copy(deep=True) for e in self.edges],
65
+ )
66
+ )
67
+
68
+ def _edge_exists(self, source: str, target: str) -> bool:
69
+ return any(e.source == source and e.target == target for e in self.edges)
70
+
71
+ def apply_op(self, op: NetworkOp) -> bool:
72
+ """Apply a single topology op. Returns True if the graph changed."""
73
+ if op.op == "add_node" and op.node is not None:
74
+ if op.node.id not in self.nodes:
75
+ self.nodes[op.node.id] = op.node
76
+ return True
77
+ return False
78
+ if op.op == "add_edge" and op.source and op.target:
79
+ if not self._edge_exists(op.source, op.target):
80
+ self.edges.append(Edge(source=op.source, target=op.target))
81
+ return True
82
+ return False
83
+ if op.op == "remove_edge" and op.source and op.target:
84
+ before = len(self.edges)
85
+ self.edges = [
86
+ e for e in self.edges
87
+ if not (e.source == op.source and e.target == op.target)
88
+ ]
89
+ return len(self.edges) != before
90
+ return False
91
+
92
+ def apply_ops(self, ops: list[NetworkOp], reason: str) -> int:
93
+ """Apply ops, snapshotting once if anything changed. Returns count."""
94
+ changed = 0
95
+ for op in ops:
96
+ if self.apply_op(op):
97
+ changed += 1
98
+ if changed:
99
+ self.version += 1
100
+ self._snapshot(reason)
101
+ return changed
backend/app/decidron/stats.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Performance statistics for the Decidron simulator.
2
+
3
+ Aggregates per-sensor, per-command, and per-output counts plus latency
4
+ and last-run timestamps -- a deterministic stand-in for the patent's
5
+ "results determined and scored" step.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import time
11
+ from collections import defaultdict
12
+
13
+
14
+ class StatsCollector:
15
+ def __init__(self) -> None:
16
+ self.sensors: dict[str, dict] = defaultdict(self._blank)
17
+ self.commands: dict[str, dict] = defaultdict(self._blank)
18
+ self.outputs: dict[str, dict] = defaultdict(self._blank)
19
+ self.total_runs: int = 0
20
+ self.total_matches: int = 0
21
+
22
+ @staticmethod
23
+ def _blank() -> dict:
24
+ return {"count": 0, "matches": 0, "last_run": None, "avg_latency_ms": 0.0}
25
+
26
+ @staticmethod
27
+ def _update(bucket: dict, matched: bool, latency_ms: float) -> None:
28
+ n = bucket["count"]
29
+ bucket["count"] = n + 1
30
+ if matched:
31
+ bucket["matches"] += 1
32
+ # incremental average latency
33
+ bucket["avg_latency_ms"] = (bucket["avg_latency_ms"] * n + latency_ms) / (n + 1)
34
+ bucket["last_run"] = time.time()
35
+
36
+ def record(
37
+ self,
38
+ sensor: str,
39
+ command: str,
40
+ output: str | None,
41
+ matched: bool,
42
+ latency_ms: float,
43
+ ) -> None:
44
+ self.total_runs += 1
45
+ if matched:
46
+ self.total_matches += 1
47
+ self._update(self.sensors[sensor], matched, latency_ms)
48
+ self._update(self.commands[command], matched, latency_ms)
49
+ if matched and output:
50
+ self._update(self.outputs[output], True, latency_ms)
51
+
52
+ def reset(self) -> None:
53
+ self.__init__()
54
+
55
+ def as_dict(self) -> dict:
56
+ return {
57
+ "total_runs": self.total_runs,
58
+ "total_matches": self.total_matches,
59
+ "per_sensor": dict(self.sensors),
60
+ "per_command": dict(self.commands),
61
+ "per_output": dict(self.outputs),
62
+ }
backend/app/main.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+
6
+ from fastapi import FastAPI, Request
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+
9
+ from app.config import settings
10
+ from app.api import decidron
11
+
12
+ logging.basicConfig(level=logging.INFO)
13
+ LOG = logging.getLogger(__name__)
14
+
15
+ STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
16
+
17
+ app = FastAPI(
18
+ title="Decidron Network Simulator",
19
+ version="1.0.0",
20
+ )
21
+
22
+ app.add_middleware(
23
+ CORSMiddleware,
24
+ allow_origins=settings.cors_origin_list or ["*"],
25
+ allow_credentials=True,
26
+ allow_methods=["*"],
27
+ allow_headers=["*"],
28
+ )
29
+
30
+ app.include_router(decidron.router, prefix="/api")
31
+
32
+
33
+ @app.get("/api/health")
34
+ async def health():
35
+ return {"status": "ok"}
36
+
37
+
38
+ if STATIC_DIR.is_dir():
39
+ from starlette.staticfiles import StaticFiles # noqa: F401
40
+ from starlette.responses import FileResponse
41
+
42
+ @app.get("/{full_path:path}")
43
+ async def serve_spa(request: Request, full_path: str):
44
+ file_path = STATIC_DIR / full_path
45
+ if file_path.is_file():
46
+ return FileResponse(file_path)
47
+ return FileResponse(STATIC_DIR / "index.html")
48
+
49
+ LOG.info("Serving frontend from %s", STATIC_DIR)
50
+ else:
51
+ LOG.info("No static directory found at %s — frontend not served.", STATIC_DIR)
backend/app/utils/__init__.py ADDED
File without changes
backend/dev.ps1 ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Backend dev server with WORKING hot reload on Windows.
2
+ #
3
+ # uvicorn's built-in `--reload` is unreliable on Windows: it detects file
4
+ # changes ("Reloading...") but often fails to actually restart the worker,
5
+ # so it silently keeps serving stale code. This wraps uvicorn in
6
+ # `watchfiles`, which does a clean full process restart on every change.
7
+ #
8
+ # Usage (from the backend/ folder):
9
+ # .\dev.ps1 # app.main:app on 127.0.0.1:7860, watching .\app
10
+ # .\dev.ps1 -Port 8000 # different port
11
+ # .\dev.ps1 -App pkg.main:app -Watch pkg
12
+ #
13
+ # Requires `watchfiles` (bundled with uvicorn[standard], or `pip install watchfiles`).
14
+
15
+ param(
16
+ [string]$App = "app.main:app",
17
+ [int]$Port = 7860,
18
+ [string]$Watch = "app",
19
+ [string]$BindHost = "127.0.0.1"
20
+ )
21
+
22
+ $ErrorActionPreference = "Stop"
23
+
24
+ # Prefer the project venv's interpreter if present.
25
+ $py = if (Test-Path ".venv\Scripts\python.exe") { ".venv\Scripts\python.exe" } else { "python" }
26
+
27
+ Write-Host "watchfiles -> uvicorn $App on http://$BindHost`:$Port (watching .\$Watch)" -ForegroundColor Cyan
28
+ & $py -m watchfiles "$py -m uvicorn $App --host $BindHost --port $Port" $Watch
backend/dev.sh ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Backend dev server with hot reload via watchfiles (cross-platform).
3
+ #
4
+ # Equivalent to dev.ps1. uvicorn's built-in --reload is unreliable on
5
+ # Windows; watchfiles does a clean full process restart on every change
6
+ # and works the same everywhere.
7
+ #
8
+ # Usage (from the backend/ folder):
9
+ # ./dev.sh # app.main:app on 127.0.0.1:7860, watching ./app
10
+ # ./dev.sh app.main:app 8000 app
11
+ #
12
+ # Requires watchfiles (bundled with uvicorn[standard], or pip install watchfiles).
13
+ set -euo pipefail
14
+
15
+ APP="${1:-app.main:app}"
16
+ PORT="${2:-7860}"
17
+ WATCH="${3:-app}"
18
+ BIND_HOST="${4:-127.0.0.1}"
19
+
20
+ PY=".venv/bin/python"
21
+ [ -x "$PY" ] || PY="python"
22
+
23
+ echo "watchfiles -> uvicorn $APP on http://$BIND_HOST:$PORT (watching ./$WATCH)"
24
+ exec "$PY" -m watchfiles "$PY -m uvicorn $APP --host $BIND_HOST --port $PORT" "$WATCH"
backend/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ uvicorn[standard]>=0.32.0
3
+ pydantic-settings>=2.6.0
4
+ python-dotenv>=1.0.0
5
+ networkx>=3.3
6
+ pytest>=8.0.0
docker-compose.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ services:
2
+ app:
3
+ build: .
4
+ ports:
5
+ # Container always listens on 7860 (matches HuggingFace Space
6
+ # app_port). Host port is overridable via CCAI_HOST_PORT in .env
7
+ # or the environment so local devs can sidestep port conflicts.
8
+ - "${CCAI_HOST_PORT:-7860}:7860"
9
+ env_file:
10
+ - .env
11
+ environment:
12
+ CORS_ORIGINS: "http://localhost:7860,http://localhost:3000"
13
+ restart: unless-stopped
frontend/.gitignore ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2
+
3
+ # dependencies
4
+ /node_modules
5
+ /.pnp
6
+ .pnp.js
7
+
8
+ # testing
9
+ /coverage
10
+
11
+ # production
12
+ /build
13
+
14
+ # misc
15
+ .DS_Store
16
+ .env.local
17
+ .env.development.local
18
+ .env.test.local
19
+ .env.production.local
20
+
21
+ npm-debug.log*
22
+ yarn-debug.log*
23
+ yarn-error.log*
frontend/package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
frontend/package.json ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "frontend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "dependencies": {
6
+ "lucide-react": "^0.577.0",
7
+ "react": "^19.2.4",
8
+ "react-dom": "^19.2.4",
9
+ "react-scripts": "5.0.1",
10
+ "vis-data": "^7.1.9",
11
+ "vis-network": "^9.1.9"
12
+ },
13
+ "scripts": {
14
+ "start": "react-scripts start",
15
+ "build": "react-scripts build",
16
+ "test": "react-scripts test",
17
+ "eject": "react-scripts eject"
18
+ },
19
+ "eslintConfig": {
20
+ "extends": "react-app"
21
+ },
22
+ "browserslist": {
23
+ "production": [
24
+ ">0.2%",
25
+ "not dead",
26
+ "not op_mini all"
27
+ ],
28
+ "development": [
29
+ "last 1 chrome version",
30
+ "last 1 firefox version",
31
+ "last 1 safari version"
32
+ ]
33
+ }
34
+ }
frontend/public/favicon.ico ADDED
frontend/public/index.html ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en" data-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <link rel="icon" type="image/png" href="/neon-logo.png" />
7
+ <meta name="theme-color" content="#6366F1" />
8
+ <meta name="description" content="CCAI Demo" />
9
+ <title>CCAI Demo</title>
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ </body>
14
+ </html>
frontend/public/manifest.json ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "short_name": "React App",
3
+ "name": "Create React App Sample",
4
+ "icons": [
5
+ {
6
+ "src": "favicon.ico",
7
+ "sizes": "64x64 32x32 24x24 16x16",
8
+ "type": "image/x-icon"
9
+ },
10
+ {
11
+ "src": "logo192.png",
12
+ "type": "image/png",
13
+ "sizes": "192x192"
14
+ },
15
+ {
16
+ "src": "logo512.png",
17
+ "type": "image/png",
18
+ "sizes": "512x512"
19
+ }
20
+ ],
21
+ "start_url": ".",
22
+ "display": "standalone",
23
+ "theme_color": "#000000",
24
+ "background_color": "#ffffff"
25
+ }
frontend/public/neon-logo.png ADDED

Git LFS Details

  • SHA256: 9c9839f8c25e457a57c2d30bffefbf7d31607b071ad85004445054f899c5ec28
  • Pointer size: 131 Bytes
  • Size of remote file: 130 kB
frontend/public/robots.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ # https://www.robotstxt.org/robotstxt.html
2
+ User-agent: *
frontend/src/App.js ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect } from 'react';
2
+ import Header from './components/Header';
3
+ import DecidronSimulator from './components/decidron/DecidronSimulator';
4
+ import { loadSettings, saveSettings } from './utils/settings';
5
+ import './styles/variables.css';
6
+ import './styles/layout.css';
7
+ import './styles/components.css';
8
+ import './styles/ccai.css';
9
+
10
+ const THEME_KEY = 'decidron-theme';
11
+
12
+ export default function App() {
13
+ const [theme, setTheme] = useState(
14
+ () => localStorage.getItem(THEME_KEY)
15
+ || (window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
16
+ );
17
+ const [settings, setSettings] = useState(() => loadSettings());
18
+
19
+ useEffect(() => {
20
+ document.documentElement.setAttribute('data-theme', theme);
21
+ localStorage.setItem(THEME_KEY, theme);
22
+ }, [theme]);
23
+
24
+ useEffect(() => {
25
+ saveSettings(settings);
26
+ }, [settings]);
27
+
28
+ const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark'));
29
+
30
+ return (
31
+ <div className="app">
32
+ <Header
33
+ theme={theme}
34
+ onToggleTheme={toggleTheme}
35
+ settings={settings}
36
+ onSettingsChange={setSettings}
37
+ />
38
+ <main className="app-main">
39
+ <DecidronSimulator settings={settings} />
40
+ </main>
41
+ </div>
42
+ );
43
+ }
frontend/src/components/Header.js ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Sun, Moon } from 'lucide-react';
3
+ import SettingsMenu from './SettingsMenu';
4
+
5
+ /**
6
+ * Slim branded header for the Decidron simulator.
7
+ */
8
+ export default function Header({ theme, onToggleTheme, settings, onSettingsChange }) {
9
+ return (
10
+ <header className="app-header">
11
+ <div className="header-left">
12
+ <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="header-brand-link">
13
+ <img src="/neon-logo.png" alt="Neon.ai" className="app-logo" />
14
+ </a>
15
+ <h1 className="app-title">
16
+ <a href="https://www.neon.ai/" target="_blank" rel="noopener noreferrer" className="app-title-link">
17
+ Neon.ai
18
+ </a> - Decidron Network Simulator
19
+ </h1>
20
+ </div>
21
+ <div className="header-right">
22
+ <SettingsMenu settings={settings} onChange={onSettingsChange} />
23
+ <button
24
+ type="button"
25
+ className="btn-sm btn-outline icon-btn"
26
+ onClick={onToggleTheme}
27
+ title={theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'}
28
+ >
29
+ {theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
30
+ </button>
31
+ </div>
32
+ </header>
33
+ );
34
+ }
frontend/src/components/SettingsMenu.js ADDED
@@ -0,0 +1,88 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useRef } from 'react';
2
+ import { Settings } from 'lucide-react';
3
+ import { LAYOUT_MODES, PANEL_KEYS, PANEL_LABELS } from '../utils/settings';
4
+
5
+ const LAYOUT_OPTIONS = [
6
+ { id: LAYOUT_MODES.RANDOM, label: 'Random seed (default)', hint: 'Layout may shift on each run' },
7
+ { id: LAYOUT_MODES.FIXED_SEED, label: 'Fixed seed', hint: 'Same layout every run unless topology changes' },
8
+ { id: LAYOUT_MODES.EXPLICIT, label: 'Explicit coordinates', hint: 'Fixed positions; physics off' },
9
+ ];
10
+
11
+ export default function SettingsMenu({ settings, onChange }) {
12
+ const [open, setOpen] = useState(false);
13
+ const wrapRef = useRef(null);
14
+
15
+ useEffect(() => {
16
+ if (!open) return undefined;
17
+ const onDocClick = (e) => {
18
+ if (wrapRef.current && !wrapRef.current.contains(e.target)) {
19
+ setOpen(false);
20
+ }
21
+ };
22
+ document.addEventListener('mousedown', onDocClick);
23
+ return () => document.removeEventListener('mousedown', onDocClick);
24
+ }, [open]);
25
+
26
+ const setLayoutMode = (layoutMode) => {
27
+ onChange({ ...settings, layoutMode });
28
+ };
29
+
30
+ const togglePanel = (key) => {
31
+ onChange({
32
+ ...settings,
33
+ panels: { ...settings.panels, [key]: !settings.panels[key] },
34
+ });
35
+ };
36
+
37
+ return (
38
+ <div className="ccai-dropdown-wrap decidron-settings-wrap" ref={wrapRef}>
39
+ <button
40
+ type="button"
41
+ className="btn-sm btn-outline icon-btn decidron-settings-trigger"
42
+ onClick={() => setOpen((v) => !v)}
43
+ title="Settings"
44
+ aria-expanded={open}
45
+ aria-haspopup="true"
46
+ >
47
+ <Settings size={16} />
48
+ </button>
49
+ {open && (
50
+ <div className="ccai-dropdown-panel decidron-settings-panel" role="menu">
51
+ <div className="ccai-dropdown-section">
52
+ <div className="ccai-dropdown-section-title">Diagram node placement</div>
53
+ {LAYOUT_OPTIONS.map((opt) => (
54
+ <label key={opt.id} className="decidron-settings-radio">
55
+ <input
56
+ type="radio"
57
+ name="layoutMode"
58
+ checked={settings.layoutMode === opt.id}
59
+ onChange={() => setLayoutMode(opt.id)}
60
+ />
61
+ <span>
62
+ <strong>{opt.label}</strong>
63
+ <span className="decidron-settings-hint">{opt.hint}</span>
64
+ </span>
65
+ </label>
66
+ ))}
67
+ </div>
68
+ <div className="ccai-dropdown-divider" />
69
+ <div className="ccai-dropdown-section">
70
+ <div className="ccai-dropdown-section-title">Right-side panels</div>
71
+ {PANEL_KEYS.map((key) => (
72
+ <label key={key} className="ccai-dropdown-item decidron-settings-check">
73
+ <input
74
+ type="checkbox"
75
+ checked={!!settings.panels[key]}
76
+ onChange={() => togglePanel(key)}
77
+ />
78
+ <span className="ccai-dropdown-item-text">
79
+ <span className="ccai-dropdown-item-name">{PANEL_LABELS[key]}</span>
80
+ </span>
81
+ </label>
82
+ ))}
83
+ </div>
84
+ </div>
85
+ )}
86
+ </div>
87
+ );
88
+ }
frontend/src/components/decidron/CommandBuilder.js ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState } from 'react';
2
+ import { Plus, Pencil, Trash2, X } from 'lucide-react';
3
+
4
+ const MATCH_OPS = ['contains', 'equals', 'gt', 'gte', 'lt', 'lte', 'startswith', 'endswith', 'regex'];
5
+
6
+ function parseMatch(match) {
7
+ if (typeof match === 'string' && match.includes(':')) {
8
+ const [op, ...rest] = match.split(':');
9
+ return { op: MATCH_OPS.includes(op) ? op : 'contains', value: rest.join(':') };
10
+ }
11
+ return { op: 'contains', value: match || '' };
12
+ }
13
+
14
+ const BLANK = { name: '', sensor: '', op: 'gte', matchValue: '', output: '', actuator: '' };
15
+
16
+ export default function CommandBuilder({ commands, sensors, nodes, onCreate, onDelete }) {
17
+ const [form, setForm] = useState(BLANK);
18
+ const [editing, setEditing] = useState(null); // original name being edited
19
+ const [preservedOps, setPreservedOps] = useState([]); // keep network_ops we don't edit in the UI
20
+ const [error, setError] = useState(null);
21
+
22
+ const actuators = nodes.filter((n) => n.type === 'actuator');
23
+ const set = (k) => (e) => setForm((f) => ({ ...f, [k]: e.target.value }));
24
+
25
+ const resetForm = () => {
26
+ setForm(BLANK);
27
+ setEditing(null);
28
+ setPreservedOps([]);
29
+ };
30
+
31
+ const startEdit = (c) => {
32
+ const { op, value } = parseMatch(c.match);
33
+ setForm({
34
+ name: c.name,
35
+ sensor: c.sensor,
36
+ op,
37
+ matchValue: value,
38
+ output: c.output,
39
+ actuator: c.actuator || '',
40
+ });
41
+ setPreservedOps(c.network_ops || []);
42
+ setEditing(c.name);
43
+ setError(null);
44
+ };
45
+
46
+ const submit = async (e) => {
47
+ e.preventDefault();
48
+ setError(null);
49
+ if (!form.name || !form.sensor || form.matchValue === '' || !form.output) {
50
+ setError('Name, sensor, match value, and output are required.');
51
+ return;
52
+ }
53
+ try {
54
+ await onCreate({
55
+ name: form.name,
56
+ sensor: form.sensor,
57
+ match: `${form.op}:${form.matchValue}`,
58
+ output: form.output,
59
+ actuator: form.actuator || null,
60
+ network_ops: preservedOps,
61
+ }, editing);
62
+ resetForm();
63
+ } catch (err) {
64
+ setError(err.message);
65
+ }
66
+ };
67
+
68
+ return (
69
+ <div className="decidron-panel">
70
+ <h2>Processing Commands</h2>
71
+ <p className="panel-hint">
72
+ Sample commands are loaded below — edit or delete them, or add your
73
+ own. A command is IF (sensor matches) THEN (emit output, optionally
74
+ drive an actuator). Commands fire when you run the simulation.
75
+ </p>
76
+
77
+ {error && <div className="decidron-error">{error}</div>}
78
+
79
+ <form onSubmit={submit}>
80
+ <div className="decidron-row">
81
+ <div className="decidron-field" style={{ flex: 1 }}>
82
+ <label>Name</label>
83
+ <input value={form.name} onChange={set('name')} placeholder="HighTempAlert" />
84
+ </div>
85
+ <div className="decidron-field" style={{ flex: 1 }}>
86
+ <label>Sensor</label>
87
+ <select value={form.sensor} onChange={set('sensor')}>
88
+ <option value="">Select…</option>
89
+ {sensors.map((s) => <option key={s.id} value={s.id}>{s.label}</option>)}
90
+ </select>
91
+ </div>
92
+ </div>
93
+ <div className="decidron-row">
94
+ <div className="decidron-field" style={{ width: 120 }}>
95
+ <label>Match</label>
96
+ <select value={form.op} onChange={set('op')}>
97
+ {MATCH_OPS.map((o) => <option key={o} value={o}>{o}</option>)}
98
+ </select>
99
+ </div>
100
+ <div className="decidron-field" style={{ flex: 1 }}>
101
+ <label>Value</label>
102
+ <input value={form.matchValue} onChange={set('matchValue')} placeholder="85" />
103
+ </div>
104
+ <div className="decidron-field" style={{ flex: 1 }}>
105
+ <label>Actuator (optional)</label>
106
+ <select value={form.actuator} onChange={set('actuator')}>
107
+ <option value="">None</option>
108
+ {actuators.map((a) => <option key={a.id} value={a.id}>{a.label}</option>)}
109
+ </select>
110
+ </div>
111
+ </div>
112
+ <div className="decidron-field">
113
+ <label>Output</label>
114
+ <input value={form.output} onChange={set('output')} placeholder="Trigger failsafe review" />
115
+ </div>
116
+ {editing && preservedOps.length > 0 && (
117
+ <p className="panel-hint">
118
+ This command has {preservedOps.length} network op(s); they are
119
+ preserved when you save.
120
+ </p>
121
+ )}
122
+ <div className="decidron-row">
123
+ <button type="submit" className="decidron-btn">
124
+ {editing
125
+ ? <><Pencil size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />Update Command</>
126
+ : <><Plus size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />Add Command</>}
127
+ </button>
128
+ {editing && (
129
+ <button type="button" className="decidron-btn secondary" onClick={resetForm}>
130
+ <X size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
131
+ Cancel
132
+ </button>
133
+ )}
134
+ </div>
135
+ </form>
136
+
137
+ <div style={{ marginTop: '0.75rem' }}>
138
+ {commands.map((c) => (
139
+ <div className="decidron-cmd" key={c.name}>
140
+ <div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem' }}>
141
+ <span>
142
+ <strong>{c.name}</strong>: IF <code>{c.sensor}</code> <code>{c.match}</code> THEN {c.output}
143
+ {c.actuator && <> → drives <code>{c.actuator}</code></>}
144
+ {c.network_ops && c.network_ops.length > 0 && <> <span className="decidron-chip">{c.network_ops.length} network op(s)</span></>}
145
+ </span>
146
+ <span style={{ whiteSpace: 'nowrap' }}>
147
+ <button type="button" className="decidron-btn secondary" title="Edit" onClick={() => startEdit(c)} style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }}>
148
+ <Pencil size={13} />
149
+ </button>
150
+ <button type="button" className="decidron-btn secondary" title="Delete" onClick={() => onDelete(c.name)} style={{ padding: '0.2rem 0.4rem', marginLeft: 4 }}>
151
+ <Trash2 size={13} />
152
+ </button>
153
+ </span>
154
+ </div>
155
+ </div>
156
+ ))}
157
+ </div>
158
+ </div>
159
+ );
160
+ }
frontend/src/components/decidron/DecidronSimulator.js ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useState, useEffect, useCallback } from 'react';
2
+ import { RotateCcw } from 'lucide-react';
3
+ import NetworkDiagram from './NetworkDiagram';
4
+ import SensorInputForm from './SensorInputForm';
5
+ import CommandBuilder from './CommandBuilder';
6
+ import ResultsPanel from './ResultsPanel';
7
+ import StatsTable from './StatsTable';
8
+ import HistoryTimeline from './HistoryTimeline';
9
+ import {
10
+ getNetwork, getCommands, getStats, getHistory,
11
+ submitSensorInput, runSimulation, createCommand, deleteCommand, resetSimulation,
12
+ } from '../../utils/api';
13
+ import '../../styles/decidron.css';
14
+
15
+ const DEFAULT_VALUE = '97';
16
+
17
+ export default function DecidronSimulator({ settings }) {
18
+ const [network, setNetwork] = useState(null);
19
+ const [commands, setCommands] = useState([]);
20
+ const [stats, setStats] = useState(null);
21
+ const [history, setHistory] = useState([]);
22
+ const [results, setResults] = useState([]);
23
+ const [pending, setPending] = useState([]);
24
+ const [selected, setSelected] = useState(null); // null = live network
25
+ const [running, setRunning] = useState(false);
26
+ const [error, setError] = useState(null);
27
+ // Live sensor-input form values, pre-populated so the user can press
28
+ // "Run Simulation" immediately and see the network react.
29
+ const [channel, setChannel] = useState('');
30
+ const [value, setValue] = useState(DEFAULT_VALUE);
31
+
32
+ const refreshAll = useCallback(async () => {
33
+ try {
34
+ const [net, cmds, st, hist] = await Promise.all([
35
+ getNetwork(), getCommands(), getStats(), getHistory(),
36
+ ]);
37
+ setNetwork(net);
38
+ setCommands(cmds);
39
+ setStats(st);
40
+ setHistory(hist.snapshots);
41
+ } catch (err) {
42
+ setError(err.message);
43
+ }
44
+ }, []);
45
+
46
+ useEffect(() => { refreshAll(); }, [refreshAll]);
47
+
48
+ // Default the channel to the first sensor once the network loads.
49
+ useEffect(() => {
50
+ if (network && !channel) {
51
+ const firstSensor = network.nodes.find((n) => n.type === 'sensor');
52
+ if (firstSensor) setChannel(firstSensor.id);
53
+ }
54
+ }, [network, channel]);
55
+
56
+ const handleQueue = async () => {
57
+ if (!channel || value === '') return;
58
+ setError(null);
59
+ try {
60
+ await submitSensorInput(channel, value);
61
+ setPending((prev) => [...prev, { channel, value }]);
62
+ } catch (err) {
63
+ setError(err.message);
64
+ }
65
+ };
66
+
67
+ const handleRun = async () => {
68
+ // Run queued inputs if any; otherwise run the current form value
69
+ // inline so a single click works on first load.
70
+ const inputs = pending.length > 0 ? null : [{ channel, value }];
71
+ if (pending.length === 0 && (!channel || value === '')) return;
72
+ setRunning(true);
73
+ setError(null);
74
+ try {
75
+ const resp = await runSimulation(inputs);
76
+ setResults(resp.results);
77
+ setNetwork(resp.network);
78
+ setSelected(null);
79
+ setPending([]);
80
+ const [st, hist] = await Promise.all([getStats(), getHistory()]);
81
+ setStats(st);
82
+ setHistory(hist.snapshots);
83
+ } catch (err) {
84
+ setError(err.message);
85
+ } finally {
86
+ setRunning(false);
87
+ }
88
+ };
89
+
90
+ const handleCreate = async (cmd, replaceName = null) => {
91
+ await createCommand(cmd);
92
+ if (replaceName && replaceName !== cmd.name) {
93
+ await deleteCommand(replaceName);
94
+ }
95
+ setCommands(await getCommands());
96
+ };
97
+
98
+ const handleDelete = async (name) => {
99
+ setError(null);
100
+ try {
101
+ await deleteCommand(name);
102
+ setCommands(await getCommands());
103
+ } catch (err) {
104
+ setError(err.message);
105
+ }
106
+ };
107
+
108
+ const handleReset = async () => {
109
+ setError(null);
110
+ try {
111
+ await resetSimulation();
112
+ setResults([]);
113
+ setPending([]);
114
+ setSelected(null);
115
+ await refreshAll();
116
+ } catch (err) {
117
+ setError(err.message);
118
+ }
119
+ };
120
+
121
+ if (!network) {
122
+ return (
123
+ <div className="decidron-app">
124
+ {error ? <div className="decidron-error">{error}</div> : <p>Loading…</p>}
125
+ </div>
126
+ );
127
+ }
128
+
129
+ const sensors = network.nodes.filter((n) => n.type === 'sensor');
130
+ const shownDiagram = selected ? selected.diagram : network.diagram;
131
+ const shownVersion = selected ? selected.version : network.version;
132
+ const { panels, layoutMode } = settings;
133
+
134
+ return (
135
+ <div className="decidron-app">
136
+ {error && <div className="decidron-error">{error}</div>}
137
+ <div className="decidron-grid">
138
+ <div>
139
+ <div className="decidron-panel">
140
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
141
+ <h2>Decidron Network (v{shownVersion}{selected ? ' — historical' : ' — live'})</h2>
142
+ <button type="button" className="decidron-btn secondary" onClick={handleReset}>
143
+ <RotateCcw size={13} style={{ marginRight: 4, verticalAlign: 'middle' }} />
144
+ Reset
145
+ </button>
146
+ </div>
147
+ <p className="panel-hint">
148
+ Sensors (blue) feed coupled Decidrons (purple) that drive
149
+ actuators (green). Commands can modify this topology.
150
+ </p>
151
+ <NetworkDiagram data={shownDiagram} layoutMode={layoutMode} />
152
+ </div>
153
+ <HistoryTimeline
154
+ snapshots={history}
155
+ selectedVersion={selected ? selected.version : null}
156
+ onSelect={(s) => setSelected(
157
+ s.version === network.version ? null : s
158
+ )}
159
+ />
160
+ </div>
161
+
162
+ <div>
163
+ {panels.sensorInput && (
164
+ <SensorInputForm
165
+ sensors={sensors}
166
+ channel={channel}
167
+ value={value}
168
+ onChannelChange={setChannel}
169
+ onValueChange={setValue}
170
+ pending={pending}
171
+ onQueue={handleQueue}
172
+ onRun={handleRun}
173
+ running={running}
174
+ />
175
+ )}
176
+ {panels.commands && (
177
+ <CommandBuilder
178
+ commands={commands}
179
+ sensors={sensors}
180
+ nodes={network.nodes}
181
+ onCreate={handleCreate}
182
+ onDelete={handleDelete}
183
+ />
184
+ )}
185
+ {panels.results && <ResultsPanel results={results} />}
186
+ {panels.stats && <StatsTable stats={stats} />}
187
+ </div>
188
+ </div>
189
+ </div>
190
+ );
191
+ }
frontend/src/components/decidron/HistoryTimeline.js ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function HistoryTimeline({ snapshots, selectedVersion, onSelect }) {
4
+ return (
5
+ <div className="decidron-panel">
6
+ <h2>Network Modification History</h2>
7
+ <p className="panel-hint">
8
+ Each topology change is snapshotted. Select a version to view that
9
+ network state in the diagram above.
10
+ </p>
11
+ {(!snapshots || snapshots.length === 0) ? (
12
+ <p className="panel-hint">No snapshots yet.</p>
13
+ ) : (
14
+ snapshots.map((s) => (
15
+ <div
16
+ key={s.version}
17
+ className={`decidron-history-item ${s.version === selectedVersion ? 'active' : ''}`}
18
+ onClick={() => onSelect(s)}
19
+ >
20
+ <strong>v{s.version}</strong> — {s.reason}
21
+ <span style={{ float: 'right', opacity: 0.6 }}>
22
+ {new Date(s.timestamp * 1000).toLocaleTimeString()}
23
+ </span>
24
+ </div>
25
+ ))
26
+ )}
27
+ </div>
28
+ );
29
+ }
frontend/src/components/decidron/NetworkDiagram.js ADDED
@@ -0,0 +1,97 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React, { useEffect, useRef, useMemo } from 'react';
2
+ import { Network } from 'vis-network/standalone';
3
+ import { LAYOUT_MODES } from '../../utils/settings';
4
+
5
+ /** Known node positions for explicit-coordinate mode (vis-network canvas coords). */
6
+ const EXPLICIT_POSITIONS = {
7
+ temperature: { x: 0, y: -140 },
8
+ D1: { x: -120, y: 20 },
9
+ D2: { x: 120, y: 20 },
10
+ 'actuator-1': { x: 0, y: 140 },
11
+ D3: { x: 220, y: 20 },
12
+ };
13
+
14
+ function fallbackPosition(nodeId, index) {
15
+ const angle = (index * 2 * Math.PI) / 8;
16
+ return { x: Math.cos(angle) * 180, y: Math.sin(angle) * 120 };
17
+ }
18
+
19
+ function applyLayout(data, layoutMode) {
20
+ const base = data || { nodes: [], edges: [] };
21
+ const nodes = (base.nodes || []).map((node, index) => {
22
+ if (layoutMode !== LAYOUT_MODES.EXPLICIT) return { ...node };
23
+ const pos = EXPLICIT_POSITIONS[node.id] ?? fallbackPosition(node.id, index);
24
+ return { ...node, x: pos.x, y: pos.y, fixed: true };
25
+ });
26
+ return { nodes, edges: base.edges || [] };
27
+ }
28
+
29
+ function buildOptions(layoutMode) {
30
+ const options = {
31
+ autoResize: true,
32
+ nodes: {
33
+ borderWidth: 2,
34
+ font: { color: '#ffffff', size: 14 },
35
+ },
36
+ edges: {
37
+ arrows: 'to',
38
+ color: { color: '#9aa3b2', highlight: '#6366F1' },
39
+ font: { size: 10, color: '#9aa3b2', strokeWidth: 0, align: 'middle' },
40
+ smooth: { type: 'cubicBezier', roundness: 0.4 },
41
+ },
42
+ groups: {
43
+ sensor: { shape: 'box', color: { background: '#3b82f6', border: '#1d4ed8' } },
44
+ decidron: { shape: 'hexagon', color: { background: '#8b5cf6', border: '#6d28d9' } },
45
+ actuator: { shape: 'triangle', color: { background: '#10b981', border: '#047857' } },
46
+ },
47
+ interaction: { hover: true, tooltipDelay: 120 },
48
+ };
49
+
50
+ if (layoutMode === LAYOUT_MODES.EXPLICIT) {
51
+ options.physics = { enabled: false };
52
+ } else {
53
+ options.physics = {
54
+ stabilization: { iterations: 150 },
55
+ barnesHut: { springLength: 150, gravitationalConstant: -6000 },
56
+ };
57
+ if (layoutMode === LAYOUT_MODES.FIXED_SEED) {
58
+ options.layout = { randomSeed: 42 };
59
+ }
60
+ // RANDOM: no randomSeed — vis-network picks a new seed each build (default).
61
+ }
62
+
63
+ return options;
64
+ }
65
+
66
+ export default function NetworkDiagram({ data, layoutMode = LAYOUT_MODES.RANDOM, height = 380 }) {
67
+ const containerRef = useRef(null);
68
+ const networkRef = useRef(null);
69
+
70
+ const graphData = useMemo(
71
+ () => applyLayout(data, layoutMode),
72
+ [data, layoutMode],
73
+ );
74
+
75
+ useEffect(() => {
76
+ if (!containerRef.current) return undefined;
77
+ networkRef.current = new Network(
78
+ containerRef.current,
79
+ graphData,
80
+ buildOptions(layoutMode),
81
+ );
82
+ return () => {
83
+ if (networkRef.current) {
84
+ networkRef.current.destroy();
85
+ networkRef.current = null;
86
+ }
87
+ };
88
+ }, [graphData, layoutMode]);
89
+
90
+ return (
91
+ <div
92
+ ref={containerRef}
93
+ className="decidron-diagram"
94
+ style={{ height, width: '100%' }}
95
+ />
96
+ );
97
+ }
frontend/src/components/decidron/ResultsPanel.js ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ export default function ResultsPanel({ results }) {
4
+ return (
5
+ <div className="decidron-panel">
6
+ <h2>Latest Run Results</h2>
7
+ {(!results || results.length === 0) ? (
8
+ <p className="panel-hint">No runs yet. Queue a sensor input and run the simulation.</p>
9
+ ) : (
10
+ <table className="decidron-table">
11
+ <thead>
12
+ <tr>
13
+ <th>Command</th>
14
+ <th>Sensor</th>
15
+ <th>Value</th>
16
+ <th>Result</th>
17
+ <th>Output</th>
18
+ <th>Net ops</th>
19
+ </tr>
20
+ </thead>
21
+ <tbody>
22
+ {results.map((r, i) => (
23
+ <tr key={i}>
24
+ <td>{r.command}</td>
25
+ <td>{r.sensor}</td>
26
+ <td>{r.value}</td>
27
+ <td>
28
+ <span className={`decidron-chip ${r.matched ? 'match' : 'nomatch'}`}>
29
+ {r.matched ? 'matched' : 'no match'}
30
+ </span>
31
+ </td>
32
+ <td>{r.output || '—'}{r.actuator ? ` → ${r.actuator}` : ''}</td>
33
+ <td>{r.network_ops_applied}</td>
34
+ </tr>
35
+ ))}
36
+ </tbody>
37
+ </table>
38
+ )}
39
+ </div>
40
+ );
41
+ }
frontend/src/components/decidron/SensorInputForm.js ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import { Activity, Play } from 'lucide-react';
3
+
4
+ export default function SensorInputForm({
5
+ sensors, channel, value, onChannelChange, onValueChange,
6
+ pending, onQueue, onRun, running,
7
+ }) {
8
+ const handleQueue = (e) => {
9
+ e.preventDefault();
10
+ if (!channel || value === '') return;
11
+ onQueue();
12
+ };
13
+
14
+ const canRun = !running && (pending.length > 0 || (channel && value !== ''));
15
+ const runLabel = pending.length > 0
16
+ ? `Run Simulation (${pending.length} queued)`
17
+ : 'Run Simulation';
18
+
19
+ return (
20
+ <div className="decidron-panel">
21
+ <h2>Sensor Input</h2>
22
+ <p className="panel-hint">
23
+ A reading is pre-filled — just press <strong>Run Simulation</strong>
24
+ to see the network react. Use <strong>Queue</strong> to stack
25
+ several inputs before running.
26
+ </p>
27
+ <form onSubmit={handleQueue}>
28
+ <div className="decidron-row">
29
+ <div className="decidron-field" style={{ flex: 1 }}>
30
+ <label>Channel</label>
31
+ <select value={channel} onChange={(e) => onChannelChange(e.target.value)}>
32
+ {sensors.map((s) => (
33
+ <option key={s.id} value={s.id}>{s.label}</option>
34
+ ))}
35
+ </select>
36
+ </div>
37
+ <div className="decidron-field" style={{ flex: 1 }}>
38
+ <label>Value</label>
39
+ <input
40
+ type="text"
41
+ value={value}
42
+ placeholder="e.g. 97"
43
+ onChange={(e) => onValueChange(e.target.value)}
44
+ />
45
+ </div>
46
+ <button type="submit" className="decidron-btn secondary">
47
+ <Activity size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
48
+ Queue
49
+ </button>
50
+ </div>
51
+ </form>
52
+
53
+ {pending.length > 0 && (
54
+ <ul className="decidron-pending">
55
+ {pending.map((p, i) => (
56
+ <li key={i}>{p.channel}: {p.value}</li>
57
+ ))}
58
+ </ul>
59
+ )}
60
+
61
+ <div className="decidron-row" style={{ marginTop: '0.5rem' }}>
62
+ <button
63
+ type="button"
64
+ className="decidron-btn"
65
+ onClick={onRun}
66
+ disabled={!canRun}
67
+ >
68
+ <Play size={14} style={{ marginRight: 4, verticalAlign: 'middle' }} />
69
+ {running ? 'Running…' : runLabel}
70
+ </button>
71
+ </div>
72
+ </div>
73
+ );
74
+ }
frontend/src/components/decidron/StatsTable.js ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+
3
+ function Section({ title, data }) {
4
+ const rows = Object.entries(data || {});
5
+ if (rows.length === 0) return null;
6
+ return (
7
+ <>
8
+ <tr><th colSpan={4} style={{ paddingTop: '0.6rem' }}>{title}</th></tr>
9
+ {rows.map(([key, s]) => (
10
+ <tr key={title + key}>
11
+ <td>{key}</td>
12
+ <td>{s.count}</td>
13
+ <td>{s.matches}</td>
14
+ <td>{s.avg_latency_ms != null ? s.avg_latency_ms.toFixed(3) : '—'}</td>
15
+ </tr>
16
+ ))}
17
+ </>
18
+ );
19
+ }
20
+
21
+ export default function StatsTable({ stats }) {
22
+ const empty = !stats || stats.total_runs === 0;
23
+ return (
24
+ <div className="decidron-panel">
25
+ <h2>Performance Statistics</h2>
26
+ {empty ? (
27
+ <p className="panel-hint">Run the simulation to collect statistics.</p>
28
+ ) : (
29
+ <>
30
+ <p className="panel-hint">
31
+ {stats.total_runs} command evaluation(s), {stats.total_matches} match(es).
32
+ </p>
33
+ <table className="decidron-table">
34
+ <thead>
35
+ <tr><th>Key</th><th>Runs</th><th>Matches</th><th>Avg ms</th></tr>
36
+ </thead>
37
+ <tbody>
38
+ <Section title="Per Sensor" data={stats.per_sensor} />
39
+ <Section title="Per Command" data={stats.per_command} />
40
+ <Section title="Per Output" data={stats.per_output} />
41
+ </tbody>
42
+ </table>
43
+ </>
44
+ )}
45
+ </div>
46
+ );
47
+ }
frontend/src/index.js ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react';
2
+ import ReactDOM from 'react-dom/client';
3
+ import App from './App';
4
+
5
+ const root = ReactDOM.createRoot(document.getElementById('root'));
6
+ root.render(
7
+ <React.StrictMode>
8
+ <App />
9
+ </React.StrictMode>
10
+ );
frontend/src/styles/ccai.css ADDED
@@ -0,0 +1,1884 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ════════════════════════════════════════════════════════════════
2
+ Collaborative Conversational AI (CCAI) Demo - additive styles on
3
+ top of LLMChats3 baseline.
4
+ Anything new (multi-participant sidebar, dropdown, expert modal,
5
+ table view, orchestrator banners, failsafe banner) lives here.
6
+ ════════════════════════════════════════════════════════════════ */
7
+
8
+
9
+ /* ── Header dropdown (Participants) ────────────────────────────── */
10
+
11
+ .ccai-dropdown-wrap { position: relative; }
12
+
13
+ .ccai-dropdown-trigger {
14
+ display: inline-flex;
15
+ align-items: center;
16
+ gap: 6px;
17
+ }
18
+
19
+ .ccai-dropdown-panel {
20
+ position: absolute;
21
+ right: 0;
22
+ top: calc(100% + 4px);
23
+ width: 320px;
24
+ max-height: 70vh;
25
+ overflow-y: auto;
26
+ background: var(--card-bg);
27
+ border: 1px solid var(--border-primary);
28
+ border-radius: 10px;
29
+ box-shadow: var(--shadow-md);
30
+ padding: 10px;
31
+ z-index: 60;
32
+ }
33
+
34
+ .ccai-dropdown-section {
35
+ display: flex;
36
+ flex-direction: column;
37
+ gap: 4px;
38
+ padding: 4px 0;
39
+ }
40
+
41
+ .ccai-dropdown-section-title {
42
+ font-size: 10px;
43
+ font-weight: 600;
44
+ text-transform: uppercase;
45
+ letter-spacing: 0.05em;
46
+ color: var(--text-muted);
47
+ padding: 4px 6px;
48
+ }
49
+
50
+ .ccai-dropdown-divider {
51
+ height: 1px;
52
+ background: var(--border-muted);
53
+ margin: 4px -10px;
54
+ }
55
+
56
+ .ccai-dropdown-empty {
57
+ font-size: 11px;
58
+ color: var(--text-muted);
59
+ font-style: italic;
60
+ padding: 4px 8px;
61
+ }
62
+
63
+ .ccai-dropdown-item {
64
+ display: flex;
65
+ align-items: center;
66
+ gap: 10px;
67
+ padding: 8px 10px;
68
+ border-radius: 8px;
69
+ cursor: pointer;
70
+ transition: background 0.1s;
71
+ }
72
+
73
+ .ccai-dropdown-item:hover {
74
+ background: var(--bg-tertiary);
75
+ }
76
+
77
+ .ccai-dropdown-item input[type="checkbox"] {
78
+ margin: 0;
79
+ flex-shrink: 0;
80
+ cursor: pointer;
81
+ }
82
+
83
+ .ccai-dropdown-item-checked {
84
+ background: var(--accent-light);
85
+ }
86
+
87
+ .ccai-dropdown-item-disabled {
88
+ opacity: 0.5;
89
+ cursor: not-allowed;
90
+ }
91
+
92
+ .ccai-dropdown-item-disabled input[type="checkbox"] {
93
+ cursor: not-allowed;
94
+ }
95
+
96
+ .ccai-dropdown-item-text {
97
+ display: flex;
98
+ flex-direction: column;
99
+ gap: 2px;
100
+ min-width: 0;
101
+ }
102
+
103
+ .ccai-dropdown-item-name {
104
+ font-size: 13px;
105
+ font-weight: 500;
106
+ color: var(--text-primary);
107
+ text-transform: capitalize;
108
+ }
109
+
110
+ .ccai-dropdown-item-sub {
111
+ font-size: 10px;
112
+ color: var(--text-muted);
113
+ }
114
+
115
+ .ccai-dropdown-create-btn {
116
+ display: inline-flex;
117
+ align-items: center;
118
+ gap: 6px;
119
+ padding: 6px 10px;
120
+ margin-top: 4px;
121
+ border: 1px dashed var(--accent-primary);
122
+ border-radius: 8px;
123
+ background: transparent;
124
+ color: var(--accent-primary);
125
+ font-size: 12px;
126
+ font-weight: 500;
127
+ }
128
+
129
+ .ccai-dropdown-create-btn:hover {
130
+ background: var(--accent-light);
131
+ }
132
+
133
+
134
+ /* ── Sidebar: participants ─────────────────────────────────────── */
135
+
136
+ .ccai-sidebar {
137
+ gap: 12px;
138
+ }
139
+
140
+ .ccai-sidebar-header { gap: 4px; display: flex; flex-direction: column; }
141
+ .ccai-sidebar-help {
142
+ font-size: 11px;
143
+ color: var(--text-muted);
144
+ }
145
+
146
+ .ccai-participant-card {
147
+ border: 1px solid var(--border-primary);
148
+ border-radius: 10px;
149
+ background: var(--card-bg);
150
+ padding: 8px 10px;
151
+ display: flex;
152
+ flex-direction: column;
153
+ gap: 6px;
154
+ transition: opacity 0.15s, background 0.15s;
155
+ }
156
+
157
+ .ccai-participant-card-off {
158
+ opacity: 0.55;
159
+ background: var(--bg-secondary);
160
+ }
161
+
162
+ .ccai-participant-row {
163
+ display: flex;
164
+ align-items: center;
165
+ gap: 6px;
166
+ }
167
+
168
+ .ccai-participant-row-secondary {
169
+ justify-content: flex-end;
170
+ }
171
+
172
+ .ccai-accordion-chevron {
173
+ display: inline-flex;
174
+ align-items: center;
175
+ justify-content: center;
176
+ width: 22px;
177
+ height: 22px;
178
+ border: none;
179
+ background: transparent;
180
+ color: var(--text-muted);
181
+ border-radius: 4px;
182
+ flex-shrink: 0;
183
+ }
184
+
185
+ .ccai-accordion-chevron:hover {
186
+ color: var(--text-primary);
187
+ background: var(--bg-tertiary);
188
+ }
189
+
190
+ .ccai-participant-name {
191
+ flex: 1;
192
+ font-size: 13px;
193
+ font-weight: 600;
194
+ color: var(--text-primary);
195
+ text-transform: capitalize;
196
+ overflow: hidden;
197
+ text-overflow: ellipsis;
198
+ white-space: nowrap;
199
+ min-width: 0;
200
+ }
201
+
202
+ .ccai-participant-controls {
203
+ display: flex;
204
+ align-items: center;
205
+ gap: 6px;
206
+ }
207
+
208
+ .ccai-participant-body {
209
+ display: flex;
210
+ flex-direction: column;
211
+ gap: 6px;
212
+ padding: 6px 0 2px;
213
+ border-top: 1px solid var(--border-muted);
214
+ }
215
+
216
+ .ccai-participant-field {
217
+ display: flex;
218
+ flex-direction: column;
219
+ gap: 2px;
220
+ }
221
+
222
+ .ccai-participant-field-label {
223
+ font-size: 10px;
224
+ font-weight: 600;
225
+ text-transform: uppercase;
226
+ letter-spacing: 0.04em;
227
+ color: var(--text-tertiary);
228
+ }
229
+
230
+ .ccai-participant-field-value {
231
+ font-size: 12px;
232
+ color: var(--text-secondary);
233
+ word-break: break-word;
234
+ }
235
+
236
+ .ccai-participant-prompt {
237
+ margin: 0;
238
+ font-size: 11px;
239
+ line-height: 1.5;
240
+ color: var(--text-secondary);
241
+ background: var(--bg-secondary);
242
+ border: 1px solid var(--border-muted);
243
+ border-radius: 6px;
244
+ padding: 8px 10px;
245
+ white-space: pre-wrap;
246
+ word-break: break-word;
247
+ max-height: 160px;
248
+ overflow-y: auto;
249
+ }
250
+
251
+ /* On/off slider switch */
252
+ .ccai-toggle {
253
+ position: relative;
254
+ display: inline-block;
255
+ width: 36px;
256
+ height: 20px;
257
+ }
258
+
259
+ .ccai-toggle input {
260
+ opacity: 0;
261
+ width: 0;
262
+ height: 0;
263
+ }
264
+
265
+ .ccai-toggle-slider {
266
+ position: absolute;
267
+ cursor: pointer;
268
+ inset: 0;
269
+ background: var(--accent-primary);
270
+ border-radius: 10px;
271
+ transition: background 0.2s;
272
+ }
273
+
274
+ .ccai-toggle-slider::before {
275
+ content: '';
276
+ position: absolute;
277
+ height: 14px;
278
+ width: 14px;
279
+ left: 19px;
280
+ top: 3px;
281
+ background: #fff;
282
+ border-radius: 50%;
283
+ transition: left 0.2s;
284
+ }
285
+
286
+ .ccai-toggle input:not(:checked) + .ccai-toggle-slider {
287
+ background: var(--text-muted);
288
+ }
289
+
290
+ .ccai-toggle input:not(:checked) + .ccai-toggle-slider::before {
291
+ left: 3px;
292
+ }
293
+
294
+ .ccai-remove-btn {
295
+ display: inline-flex;
296
+ align-items: center;
297
+ gap: 4px;
298
+ padding: 4px 8px;
299
+ border: 1px solid #DC2626;
300
+ border-radius: 6px;
301
+ background: transparent;
302
+ color: #DC2626;
303
+ font-size: 11px;
304
+ }
305
+
306
+ .ccai-remove-btn:hover { background: #FEE2E2; }
307
+ :root[data-theme="dark"] .ccai-remove-btn:hover { background: rgba(220, 38, 38, 0.15); }
308
+
309
+ .ccai-reenable-btn { font-size: 11px; padding: 4px 8px; }
310
+
311
+
312
+ /* ── Expert Persona modal ──────────────────────────────────────── */
313
+
314
+ .ccai-expert-modal {
315
+ width: min(760px, 92vw);
316
+ max-height: 88vh;
317
+ }
318
+
319
+ .ccai-expert-row {
320
+ display: grid;
321
+ grid-template-columns: 1fr 1fr;
322
+ gap: 12px;
323
+ }
324
+
325
+ .ccai-expert-field {
326
+ display: flex;
327
+ flex-direction: column;
328
+ gap: 4px;
329
+ }
330
+
331
+ .ccai-expert-field label {
332
+ font-size: 11px;
333
+ font-weight: 600;
334
+ color: var(--text-tertiary);
335
+ text-transform: uppercase;
336
+ letter-spacing: 0.04em;
337
+ }
338
+
339
+ .ccai-expert-field input,
340
+ .ccai-expert-field select,
341
+ .ccai-expert-field textarea {
342
+ width: 100%;
343
+ padding: 8px 10px;
344
+ border: 1px solid var(--border-primary);
345
+ border-radius: 8px;
346
+ background: var(--bg-primary);
347
+ color: var(--text-primary);
348
+ font-size: 13px;
349
+ }
350
+
351
+ .ccai-tab-row {
352
+ display: flex;
353
+ align-items: center;
354
+ gap: 6px;
355
+ border-bottom: 1px solid var(--border-primary);
356
+ padding-bottom: 6px;
357
+ }
358
+
359
+ .ccai-tab-btn {
360
+ padding: 6px 12px;
361
+ border: 1px solid transparent;
362
+ border-radius: 6px;
363
+ background: transparent;
364
+ color: var(--text-secondary);
365
+ font-size: 12px;
366
+ font-weight: 600;
367
+ }
368
+
369
+ .ccai-tab-btn:hover {
370
+ background: var(--bg-tertiary);
371
+ color: var(--text-primary);
372
+ }
373
+
374
+ .ccai-tab-btn-active {
375
+ background: var(--accent-light);
376
+ color: var(--accent-primary);
377
+ border-color: var(--accent-primary);
378
+ }
379
+
380
+ .ccai-tab-spacer { flex: 1; }
381
+
382
+ .ccai-role-style {
383
+ display: inline-flex;
384
+ align-items: center;
385
+ gap: 4px;
386
+ font-size: 12px;
387
+ color: var(--text-secondary);
388
+ cursor: pointer;
389
+ }
390
+
391
+ .ccai-expert-freeform,
392
+ .ccai-expert-structured {
393
+ display: flex;
394
+ flex-direction: column;
395
+ gap: 8px;
396
+ }
397
+
398
+ .ccai-expert-freeform .freeform-textarea {
399
+ width: 100%;
400
+ padding: 10px 12px;
401
+ border: 1px solid var(--border-primary);
402
+ border-radius: 8px;
403
+ background: var(--bg-primary);
404
+ color: var(--text-primary);
405
+ font-size: 13px;
406
+ }
407
+
408
+ .ccai-expert-structured textarea {
409
+ resize: vertical;
410
+ }
411
+
412
+ .ccai-expert-actions {
413
+ display: flex;
414
+ justify-content: flex-end;
415
+ }
416
+
417
+ .ccai-expert-prompt {
418
+ display: flex;
419
+ flex-direction: column;
420
+ gap: 4px;
421
+ }
422
+
423
+ .ccai-expert-prompt label {
424
+ font-size: 11px;
425
+ font-weight: 600;
426
+ color: var(--text-tertiary);
427
+ text-transform: uppercase;
428
+ letter-spacing: 0.04em;
429
+ }
430
+
431
+ .ccai-expert-prompt textarea {
432
+ width: 100%;
433
+ padding: 10px 12px;
434
+ border: 1px solid var(--border-primary);
435
+ border-radius: 8px;
436
+ background: var(--bg-secondary);
437
+ color: var(--text-primary);
438
+ font-size: 13px;
439
+ line-height: 1.5;
440
+ }
441
+
442
+ .ccai-expert-error {
443
+ background: var(--error-bg);
444
+ border: 1px solid var(--error-border);
445
+ color: var(--error-text);
446
+ padding: 8px 12px;
447
+ border-radius: 8px;
448
+ font-size: 12px;
449
+ }
450
+
451
+ .ccai-expert-footer {
452
+ display: flex;
453
+ align-items: center;
454
+ gap: 8px;
455
+ border-top: 1px solid var(--border-primary);
456
+ padding-top: 12px;
457
+ }
458
+
459
+
460
+ /* ── Table view ────────────────────────────────────────────────── */
461
+
462
+ .ccai-table-overlay {
463
+ position: fixed;
464
+ inset: 0;
465
+ background: rgba(0, 0, 0, 0.5);
466
+ display: flex;
467
+ align-items: center;
468
+ justify-content: center;
469
+ z-index: 1000;
470
+ }
471
+
472
+ .ccai-table-card {
473
+ background: var(--bg-primary);
474
+ border: 1px solid var(--border-primary);
475
+ border-radius: 12px;
476
+ width: min(1100px, 95vw);
477
+ max-height: 90vh;
478
+ display: flex;
479
+ flex-direction: column;
480
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
481
+ }
482
+
483
+ .ccai-table-header {
484
+ display: flex;
485
+ align-items: center;
486
+ gap: 8px;
487
+ padding: 16px 20px;
488
+ border-bottom: 1px solid var(--border-primary);
489
+ }
490
+
491
+ .ccai-table-header h2 {
492
+ margin: 0;
493
+ font-size: 16px;
494
+ font-weight: 600;
495
+ color: var(--text-primary);
496
+ }
497
+
498
+ .ccai-table-body {
499
+ padding: 16px 20px;
500
+ display: flex;
501
+ flex-direction: column;
502
+ gap: 12px;
503
+ overflow: hidden;
504
+ }
505
+
506
+ .ccai-table-question,
507
+ .ccai-table-final {
508
+ background: var(--bg-secondary);
509
+ border-radius: 8px;
510
+ padding: 10px 12px;
511
+ font-size: 13px;
512
+ color: var(--text-secondary);
513
+ }
514
+
515
+ .ccai-table-question strong,
516
+ .ccai-table-final strong {
517
+ display: block;
518
+ font-size: 11px;
519
+ text-transform: uppercase;
520
+ color: var(--text-tertiary);
521
+ letter-spacing: 0.04em;
522
+ margin-bottom: 4px;
523
+ }
524
+
525
+ .ccai-table-scroll {
526
+ overflow: auto;
527
+ border: 1px solid var(--border-primary);
528
+ border-radius: 8px;
529
+ }
530
+
531
+ .ccai-table {
532
+ width: 100%;
533
+ border-collapse: collapse;
534
+ font-size: 12px;
535
+ color: var(--text-primary);
536
+ table-layout: fixed;
537
+ }
538
+
539
+ .ccai-table th,
540
+ .ccai-table td {
541
+ border: 1px solid var(--border-primary);
542
+ padding: 8px 10px;
543
+ vertical-align: top;
544
+ text-align: left;
545
+ }
546
+
547
+ .ccai-table th {
548
+ background: var(--bg-secondary);
549
+ font-weight: 600;
550
+ text-transform: uppercase;
551
+ font-size: 10px;
552
+ letter-spacing: 0.04em;
553
+ color: var(--text-tertiary);
554
+ position: sticky;
555
+ top: 0;
556
+ }
557
+
558
+ .ccai-table th:first-child,
559
+ .ccai-table td:first-child {
560
+ width: 15%;
561
+ }
562
+
563
+ .ccai-table th:nth-child(n + 2),
564
+ .ccai-table td:nth-child(n + 2) {
565
+ width: 21.25%;
566
+ overflow-wrap: break-word;
567
+ word-wrap: break-word;
568
+ }
569
+
570
+ .ccai-table-name {
571
+ white-space: nowrap;
572
+ font-weight: 600;
573
+ }
574
+
575
+ .ccai-table-name small {
576
+ display: block;
577
+ font-weight: 400;
578
+ font-size: 10px;
579
+ color: var(--text-muted);
580
+ }
581
+
582
+
583
+ /* ── Credential Summary modal ──────────────────────────────────────
584
+ Surfaces the orchestrator's per-participant assessment built after
585
+ Phase 1 (concurrent per participant). Reuses the table
586
+ modal's overlay/card chrome for visual consistency.
587
+ ──────────────────────────────────────────────────────────────── */
588
+
589
+ .ccai-credentials-overlay {
590
+ position: fixed;
591
+ inset: 0;
592
+ background: rgba(0, 0, 0, 0.5);
593
+ display: flex;
594
+ align-items: center;
595
+ justify-content: center;
596
+ z-index: 1000;
597
+ }
598
+
599
+ .ccai-credentials-card {
600
+ background: var(--bg-primary);
601
+ border: 1px solid var(--border-primary);
602
+ border-radius: 12px;
603
+ width: min(880px, 95vw);
604
+ max-height: 90vh;
605
+ display: flex;
606
+ flex-direction: column;
607
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
608
+ }
609
+
610
+ .ccai-credentials-header {
611
+ display: flex;
612
+ align-items: flex-start;
613
+ gap: 12px;
614
+ padding: 16px 20px;
615
+ border-bottom: 1px solid var(--border-primary);
616
+ }
617
+
618
+ .ccai-credentials-header h2 {
619
+ margin: 0;
620
+ font-size: 16px;
621
+ font-weight: 600;
622
+ color: var(--text-primary);
623
+ }
624
+
625
+ .ccai-credentials-subtitle {
626
+ margin-top: 4px;
627
+ font-size: 12px;
628
+ color: var(--text-tertiary);
629
+ line-height: 1.4;
630
+ max-width: 560px;
631
+ }
632
+
633
+ .ccai-rate-limit-card {
634
+ width: min(480px, 92vw);
635
+ }
636
+
637
+ .ccai-rate-limit-body {
638
+ padding: 16px 20px;
639
+ font-size: 14px;
640
+ line-height: 1.5;
641
+ color: var(--text-secondary);
642
+ }
643
+
644
+ .ccai-rate-limit-body a {
645
+ color: var(--accent-primary);
646
+ text-decoration: none;
647
+ }
648
+
649
+ .ccai-rate-limit-body a:hover {
650
+ text-decoration: underline;
651
+ }
652
+
653
+ .ccai-rate-limit-actions {
654
+ display: flex;
655
+ justify-content: flex-end;
656
+ padding: 12px 20px 16px;
657
+ border-top: 1px solid var(--border-primary);
658
+ }
659
+
660
+ .ccai-credentials-question {
661
+ margin: 0 20px;
662
+ padding: 10px 12px;
663
+ background: var(--bg-secondary);
664
+ border-radius: 8px;
665
+ font-size: 13px;
666
+ color: var(--text-secondary);
667
+ }
668
+
669
+ .ccai-credentials-question strong {
670
+ display: block;
671
+ font-size: 11px;
672
+ text-transform: uppercase;
673
+ color: var(--text-tertiary);
674
+ letter-spacing: 0.04em;
675
+ margin-bottom: 4px;
676
+ }
677
+
678
+ .ccai-credentials-body {
679
+ padding: 16px 20px;
680
+ display: flex;
681
+ flex-direction: column;
682
+ gap: 12px;
683
+ overflow-y: auto;
684
+ }
685
+
686
+ .ccai-credentials-empty {
687
+ padding: 24px;
688
+ text-align: center;
689
+ font-size: 13px;
690
+ color: var(--text-tertiary);
691
+ border: 1px dashed var(--border-primary);
692
+ border-radius: 8px;
693
+ }
694
+
695
+ .ccai-credential-card {
696
+ padding: 12px 14px;
697
+ border: 1px solid var(--border-primary);
698
+ border-radius: 10px;
699
+ background: var(--bg-secondary);
700
+ }
701
+
702
+ .ccai-credential-card-head {
703
+ display: flex;
704
+ align-items: center;
705
+ gap: 12px;
706
+ margin-bottom: 8px;
707
+ flex-wrap: wrap;
708
+ }
709
+
710
+ .ccai-credential-name {
711
+ font-size: 14px;
712
+ font-weight: 600;
713
+ color: var(--text-primary);
714
+ }
715
+
716
+ .ccai-credibility-wrap {
717
+ display: inline-flex;
718
+ align-items: center;
719
+ gap: 6px;
720
+ margin-left: auto;
721
+ }
722
+
723
+ .ccai-credibility-label {
724
+ font-size: 10px;
725
+ text-transform: uppercase;
726
+ letter-spacing: 0.04em;
727
+ color: var(--text-tertiary);
728
+ }
729
+
730
+ .ccai-credibility-bar {
731
+ width: 120px;
732
+ height: 6px;
733
+ background: var(--border-primary);
734
+ border-radius: 3px;
735
+ overflow: hidden;
736
+ }
737
+
738
+ .ccai-credibility-fill {
739
+ height: 100%;
740
+ background: linear-gradient(
741
+ 90deg,
742
+ var(--accent-primary, #6366F1),
743
+ var(--accent-primary, #6366F1)
744
+ );
745
+ transition: width 0.3s ease;
746
+ }
747
+
748
+ .ccai-credibility-num {
749
+ font-size: 12px;
750
+ font-weight: 600;
751
+ font-variant-numeric: tabular-nums;
752
+ color: var(--text-secondary);
753
+ min-width: 32px;
754
+ text-align: right;
755
+ }
756
+
757
+ .ccai-credential-row {
758
+ display: grid;
759
+ grid-template-columns: 110px 1fr;
760
+ gap: 8px;
761
+ padding: 4px 0;
762
+ font-size: 13px;
763
+ line-height: 1.45;
764
+ }
765
+
766
+ .ccai-credential-row-label {
767
+ font-size: 10px;
768
+ text-transform: uppercase;
769
+ letter-spacing: 0.04em;
770
+ color: var(--text-tertiary);
771
+ font-weight: 600;
772
+ padding-top: 2px;
773
+ }
774
+
775
+ .ccai-credential-row-value {
776
+ color: var(--text-primary);
777
+ }
778
+
779
+ @media (max-width: 600px) {
780
+ .ccai-credentials-card {
781
+ max-height: 100vh;
782
+ border-radius: 0;
783
+ width: 100vw;
784
+ }
785
+ .ccai-credential-row {
786
+ grid-template-columns: 1fr;
787
+ gap: 2px;
788
+ }
789
+ .ccai-credibility-bar {
790
+ width: 80px;
791
+ }
792
+ }
793
+
794
+
795
+ /* ── Orchestrator messages ─────────────────────────────────────── */
796
+
797
+ .ccai-orchestrator-msg {
798
+ text-align: center;
799
+ font-style: italic;
800
+ color: var(--text-secondary);
801
+ background: var(--bg-secondary);
802
+ border: 1px dashed var(--border-primary);
803
+ border-radius: 12px;
804
+ padding: 10px 16px;
805
+ margin: 6px auto;
806
+ max-width: 720px;
807
+ font-size: 13px;
808
+ }
809
+
810
+ .ccai-orchestrator-msg-label {
811
+ font-size: 10px;
812
+ text-transform: uppercase;
813
+ letter-spacing: 0.06em;
814
+ font-weight: 700;
815
+ color: var(--text-tertiary);
816
+ margin-bottom: 4px;
817
+ font-style: normal;
818
+ }
819
+
820
+ .ccai-orchestrator-msg-body p:last-child { margin-bottom: 0; }
821
+
822
+ .ccai-orchestrator-msg-report {
823
+ background: var(--accent-light);
824
+ border-style: solid;
825
+ border-color: var(--accent-primary);
826
+ font-style: normal;
827
+ text-align: left;
828
+ color: var(--text-primary);
829
+ font-size: 14px;
830
+ }
831
+
832
+ .ccai-orchestrator-msg-report .ccai-orchestrator-msg-label {
833
+ color: var(--accent-primary);
834
+ }
835
+
836
+
837
+ /* ── Failsafe pause banner ─────────────────────────────────────── */
838
+
839
+ .ccai-failsafe-banner {
840
+ display: flex;
841
+ align-items: center;
842
+ gap: 12px;
843
+ background: #FEF3C7;
844
+ border: 1px solid #FCD34D;
845
+ border-radius: 12px;
846
+ padding: 12px 14px;
847
+ color: #92400E;
848
+ margin: 8px 0;
849
+ }
850
+
851
+ :root[data-theme="dark"] .ccai-failsafe-banner {
852
+ background: rgba(217, 119, 6, 0.15);
853
+ border-color: rgba(252, 211, 77, 0.5);
854
+ color: #FBBF24;
855
+ }
856
+
857
+ .ccai-failsafe-banner > div:first-child {
858
+ flex: 1;
859
+ }
860
+
861
+ .ccai-failsafe-title {
862
+ font-weight: 700;
863
+ font-size: 13px;
864
+ margin-bottom: 2px;
865
+ }
866
+
867
+ .ccai-failsafe-text {
868
+ font-size: 12px;
869
+ opacity: 0.85;
870
+ }
871
+
872
+
873
+ /* ── Active-question chip (next to Stop Chat) ─────────────────── */
874
+
875
+ /* The chip caps at roughly two lines tall and scrolls vertically for
876
+ longer prompts. The full prompt is also exposed via the title
877
+ attribute for hover tooltips. */
878
+ .ccai-active-question {
879
+ flex: 1;
880
+ min-width: 0;
881
+ max-height: 60px;
882
+ padding: 8px 12px;
883
+ border: 1px solid var(--border-primary);
884
+ border-radius: 8px;
885
+ background: var(--bg-secondary);
886
+ color: var(--text-secondary);
887
+ font-size: 13px;
888
+ line-height: 1.4;
889
+ overflow-y: auto;
890
+ overflow-x: hidden;
891
+ overscroll-behavior: contain;
892
+ }
893
+
894
+ .ccai-active-question-label {
895
+ font-size: 10px;
896
+ font-weight: 700;
897
+ text-transform: uppercase;
898
+ letter-spacing: 0.05em;
899
+ color: var(--text-tertiary);
900
+ margin-right: 6px;
901
+ }
902
+
903
+ .ccai-active-question-text {
904
+ color: var(--text-secondary);
905
+ font-style: italic;
906
+ }
907
+
908
+
909
+ /* ── Conversation threading & addressing cues ─────────────────────
910
+ Lightweight visual aids that help a human reader follow who is
911
+ speaking to whom. Backed by the message's `addressed_to` field
912
+ (one participant id) and `replying_to` field (list of asker ids
913
+ the speaker had open threads with at turn start).
914
+ ────────────────────────────────────────────────────────────────── */
915
+
916
+ /* Indent + thread line when a participant message is a direct reply
917
+ to the immediately previous participant message. We don't fully
918
+ nest replies because N-participant chats turn into trees fast;
919
+ this is a single-level visual hint, not full threading. */
920
+ .ccai-message-row.ccai-message-row-reply {
921
+ margin-left: 28px;
922
+ padding-left: 12px;
923
+ position: relative;
924
+ }
925
+
926
+ .ccai-message-row.ccai-message-row-reply::before {
927
+ content: '';
928
+ position: absolute;
929
+ left: 0;
930
+ top: 6px;
931
+ bottom: 6px;
932
+ width: 2px;
933
+ border-radius: 1px;
934
+ background: var(--border-primary);
935
+ opacity: 0.6;
936
+ }
937
+
938
+ /* "Replying to: X, Y" pill above the bubble, colored with the
939
+ speaker's tone. Mirrors the pending-thread list the orchestrator
940
+ showed the speaker at turn-start. Stays small and unobtrusive. */
941
+ .ccai-replying-to-pill {
942
+ display: inline-block;
943
+ font-size: 11px;
944
+ font-weight: 600;
945
+ letter-spacing: 0.01em;
946
+ padding: 2px 8px;
947
+ border-radius: 999px;
948
+ border: 1px solid;
949
+ background: var(--card-bg);
950
+ margin-bottom: 6px;
951
+ white-space: nowrap;
952
+ max-width: 100%;
953
+ overflow: hidden;
954
+ text-overflow: ellipsis;
955
+ }
956
+
957
+ /* "→ Addressee" chip in the speaker line. The addressee name is a
958
+ clickable button that scrolls the chat to that participant's most
959
+ recent message before this one. */
960
+ .ccai-addressee-wrap {
961
+ display: inline-flex;
962
+ align-items: center;
963
+ gap: 4px;
964
+ margin-left: 8px;
965
+ font-weight: 500;
966
+ opacity: 0.9;
967
+ }
968
+
969
+ .ccai-addressee-arrow {
970
+ display: inline-block;
971
+ vertical-align: middle;
972
+ opacity: 0.7;
973
+ }
974
+
975
+ .ccai-addressee-link {
976
+ background: none;
977
+ border: none;
978
+ cursor: pointer;
979
+ padding: 0;
980
+ font: inherit;
981
+ font-weight: 600;
982
+ text-decoration: underline;
983
+ text-decoration-style: dotted;
984
+ text-decoration-color: currentColor;
985
+ text-underline-offset: 2px;
986
+ transition: text-decoration-style 0.1s ease, opacity 0.1s ease;
987
+ }
988
+
989
+ .ccai-addressee-link:hover {
990
+ text-decoration-style: solid;
991
+ opacity: 1;
992
+ }
993
+
994
+ .ccai-addressee-link:focus-visible {
995
+ outline: 2px solid currentColor;
996
+ outline-offset: 2px;
997
+ border-radius: 2px;
998
+ }
999
+
1000
+ /* Flash highlight applied to the scroll-target when the user clicks an
1001
+ addressee chip. Makes the destination obvious for a beat. */
1002
+ .ccai-flash-highlight .message-bubble {
1003
+ animation: ccai-flash 1.4s ease-out;
1004
+ }
1005
+
1006
+ @keyframes ccai-flash {
1007
+ 0% {
1008
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.55);
1009
+ }
1010
+ 60% {
1011
+ box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.25);
1012
+ }
1013
+ 100% {
1014
+ box-shadow: 0 0 0 0 rgba(99, 102, 241, 0);
1015
+ }
1016
+ }
1017
+
1018
+
1019
+ /* ── Stepper ───────────────────────────────────────────────────── */
1020
+
1021
+ .ccai-stepper-row {
1022
+ display: flex;
1023
+ align-items: center;
1024
+ gap: 8px;
1025
+ /* 22px left padding matches .dev-panel-row indentation so the
1026
+ stepper hangs under the "Max participants" category label
1027
+ consistently with every other category's items. */
1028
+ padding: 4px 10px 8px 22px;
1029
+ }
1030
+
1031
+ .ccai-stepper-btn {
1032
+ width: 24px;
1033
+ height: 24px;
1034
+ padding: 0;
1035
+ display: inline-flex;
1036
+ align-items: center;
1037
+ justify-content: center;
1038
+ }
1039
+
1040
+ .ccai-stepper-val {
1041
+ font-weight: 600;
1042
+ min-width: 20px;
1043
+ text-align: center;
1044
+ }
1045
+
1046
+
1047
+ /* ── Responsive tweaks ─────────────────────────────────────────── */
1048
+
1049
+ @media (max-width: 900px) {
1050
+ .ccai-expert-row {
1051
+ grid-template-columns: 1fr;
1052
+ }
1053
+ .ccai-dropdown-panel {
1054
+ width: min(320px, calc(100vw - 24px));
1055
+ }
1056
+ }
1057
+
1058
+ @media (max-width: 600px) {
1059
+ .ccai-table-card {
1060
+ max-height: 100vh;
1061
+ border-radius: 0;
1062
+ width: 100vw;
1063
+ }
1064
+ }
1065
+
1066
+
1067
+ /* ── Conversation limits modal ────────────────────────────────────
1068
+ Re-uses .ccai-credentials-overlay/.ccai-credentials-card/.ccai-
1069
+ credentials-header/.ccai-credentials-body for the shell so the
1070
+ look matches the credential summary modal. The classes below
1071
+ style the per-section list and per-field stepper row. */
1072
+
1073
+ .ccai-limits-group {
1074
+ display: flex;
1075
+ flex-direction: column;
1076
+ gap: 10px;
1077
+ padding: 12px 14px;
1078
+ background: var(--bg-secondary);
1079
+ border: 1px solid var(--border-primary);
1080
+ border-radius: 10px;
1081
+ }
1082
+
1083
+ .ccai-limits-group + .ccai-limits-group {
1084
+ margin-top: 4px;
1085
+ }
1086
+
1087
+ .ccai-limits-group-title {
1088
+ font-size: 13px;
1089
+ font-weight: 600;
1090
+ color: var(--text-primary);
1091
+ text-transform: uppercase;
1092
+ letter-spacing: 0.04em;
1093
+ border-bottom: 1px solid var(--border-primary);
1094
+ padding-bottom: 6px;
1095
+ margin-bottom: 2px;
1096
+ }
1097
+
1098
+ .ccai-limit-row {
1099
+ display: flex;
1100
+ flex-direction: column;
1101
+ gap: 4px;
1102
+ padding: 6px 0;
1103
+ }
1104
+
1105
+ .ccai-limit-row + .ccai-limit-row {
1106
+ border-top: 1px dashed var(--border-primary);
1107
+ padding-top: 10px;
1108
+ }
1109
+
1110
+ .ccai-limit-row-head {
1111
+ display: flex;
1112
+ align-items: center;
1113
+ justify-content: space-between;
1114
+ gap: 12px;
1115
+ flex-wrap: wrap;
1116
+ }
1117
+
1118
+ .ccai-limit-label {
1119
+ font-size: 14px;
1120
+ font-weight: 600;
1121
+ color: var(--text-primary);
1122
+ }
1123
+
1124
+ .ccai-limit-stepper {
1125
+ display: inline-flex;
1126
+ align-items: center;
1127
+ gap: 8px;
1128
+ }
1129
+
1130
+ .ccai-limit-input {
1131
+ width: 64px;
1132
+ padding: 4px 6px;
1133
+ background: var(--bg-primary);
1134
+ border: 1px solid var(--border-primary);
1135
+ border-radius: 6px;
1136
+ color: var(--text-primary);
1137
+ font-size: 14px;
1138
+ text-align: right;
1139
+ }
1140
+
1141
+ .ccai-limit-range {
1142
+ font-size: 12px;
1143
+ color: var(--text-secondary);
1144
+ }
1145
+
1146
+ .ccai-limit-reset {
1147
+ font-size: 12px;
1148
+ background: transparent;
1149
+ border: 1px solid var(--border-primary);
1150
+ color: var(--text-secondary);
1151
+ padding: 2px 8px;
1152
+ border-radius: 6px;
1153
+ cursor: pointer;
1154
+ }
1155
+
1156
+ .ccai-limit-reset:hover {
1157
+ background: var(--card-bg);
1158
+ color: var(--text-primary);
1159
+ }
1160
+
1161
+ .ccai-limit-help {
1162
+ font-size: 12.5px;
1163
+ color: var(--text-secondary);
1164
+ line-height: 1.45;
1165
+ }
1166
+
1167
+
1168
+ /* ── Auto-select participants (dropdown + sidebar placeholder) ──── */
1169
+
1170
+ .ccai-dropdown-autoselect {
1171
+ display: flex;
1172
+ align-items: center;
1173
+ gap: 8px;
1174
+ width: 100%;
1175
+ padding: 8px 10px;
1176
+ background: var(--bg-secondary);
1177
+ border: 1px solid var(--border-primary);
1178
+ border-radius: 8px;
1179
+ color: var(--text-primary);
1180
+ font-size: 13px;
1181
+ font-weight: 600;
1182
+ cursor: pointer;
1183
+ text-align: left;
1184
+ }
1185
+
1186
+ .ccai-dropdown-autoselect:hover {
1187
+ border-color: var(--accent-primary, var(--text-primary));
1188
+ }
1189
+
1190
+ .ccai-dropdown-autoselect-on {
1191
+ background: var(--accent-primary, var(--bg-primary));
1192
+ color: var(--bg-primary, var(--text-primary));
1193
+ border-color: transparent;
1194
+ }
1195
+
1196
+ .ccai-dropdown-autoselect-help {
1197
+ font-size: 11.5px;
1198
+ color: var(--text-secondary);
1199
+ padding: 6px 4px 0;
1200
+ line-height: 1.4;
1201
+ }
1202
+
1203
+ .ccai-dropdown-section-muted {
1204
+ opacity: 0.55;
1205
+ pointer-events: none;
1206
+ }
1207
+
1208
+ .ccai-sidebar-autoselect-empty {
1209
+ margin: 12px;
1210
+ padding: 12px;
1211
+ border: 1px dashed var(--border-primary);
1212
+ border-radius: 8px;
1213
+ color: var(--text-secondary);
1214
+ font-size: 12.5px;
1215
+ line-height: 1.45;
1216
+ }
1217
+
1218
+ .ccai-sidebar-autoselect-empty strong {
1219
+ color: var(--text-primary);
1220
+ font-size: 13px;
1221
+ }
1222
+
1223
+
1224
+ /* ── Prompt catalog modal ─────────────────────────────────────────
1225
+ Re-uses .ccai-credentials-overlay/.ccai-credentials-card shell.
1226
+ The classes below style the per-group sections and per-item
1227
+ purpose / variables / template blocks. */
1228
+
1229
+ .ccai-prompt-group {
1230
+ display: flex;
1231
+ flex-direction: column;
1232
+ gap: 10px;
1233
+ padding: 12px 14px;
1234
+ background: var(--bg-secondary);
1235
+ border: 1px solid var(--border-primary);
1236
+ border-radius: 10px;
1237
+ }
1238
+
1239
+ .ccai-prompt-group + .ccai-prompt-group {
1240
+ margin-top: 4px;
1241
+ }
1242
+
1243
+ .ccai-prompt-group-title {
1244
+ font-size: 13px;
1245
+ font-weight: 600;
1246
+ color: var(--text-primary);
1247
+ text-transform: uppercase;
1248
+ letter-spacing: 0.04em;
1249
+ border-bottom: 1px solid var(--border-primary);
1250
+ padding-bottom: 6px;
1251
+ }
1252
+
1253
+ .ccai-prompt-item {
1254
+ display: flex;
1255
+ flex-direction: column;
1256
+ gap: 6px;
1257
+ padding: 8px 0;
1258
+ }
1259
+
1260
+ .ccai-prompt-item + .ccai-prompt-item {
1261
+ border-top: 1px dashed var(--border-primary);
1262
+ padding-top: 12px;
1263
+ }
1264
+
1265
+ .ccai-prompt-item-head {
1266
+ display: flex;
1267
+ align-items: center;
1268
+ justify-content: space-between;
1269
+ gap: 12px;
1270
+ }
1271
+
1272
+ .ccai-prompt-item-title {
1273
+ font-size: 14px;
1274
+ font-weight: 600;
1275
+ color: var(--text-primary);
1276
+ }
1277
+
1278
+ .ccai-prompt-copy-btn {
1279
+ display: inline-flex;
1280
+ align-items: center;
1281
+ gap: 4px;
1282
+ font-size: 12px;
1283
+ padding: 3px 8px;
1284
+ background: transparent;
1285
+ border: 1px solid var(--border-primary);
1286
+ color: var(--text-secondary);
1287
+ border-radius: 6px;
1288
+ cursor: pointer;
1289
+ }
1290
+
1291
+ .ccai-prompt-copy-btn:hover {
1292
+ background: var(--card-bg);
1293
+ color: var(--text-primary);
1294
+ }
1295
+
1296
+ .ccai-prompt-purpose {
1297
+ font-size: 12.5px;
1298
+ color: var(--text-secondary);
1299
+ line-height: 1.45;
1300
+ }
1301
+
1302
+ .ccai-prompt-vars {
1303
+ font-size: 12px;
1304
+ color: var(--text-secondary);
1305
+ }
1306
+
1307
+ .ccai-prompt-vars code {
1308
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1309
+ background: var(--bg-primary);
1310
+ border: 1px solid var(--border-primary);
1311
+ border-radius: 4px;
1312
+ padding: 1px 4px;
1313
+ font-size: 11.5px;
1314
+ }
1315
+
1316
+ .ccai-prompt-template {
1317
+ background: var(--bg-primary);
1318
+ border: 1px solid var(--border-primary);
1319
+ border-radius: 6px;
1320
+ padding: 10px 12px;
1321
+ margin: 0;
1322
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
1323
+ font-size: 12px;
1324
+ line-height: 1.45;
1325
+ color: var(--text-primary);
1326
+ white-space: pre-wrap;
1327
+ overflow-x: auto;
1328
+ max-height: 280px;
1329
+ overflow-y: auto;
1330
+ }
1331
+
1332
+
1333
+ /* ════════════════════════════════════════════════════════════════
1334
+ Human Participant
1335
+ Styles for the in-the-loop human: add/edit modal, sidebar entry,
1336
+ in-chat input slot, lower-screen attention cue, green-accent bubble,
1337
+ and the inline edit affordance on the human's credential row.
1338
+ ════════════════════════════════════════════════════════════════ */
1339
+
1340
+ /* ── Add a Human Participant button (header) ───────────────────── */
1341
+
1342
+ .ccai-human-add-btn {
1343
+ display: inline-flex;
1344
+ align-items: center;
1345
+ gap: 4px;
1346
+ white-space: nowrap;
1347
+ }
1348
+
1349
+ /* Header "Table View" button — links to the same Summary Table modal
1350
+ the Settings menu opens, so users don't have to dig through a menu
1351
+ to view it during/after a chat. */
1352
+ .ccai-table-view-btn {
1353
+ display: inline-flex;
1354
+ align-items: center;
1355
+ gap: 4px;
1356
+ white-space: nowrap;
1357
+ }
1358
+
1359
+ .ccai-human-add-btn-active {
1360
+ border-color: #16A34A;
1361
+ color: #16A34A;
1362
+ }
1363
+
1364
+ [data-theme="dark"] .ccai-human-add-btn-active {
1365
+ border-color: #4ADE80;
1366
+ color: #4ADE80;
1367
+ }
1368
+
1369
+ /* ── Human Participant modal (Add / Edit) ──────────────────────── */
1370
+
1371
+ .ccai-human-modal-card {
1372
+ width: min(640px, 92vw);
1373
+ }
1374
+
1375
+ .ccai-human-modal-body {
1376
+ display: flex;
1377
+ flex-direction: column;
1378
+ gap: 16px;
1379
+ padding: 16px 20px;
1380
+ }
1381
+
1382
+ .ccai-human-field {
1383
+ display: flex;
1384
+ flex-direction: column;
1385
+ gap: 6px;
1386
+ }
1387
+
1388
+ .ccai-human-field-label {
1389
+ font-size: 12px;
1390
+ font-weight: 600;
1391
+ color: var(--text-secondary);
1392
+ text-transform: uppercase;
1393
+ letter-spacing: 0.04em;
1394
+ }
1395
+
1396
+ .ccai-human-input {
1397
+ padding: 8px 10px;
1398
+ border: 1px solid var(--border-primary);
1399
+ border-radius: 6px;
1400
+ background: var(--card-bg);
1401
+ color: var(--text-primary);
1402
+ font-size: 14px;
1403
+ }
1404
+
1405
+ .ccai-human-input:focus {
1406
+ outline: none;
1407
+ border-color: #16A34A;
1408
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
1409
+ }
1410
+
1411
+ .ccai-human-summary-header {
1412
+ display: flex;
1413
+ align-items: center;
1414
+ justify-content: space-between;
1415
+ gap: 8px;
1416
+ }
1417
+
1418
+ .ccai-human-summary {
1419
+ width: 100%;
1420
+ padding: 10px 12px;
1421
+ border: 1px solid var(--border-primary);
1422
+ border-radius: 6px;
1423
+ background: var(--card-bg);
1424
+ color: var(--text-primary);
1425
+ font-size: 13px;
1426
+ font-family: inherit;
1427
+ line-height: 1.5;
1428
+ resize: vertical;
1429
+ min-height: 160px;
1430
+ box-sizing: border-box;
1431
+ }
1432
+
1433
+ .ccai-human-summary:focus {
1434
+ outline: none;
1435
+ border-color: #16A34A;
1436
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
1437
+ }
1438
+
1439
+ .ccai-human-summary-help {
1440
+ font-size: 12px;
1441
+ color: var(--text-tertiary);
1442
+ font-style: italic;
1443
+ }
1444
+
1445
+ .ccai-human-ai-btn {
1446
+ display: inline-flex;
1447
+ align-items: center;
1448
+ gap: 4px;
1449
+ white-space: nowrap;
1450
+ }
1451
+
1452
+ .ccai-human-ai-panel {
1453
+ border: 1px solid #16A34A;
1454
+ background: rgba(22, 163, 74, 0.04);
1455
+ border-radius: 8px;
1456
+ padding: 12px 14px;
1457
+ display: flex;
1458
+ flex-direction: column;
1459
+ gap: 10px;
1460
+ }
1461
+
1462
+ [data-theme="dark"] .ccai-human-ai-panel {
1463
+ background: rgba(74, 222, 128, 0.06);
1464
+ border-color: #4ADE80;
1465
+ }
1466
+
1467
+ .ccai-human-ai-counter {
1468
+ display: flex;
1469
+ align-items: center;
1470
+ justify-content: space-between;
1471
+ font-size: 12px;
1472
+ font-weight: 600;
1473
+ color: #16A34A;
1474
+ text-transform: uppercase;
1475
+ letter-spacing: 0.04em;
1476
+ }
1477
+
1478
+ [data-theme="dark"] .ccai-human-ai-counter {
1479
+ color: #4ADE80;
1480
+ }
1481
+
1482
+ .ccai-human-ai-stop {
1483
+ display: inline-flex;
1484
+ align-items: center;
1485
+ gap: 4px;
1486
+ background: transparent;
1487
+ border: 1px solid var(--border-primary);
1488
+ border-radius: 4px;
1489
+ padding: 2px 6px;
1490
+ font-size: 11px;
1491
+ color: var(--text-secondary);
1492
+ cursor: pointer;
1493
+ }
1494
+
1495
+ .ccai-human-ai-stop:hover {
1496
+ border-color: var(--text-secondary);
1497
+ color: var(--text-primary);
1498
+ }
1499
+
1500
+ .ccai-human-ai-turn {
1501
+ display: flex;
1502
+ flex-direction: column;
1503
+ gap: 2px;
1504
+ font-size: 12.5px;
1505
+ padding-bottom: 6px;
1506
+ border-bottom: 1px dashed var(--border-primary);
1507
+ }
1508
+
1509
+ .ccai-human-ai-q {
1510
+ color: var(--text-secondary);
1511
+ font-style: italic;
1512
+ }
1513
+
1514
+ .ccai-human-ai-a {
1515
+ color: var(--text-primary);
1516
+ }
1517
+
1518
+ .ccai-human-ai-current-q {
1519
+ font-size: 13px;
1520
+ color: var(--text-primary);
1521
+ background: var(--card-bg);
1522
+ border-left: 3px solid #16A34A;
1523
+ padding: 6px 10px;
1524
+ border-radius: 4px;
1525
+ }
1526
+
1527
+ .ccai-human-ai-answer {
1528
+ width: 100%;
1529
+ border: 1px solid var(--border-primary);
1530
+ border-radius: 6px;
1531
+ background: var(--card-bg);
1532
+ color: var(--text-primary);
1533
+ font-family: inherit;
1534
+ font-size: 13px;
1535
+ padding: 8px 10px;
1536
+ box-sizing: border-box;
1537
+ resize: vertical;
1538
+ }
1539
+
1540
+ .ccai-human-ai-answer:focus {
1541
+ outline: none;
1542
+ border-color: #16A34A;
1543
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
1544
+ }
1545
+
1546
+ .ccai-human-ai-actions {
1547
+ display: flex;
1548
+ justify-content: flex-end;
1549
+ }
1550
+
1551
+ .ccai-human-error {
1552
+ font-size: 12.5px;
1553
+ color: #DC2626;
1554
+ background: rgba(220, 38, 38, 0.08);
1555
+ border: 1px solid rgba(220, 38, 38, 0.3);
1556
+ border-radius: 6px;
1557
+ padding: 6px 10px;
1558
+ }
1559
+
1560
+ .ccai-human-modal-footer {
1561
+ display: flex;
1562
+ align-items: center;
1563
+ justify-content: space-between;
1564
+ gap: 8px;
1565
+ padding: 12px 20px 16px 20px;
1566
+ border-top: 1px solid var(--border-primary);
1567
+ }
1568
+
1569
+ .ccai-human-modal-footer-right {
1570
+ display: flex;
1571
+ align-items: center;
1572
+ gap: 8px;
1573
+ }
1574
+
1575
+ .ccai-human-remove {
1576
+ color: #DC2626;
1577
+ border-color: rgba(220, 38, 38, 0.4);
1578
+ }
1579
+
1580
+ .ccai-human-remove:hover {
1581
+ background: rgba(220, 38, 38, 0.06);
1582
+ border-color: #DC2626;
1583
+ }
1584
+
1585
+ /* ── Sidebar: Human participant card ──────────────────────────── */
1586
+
1587
+ .ccai-participant-card-human {
1588
+ border-left: 4px solid #16A34A;
1589
+ }
1590
+
1591
+ [data-theme="dark"] .ccai-participant-card-human {
1592
+ border-left-color: #4ADE80;
1593
+ }
1594
+
1595
+ .ccai-participant-human-tag {
1596
+ display: inline-block;
1597
+ margin-left: 8px;
1598
+ padding: 1px 6px;
1599
+ font-size: 10px;
1600
+ font-weight: 600;
1601
+ text-transform: uppercase;
1602
+ letter-spacing: 0.04em;
1603
+ color: #16A34A;
1604
+ background: rgba(22, 163, 74, 0.1);
1605
+ border-radius: 4px;
1606
+ }
1607
+
1608
+ [data-theme="dark"] .ccai-participant-human-tag {
1609
+ color: #4ADE80;
1610
+ background: rgba(74, 222, 128, 0.12);
1611
+ }
1612
+
1613
+ /* ── In-chat human input slot ─────────────────────────────────── */
1614
+
1615
+ .ccai-human-slot {
1616
+ display: flex;
1617
+ align-items: stretch;
1618
+ margin: 16px 0;
1619
+ border-radius: 10px;
1620
+ background: var(--card-bg);
1621
+ border: 2px solid #16A34A;
1622
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.12);
1623
+ overflow: hidden;
1624
+ animation: ccai-human-slot-pop 0.25s ease-out;
1625
+ }
1626
+
1627
+ @keyframes ccai-human-slot-pop {
1628
+ from { transform: scale(0.985); opacity: 0; }
1629
+ to { transform: scale(1); opacity: 1; }
1630
+ }
1631
+
1632
+ .ccai-human-slot-accent {
1633
+ width: 6px;
1634
+ background: #16A34A;
1635
+ flex-shrink: 0;
1636
+ animation: ccai-human-pulse-accent 1.6s ease-in-out infinite;
1637
+ }
1638
+
1639
+ @keyframes ccai-human-pulse-accent {
1640
+ 0% { background: #16A34A; }
1641
+ 50% { background: #4ADE80; }
1642
+ 100% { background: #16A34A; }
1643
+ }
1644
+
1645
+ .ccai-human-slot-body {
1646
+ flex: 1;
1647
+ padding: 12px 14px;
1648
+ display: flex;
1649
+ flex-direction: column;
1650
+ gap: 8px;
1651
+ }
1652
+
1653
+ .ccai-human-slot-header {
1654
+ display: flex;
1655
+ align-items: center;
1656
+ gap: 8px;
1657
+ font-size: 13px;
1658
+ }
1659
+
1660
+ .ccai-human-slot-name {
1661
+ font-weight: 700;
1662
+ color: #16A34A;
1663
+ }
1664
+
1665
+ [data-theme="dark"] .ccai-human-slot-name {
1666
+ color: #4ADE80;
1667
+ }
1668
+
1669
+ .ccai-human-slot-pulse {
1670
+ width: 8px;
1671
+ height: 8px;
1672
+ border-radius: 50%;
1673
+ background: #16A34A;
1674
+ box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6);
1675
+ animation: ccai-human-pulse-dot 1.4s ease-in-out infinite;
1676
+ }
1677
+
1678
+ @keyframes ccai-human-pulse-dot {
1679
+ 0% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0.6); }
1680
+ 70% { box-shadow: 0 0 0 8px rgba(22, 163, 74, 0); }
1681
+ 100% { box-shadow: 0 0 0 0 rgba(22, 163, 74, 0); }
1682
+ }
1683
+
1684
+ .ccai-human-slot-prompt {
1685
+ color: var(--text-secondary);
1686
+ font-style: italic;
1687
+ }
1688
+
1689
+ .ccai-human-slot-context {
1690
+ font-size: 12.5px;
1691
+ color: var(--text-secondary);
1692
+ background: rgba(22, 163, 74, 0.06);
1693
+ border-left: 3px solid rgba(22, 163, 74, 0.6);
1694
+ padding: 6px 10px;
1695
+ border-radius: 4px;
1696
+ }
1697
+
1698
+ .ccai-human-slot-textarea {
1699
+ width: 100%;
1700
+ border: 1px solid var(--border-primary);
1701
+ border-radius: 6px;
1702
+ background: var(--bg-primary);
1703
+ color: var(--text-primary);
1704
+ font-family: inherit;
1705
+ font-size: 14px;
1706
+ line-height: 1.45;
1707
+ padding: 10px 12px;
1708
+ resize: vertical;
1709
+ min-height: 80px;
1710
+ box-sizing: border-box;
1711
+ }
1712
+
1713
+ .ccai-human-slot-textarea:focus {
1714
+ outline: none;
1715
+ border-color: #16A34A;
1716
+ box-shadow: 0 0 0 2px rgba(22, 163, 74, 0.18);
1717
+ }
1718
+
1719
+ .ccai-human-slot-actions {
1720
+ display: flex;
1721
+ align-items: center;
1722
+ justify-content: space-between;
1723
+ gap: 8px;
1724
+ }
1725
+
1726
+ .ccai-human-slot-hint {
1727
+ font-size: 11.5px;
1728
+ color: var(--text-tertiary);
1729
+ }
1730
+
1731
+ .ccai-human-slot-actions-right {
1732
+ display: flex;
1733
+ gap: 8px;
1734
+ }
1735
+
1736
+ /* ── Bubble accent for the human's posted messages ────────────── */
1737
+
1738
+ .ccai-message-row-human .ccai-bubble {
1739
+ border-left: 4px solid #16A34A !important;
1740
+ padding-left: 12px;
1741
+ }
1742
+
1743
+ [data-theme="dark"] .ccai-message-row-human .ccai-bubble {
1744
+ border-left-color: #4ADE80 !important;
1745
+ }
1746
+
1747
+ /* ── Fixed-position lower-screen "waiting for your input" cue ──── */
1748
+
1749
+ .ccai-human-indicator {
1750
+ position: fixed;
1751
+ bottom: 18px;
1752
+ left: 50%;
1753
+ transform: translateX(-50%);
1754
+ display: inline-flex;
1755
+ align-items: center;
1756
+ gap: 8px;
1757
+ padding: 10px 18px;
1758
+ background: #16A34A;
1759
+ color: #ffffff;
1760
+ border: none;
1761
+ border-radius: 999px;
1762
+ font-size: 13px;
1763
+ font-weight: 600;
1764
+ letter-spacing: 0.01em;
1765
+ box-shadow:
1766
+ 0 8px 22px rgba(22, 163, 74, 0.35),
1767
+ 0 0 0 0 rgba(22, 163, 74, 0.5);
1768
+ cursor: pointer;
1769
+ z-index: 80;
1770
+ animation: ccai-human-indicator-pulse 1.8s ease-in-out infinite;
1771
+ }
1772
+
1773
+ [data-theme="dark"] .ccai-human-indicator {
1774
+ background: #22C55E;
1775
+ }
1776
+
1777
+ @keyframes ccai-human-indicator-pulse {
1778
+ 0% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0.5); }
1779
+ 60% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 14px rgba(22, 163, 74, 0); }
1780
+ 100% { box-shadow: 0 8px 22px rgba(22, 163, 74, 0.35), 0 0 0 0 rgba(22, 163, 74, 0); }
1781
+ }
1782
+
1783
+ .ccai-human-indicator:hover {
1784
+ background: #15803D;
1785
+ }
1786
+
1787
+ [data-theme="dark"] .ccai-human-indicator:hover {
1788
+ background: #16A34A;
1789
+ }
1790
+
1791
+ .ccai-human-indicator-arrow {
1792
+ animation: ccai-human-indicator-arrow-bob 1.4s ease-in-out infinite;
1793
+ }
1794
+
1795
+ @keyframes ccai-human-indicator-arrow-bob {
1796
+ 0%, 100% { transform: translateY(0); }
1797
+ 50% { transform: translateY(3px); }
1798
+ }
1799
+
1800
+ /* ── CredentialSummaryModal: human row + inline edit ──────────── */
1801
+
1802
+ .ccai-credential-card-human {
1803
+ border-left: 4px solid #16A34A;
1804
+ }
1805
+
1806
+ [data-theme="dark"] .ccai-credential-card-human {
1807
+ border-left-color: #4ADE80;
1808
+ }
1809
+
1810
+ .ccai-credential-human-tag {
1811
+ display: inline-block;
1812
+ margin-left: 8px;
1813
+ padding: 1px 6px;
1814
+ font-size: 10px;
1815
+ font-weight: 600;
1816
+ text-transform: uppercase;
1817
+ letter-spacing: 0.04em;
1818
+ color: #16A34A;
1819
+ background: rgba(22, 163, 74, 0.1);
1820
+ border-radius: 4px;
1821
+ vertical-align: middle;
1822
+ }
1823
+
1824
+ [data-theme="dark"] .ccai-credential-human-tag {
1825
+ color: #4ADE80;
1826
+ background: rgba(74, 222, 128, 0.12);
1827
+ }
1828
+
1829
+ .ccai-credential-edit-btn {
1830
+ display: inline-flex;
1831
+ align-items: center;
1832
+ gap: 4px;
1833
+ margin-left: 8px;
1834
+ }
1835
+
1836
+ .ccai-credential-card-editing {
1837
+ background: rgba(22, 163, 74, 0.04);
1838
+ }
1839
+
1840
+ [data-theme="dark"] .ccai-credential-card-editing {
1841
+ background: rgba(74, 222, 128, 0.06);
1842
+ }
1843
+
1844
+ .ccai-credential-edit-name {
1845
+ padding: 4px 8px;
1846
+ border: 1px solid var(--border-primary);
1847
+ border-radius: 4px;
1848
+ background: var(--card-bg);
1849
+ color: var(--text-primary);
1850
+ font-size: 14px;
1851
+ font-weight: 600;
1852
+ margin-left: 0;
1853
+ }
1854
+
1855
+ .ccai-credential-row-edit {
1856
+ display: flex;
1857
+ flex-direction: column;
1858
+ gap: 4px;
1859
+ }
1860
+
1861
+ .ccai-credential-row-input {
1862
+ width: 100%;
1863
+ padding: 6px 8px;
1864
+ border: 1px solid var(--border-primary);
1865
+ border-radius: 4px;
1866
+ background: var(--card-bg);
1867
+ color: var(--text-primary);
1868
+ font-family: inherit;
1869
+ font-size: 13px;
1870
+ resize: vertical;
1871
+ box-sizing: border-box;
1872
+ }
1873
+
1874
+ .ccai-credential-row-input-num {
1875
+ width: 90px;
1876
+ }
1877
+
1878
+ .ccai-credential-edit-actions {
1879
+ display: flex;
1880
+ justify-content: flex-end;
1881
+ gap: 8px;
1882
+ margin-top: 8px;
1883
+ }
1884
+
frontend/src/styles/components.css ADDED
@@ -0,0 +1,1455 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ── Sidebar: LLM Selector ─────────────────────────────────────── */
2
+
3
+ .sidebar-title {
4
+ font-size: 18px;
5
+ font-weight: 700;
6
+ color: var(--text-primary);
7
+ text-align: center;
8
+ padding-bottom: 8px;
9
+ border-bottom: 2px solid var(--accent-primary);
10
+ margin: 0;
11
+ }
12
+
13
+ .sidebar-section {
14
+ display: flex;
15
+ flex-direction: column;
16
+ gap: 6px;
17
+ }
18
+
19
+ .selector-title {
20
+ display: flex;
21
+ align-items: center;
22
+ gap: 6px;
23
+ font-size: 15px;
24
+ font-weight: 700;
25
+ color: var(--text-primary);
26
+ text-decoration: underline;
27
+ text-underline-offset: 3px;
28
+ text-decoration-color: var(--border-primary);
29
+ margin: 0;
30
+ }
31
+
32
+ .selector-title-icon {
33
+ height: 16px;
34
+ width: auto;
35
+ }
36
+
37
+ .provider-group {
38
+ margin-bottom: 0;
39
+ border: 1px solid var(--border-primary);
40
+ border-radius: 8px;
41
+ overflow: hidden;
42
+ }
43
+
44
+ .provider-group.comp-group {
45
+ background: var(--comp-bg);
46
+ }
47
+
48
+ /* ── Neon model cards (matching LLMComparisons) ──────────────────── */
49
+
50
+ .neon-model-list {
51
+ display: flex;
52
+ flex-direction: column;
53
+ gap: 6px;
54
+ }
55
+
56
+ .neon-model-card {
57
+ border: 1px solid var(--neon-border);
58
+ border-radius: 8px;
59
+ overflow: hidden;
60
+ background: var(--neon-bg);
61
+ }
62
+
63
+ .neon-model-header {
64
+ display: flex;
65
+ align-items: center;
66
+ justify-content: space-between;
67
+ width: 100%;
68
+ padding: 10px 12px;
69
+ background: none;
70
+ border: none;
71
+ color: var(--text-primary);
72
+ font-size: 13px;
73
+ text-align: left;
74
+ cursor: pointer;
75
+ transition: background 0.15s;
76
+ }
77
+
78
+ .neon-model-header:hover {
79
+ background: var(--card-hover);
80
+ }
81
+
82
+ .neon-model-info {
83
+ display: flex;
84
+ align-items: baseline;
85
+ gap: 6px;
86
+ }
87
+
88
+ .neon-model-name {
89
+ font-weight: 600;
90
+ text-transform: capitalize;
91
+ }
92
+
93
+ .neon-model-version {
94
+ font-size: 11px;
95
+ color: var(--text-muted);
96
+ }
97
+
98
+ .neon-model-meta {
99
+ display: flex;
100
+ align-items: center;
101
+ gap: 6px;
102
+ color: var(--text-tertiary);
103
+ }
104
+
105
+ .badge.badge-neon {
106
+ display: inline-flex;
107
+ align-items: center;
108
+ padding: 2px 8px;
109
+ border-radius: 9999px;
110
+ font-size: 11px;
111
+ font-weight: 700;
112
+ background: var(--neon-bg);
113
+ color: var(--text-primary);
114
+ border: 1px solid var(--neon-border);
115
+ }
116
+
117
+ .neon-persona-list {
118
+ border-top: 1px solid var(--border-muted);
119
+ padding: 4px;
120
+ display: flex;
121
+ flex-direction: column;
122
+ gap: 2px;
123
+ }
124
+
125
+ .neon-persona-item {
126
+ display: flex;
127
+ align-items: flex-start;
128
+ gap: 8px;
129
+ width: 100%;
130
+ padding: 8px;
131
+ border-radius: 6px;
132
+ border: none;
133
+ background: none;
134
+ color: var(--text-primary);
135
+ cursor: pointer;
136
+ text-align: left;
137
+ transition: background 0.15s;
138
+ }
139
+
140
+ .neon-persona-item:hover {
141
+ background: var(--card-hover);
142
+ }
143
+
144
+ .persona-details {
145
+ flex: 1;
146
+ min-width: 0;
147
+ }
148
+
149
+ .persona-name-row {
150
+ display: flex;
151
+ align-items: center;
152
+ gap: 4px;
153
+ font-size: 13px;
154
+ font-weight: 500;
155
+ color: var(--text-primary);
156
+ text-transform: capitalize;
157
+ }
158
+
159
+ .persona-prompt-preview {
160
+ font-size: 11px;
161
+ color: var(--text-muted);
162
+ line-height: 1.4;
163
+ margin-top: 2px;
164
+ overflow: hidden;
165
+ display: -webkit-box;
166
+ -webkit-line-clamp: 2;
167
+ -webkit-box-orient: vertical;
168
+ }
169
+
170
+ .provider-accordion-header {
171
+ display: flex;
172
+ align-items: center;
173
+ justify-content: space-between;
174
+ width: 100%;
175
+ min-width: 0;
176
+ padding: 10px 12px;
177
+ border: none;
178
+ background: none;
179
+ color: var(--text-primary);
180
+ font-size: 13px;
181
+ font-weight: 600;
182
+ text-transform: uppercase;
183
+ transition: background 0.15s;
184
+ }
185
+
186
+ .provider-accordion-header:hover {
187
+ background: var(--card-hover);
188
+ }
189
+
190
+ .provider-accordion-title {
191
+ flex: 1;
192
+ min-width: 0;
193
+ text-align: left;
194
+ overflow: hidden;
195
+ text-overflow: ellipsis;
196
+ white-space: nowrap;
197
+ }
198
+
199
+ .provider-accordion-meta {
200
+ display: flex;
201
+ align-items: center;
202
+ gap: 6px;
203
+ color: var(--text-tertiary);
204
+ }
205
+
206
+ .provider-model-count {
207
+ font-size: 11px;
208
+ font-weight: 600;
209
+ color: var(--accent-primary);
210
+ background: var(--accent-light);
211
+ padding: 1px 6px;
212
+ border-radius: 10px;
213
+ }
214
+
215
+ .provider-group .model-list {
216
+ padding: 4px;
217
+ border-top: 1px solid var(--border-muted);
218
+ }
219
+
220
+ .model-list {
221
+ display: flex;
222
+ flex-direction: column;
223
+ gap: 4px;
224
+ }
225
+
226
+ .model-btn {
227
+ display: flex;
228
+ align-items: center;
229
+ gap: 8px;
230
+ width: 100%;
231
+ min-width: 0;
232
+ padding: 6px 10px;
233
+ border: 1px solid transparent;
234
+ border-radius: 6px;
235
+ background: none;
236
+ color: var(--text-primary);
237
+ font-size: 13px;
238
+ transition: all 0.15s;
239
+ text-align: left;
240
+ }
241
+
242
+ .model-btn:hover {
243
+ background: var(--card-hover);
244
+ }
245
+
246
+ .model-btn .model-name {
247
+ flex: 1;
248
+ min-width: 0;
249
+ font-weight: 500;
250
+ overflow: hidden;
251
+ text-overflow: ellipsis;
252
+ white-space: nowrap;
253
+ }
254
+
255
+ .model-btn .model-params {
256
+ font-size: 10px;
257
+ color: var(--text-muted);
258
+ white-space: nowrap;
259
+ flex-shrink: 0;
260
+ }
261
+
262
+ /* Selection indicator: circle */
263
+ .select-indicator {
264
+ width: 16px;
265
+ height: 16px;
266
+ border-radius: 50%;
267
+ border: 2px solid var(--text-secondary);
268
+ background: #fff;
269
+ display: flex;
270
+ align-items: center;
271
+ justify-content: center;
272
+ flex-shrink: 0;
273
+ transition: all 0.15s;
274
+ position: relative;
275
+ }
276
+
277
+ .select-indicator.selected-a {
278
+ border-color: var(--speaker-a-color);
279
+ background: var(--speaker-a-color);
280
+ }
281
+
282
+ .select-indicator.selected-b {
283
+ border-color: var(--speaker-b-color);
284
+ background: var(--speaker-b-color);
285
+ }
286
+
287
+ /* Double-select ring for "same LLM both sides" */
288
+ .select-indicator.double-selected {
289
+ box-shadow: 0 0 0 3px var(--card-bg), 0 0 0 5px var(--speaker-a-color);
290
+ border-color: var(--speaker-b-color);
291
+ background: linear-gradient(135deg, var(--speaker-a-color), var(--speaker-b-color));
292
+ }
293
+
294
+ .selection-label {
295
+ font-size: 10px;
296
+ font-weight: 700;
297
+ color: #fff;
298
+ }
299
+
300
+
301
+ /* ── Accordion ─────────────────────────────────────────────────── */
302
+
303
+ .accordion {
304
+ border: 1px solid var(--border-primary);
305
+ border-radius: 12px;
306
+ background: var(--card-bg);
307
+ box-shadow: var(--shadow-sm);
308
+ overflow: hidden;
309
+ margin-bottom: 16px;
310
+ }
311
+
312
+ .accordion-header {
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: space-between;
316
+ padding: 14px 20px;
317
+ background: var(--bg-secondary);
318
+ border: none;
319
+ width: 100%;
320
+ font-size: 15px;
321
+ font-weight: 600;
322
+ color: var(--text-primary);
323
+ transition: background 0.15s;
324
+ }
325
+
326
+ .accordion-header:hover {
327
+ background: var(--bg-tertiary);
328
+ }
329
+
330
+ .accordion-body {
331
+ max-height: 0;
332
+ overflow: hidden;
333
+ transition: max-height 0.35s ease, padding 0.35s ease;
334
+ padding: 0 20px;
335
+ }
336
+
337
+ .accordion-body.open {
338
+ max-height: calc(100vh - 260px);
339
+ overflow-y: auto;
340
+ padding: 16px 20px 20px;
341
+ }
342
+
343
+ .persona-panels {
344
+ display: grid;
345
+ grid-template-columns: 1fr 1fr;
346
+ gap: 20px;
347
+ }
348
+
349
+ .persona-panel {
350
+ display: flex;
351
+ flex-direction: column;
352
+ gap: 12px;
353
+ }
354
+
355
+ .persona-panel-header {
356
+ display: flex;
357
+ align-items: center;
358
+ gap: 8px;
359
+ font-size: 14px;
360
+ font-weight: 600;
361
+ color: var(--text-primary);
362
+ padding-bottom: 8px;
363
+ border-bottom: 2px solid var(--border-primary);
364
+ }
365
+
366
+ .persona-panel:first-child .persona-panel-header {
367
+ border-color: var(--speaker-a-color);
368
+ }
369
+
370
+ .persona-panel:last-child .persona-panel-header {
371
+ border-color: var(--speaker-b-color);
372
+ }
373
+
374
+ .persona-field label {
375
+ display: block;
376
+ font-size: 12px;
377
+ font-weight: 600;
378
+ color: var(--text-tertiary);
379
+ margin-bottom: 4px;
380
+ text-transform: uppercase;
381
+ letter-spacing: 0.04em;
382
+ }
383
+
384
+ .persona-field input,
385
+ .persona-field textarea {
386
+ width: 100%;
387
+ padding: 10px 12px;
388
+ border: 1px solid var(--border-primary);
389
+ border-radius: 8px;
390
+ background: var(--bg-primary);
391
+ color: var(--text-primary);
392
+ font-size: 13px;
393
+ transition: border-color 0.15s;
394
+ resize: vertical;
395
+ }
396
+
397
+ .persona-field input:focus,
398
+ .persona-field textarea:focus {
399
+ outline: none;
400
+ border-color: var(--accent-primary);
401
+ box-shadow: 0 0 0 3px var(--accent-light);
402
+ }
403
+
404
+ .persona-field textarea {
405
+ min-height: 80px;
406
+ }
407
+
408
+ .persona-field textarea.tall {
409
+ min-height: 120px;
410
+ }
411
+
412
+ .persona-field input::placeholder,
413
+ .persona-field textarea::placeholder {
414
+ color: var(--text-muted);
415
+ font-style: italic;
416
+ }
417
+
418
+ .freeform-label-row {
419
+ display: flex;
420
+ align-items: center;
421
+ gap: 10px;
422
+ }
423
+
424
+ .freeform-label-row label {
425
+ flex: 1;
426
+ }
427
+
428
+ .upload-btn {
429
+ flex-shrink: 0;
430
+ }
431
+
432
+ .freeform-textarea {
433
+ min-height: 260px;
434
+ resize: vertical;
435
+ }
436
+
437
+ /* responsive rules consolidated below */
438
+
439
+ /* ── Chat Area ─────────────────────────────────────────────────── */
440
+
441
+ .chat-area {
442
+ flex: 1;
443
+ display: flex;
444
+ flex-direction: column;
445
+ gap: 16px;
446
+ padding: 16px 0;
447
+ overflow-y: auto;
448
+ }
449
+
450
+ .chat-empty {
451
+ flex: 1;
452
+ display: flex;
453
+ align-items: center;
454
+ justify-content: center;
455
+ color: var(--text-muted);
456
+ font-size: 15px;
457
+ font-style: italic;
458
+ }
459
+
460
+ /* ── Message Bubbles ───────────────────────────────────────────── */
461
+
462
+ .message-row {
463
+ display: flex;
464
+ align-items: flex-start;
465
+ gap: 12px;
466
+ margin-bottom: 8px;
467
+ animation: fadeSlideIn 0.3s ease;
468
+ }
469
+
470
+ .message-row.speaker-a {
471
+ flex-direction: row;
472
+ }
473
+
474
+ .message-row.speaker-b {
475
+ flex-direction: row-reverse;
476
+ }
477
+
478
+ @keyframes fadeSlideIn {
479
+ from { opacity: 0; transform: translateY(8px); }
480
+ to { opacity: 1; transform: translateY(0); }
481
+ }
482
+
483
+ /* Geometric shape avatars */
484
+ .avatar {
485
+ width: 40px;
486
+ height: 40px;
487
+ flex-shrink: 0;
488
+ display: flex;
489
+ align-items: center;
490
+ justify-content: center;
491
+ font-weight: 700;
492
+ font-size: 14px;
493
+ color: #fff;
494
+ }
495
+
496
+ .avatar-a {
497
+ background: var(--speaker-a-color);
498
+ border-radius: 50%;
499
+ }
500
+
501
+ .avatar-b {
502
+ background: var(--speaker-b-color);
503
+ clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%);
504
+ }
505
+
506
+ .message-bubble {
507
+ max-width: min(520px, 85%);
508
+ padding: 12px 16px;
509
+ border-radius: 16px;
510
+ font-size: 14px;
511
+ line-height: 1.6;
512
+ box-shadow: var(--shadow-sm);
513
+ overflow-wrap: break-word;
514
+ word-break: break-word;
515
+ }
516
+
517
+ .message-bubble.bubble-a {
518
+ background: var(--speaker-a-bg);
519
+ color: var(--text-primary);
520
+ border: 1px solid color-mix(in srgb, var(--speaker-a-color) 20%, transparent);
521
+ border-bottom-left-radius: 4px;
522
+ }
523
+
524
+ .message-bubble.bubble-b {
525
+ background: var(--speaker-b-bg);
526
+ color: var(--text-primary);
527
+ border: 1px solid color-mix(in srgb, var(--speaker-b-color) 20%, transparent);
528
+ border-bottom-right-radius: 4px;
529
+ }
530
+
531
+ .message-speaker {
532
+ font-size: 11px;
533
+ font-weight: 600;
534
+ margin-bottom: 4px;
535
+ text-transform: uppercase;
536
+ letter-spacing: 0.04em;
537
+ }
538
+
539
+ .speaker-a .message-speaker {
540
+ color: var(--speaker-a-color);
541
+ }
542
+
543
+ .speaker-b .message-speaker {
544
+ color: var(--speaker-b-color);
545
+ }
546
+
547
+ .message-bubble p {
548
+ margin: 0 0 8px;
549
+ }
550
+
551
+ .message-bubble p:last-child {
552
+ margin-bottom: 0;
553
+ }
554
+
555
+ .message-elapsed {
556
+ margin-top: 6px;
557
+ font-size: 11px;
558
+ color: var(--text-muted);
559
+ text-align: right;
560
+ opacity: 0.7;
561
+ }
562
+
563
+ /* System messages */
564
+ .system-message {
565
+ text-align: center;
566
+ padding: 12px;
567
+ color: var(--text-muted);
568
+ font-style: italic;
569
+ font-size: 14px;
570
+ }
571
+
572
+ .system-message.end-of-chat {
573
+ font-weight: 700;
574
+ font-style: normal;
575
+ color: var(--text-secondary);
576
+ font-size: 16px;
577
+ padding: 20px;
578
+ border-top: 1px solid var(--border-primary);
579
+ margin-top: 8px;
580
+ }
581
+
582
+ .chat-stats {
583
+ text-align: center;
584
+ padding: 8px 12px;
585
+ font-size: 13px;
586
+ color: var(--text-muted);
587
+ background: var(--bg-secondary);
588
+ border-radius: 8px;
589
+ margin: 4px auto 8px;
590
+ max-width: 400px;
591
+ }
592
+
593
+ /* Download strip rendered below the "End of Chat" marker. Mirrors the
594
+ items in the header DownloadMenu so the user doesn't have to scroll
595
+ back up to the top of a finished conversation to export it. */
596
+ .chat-end-downloads {
597
+ display: flex;
598
+ flex-wrap: wrap;
599
+ justify-content: center;
600
+ gap: 8px;
601
+ padding: 12px 0 4px;
602
+ }
603
+
604
+ .chat-end-download-btn {
605
+ white-space: nowrap;
606
+ }
607
+
608
+ /* ── Chat Controls ─────────────────────────────────────────────── */
609
+
610
+ .chat-controls {
611
+ display: flex;
612
+ gap: 12px;
613
+ align-items: stretch;
614
+ padding: 12px 0;
615
+ }
616
+
617
+ .chat-controls input {
618
+ flex: 1;
619
+ padding: 10px 14px;
620
+ border: 1px solid var(--border-primary);
621
+ border-radius: 8px;
622
+ background: var(--bg-primary);
623
+ color: var(--text-primary);
624
+ font-size: 14px;
625
+ }
626
+
627
+ .chat-controls input:focus {
628
+ outline: none;
629
+ border-color: var(--accent-primary);
630
+ box-shadow: 0 0 0 3px var(--accent-light);
631
+ }
632
+
633
+ .btn-primary {
634
+ padding: 10px 20px;
635
+ border: none;
636
+ border-radius: 8px;
637
+ background: var(--accent-gradient);
638
+ color: #fff;
639
+ font-size: 14px;
640
+ font-weight: 600;
641
+ white-space: nowrap;
642
+ transition: opacity 0.15s, transform 0.1s;
643
+ }
644
+
645
+ .btn-primary:hover {
646
+ opacity: 0.9;
647
+ }
648
+
649
+ .btn-primary:active {
650
+ transform: scale(0.97);
651
+ }
652
+
653
+ .btn-primary:disabled {
654
+ opacity: 0.5;
655
+ cursor: not-allowed;
656
+ }
657
+
658
+ .btn-secondary {
659
+ padding: 10px 20px;
660
+ border: 1px solid var(--border-primary);
661
+ border-radius: 8px;
662
+ background: var(--bg-primary);
663
+ color: var(--text-secondary);
664
+ font-size: 14px;
665
+ font-weight: 500;
666
+ white-space: nowrap;
667
+ transition: all 0.15s;
668
+ }
669
+
670
+ .btn-secondary:hover {
671
+ background: var(--bg-tertiary);
672
+ border-color: var(--accent-primary);
673
+ color: var(--text-primary);
674
+ }
675
+
676
+ .btn-secondary:disabled {
677
+ opacity: 0.5;
678
+ cursor: not-allowed;
679
+ }
680
+
681
+ .btn-stop {
682
+ padding: 10px 20px;
683
+ border: none;
684
+ border-radius: 8px;
685
+ background: #DC2626;
686
+ color: #fff;
687
+ font-size: 14px;
688
+ font-weight: 600;
689
+ white-space: nowrap;
690
+ transition: opacity 0.15s, transform 0.1s;
691
+ }
692
+
693
+ .btn-stop:hover {
694
+ opacity: 0.9;
695
+ }
696
+
697
+ .btn-stop:active {
698
+ transform: scale(0.97);
699
+ }
700
+
701
+ /* ── Header Dev Menu ───────────────────────────────────────────── */
702
+
703
+ .dev-wrap {
704
+ display: flex;
705
+ align-items: center;
706
+ gap: 10px;
707
+ }
708
+
709
+ .dev-download-btns {
710
+ display: flex;
711
+ gap: 6px;
712
+ }
713
+
714
+ .btn-sm {
715
+ display: inline-flex;
716
+ align-items: center;
717
+ gap: 5px;
718
+ padding: 6px 10px;
719
+ border-radius: 8px;
720
+ font-size: 12px;
721
+ font-weight: 500;
722
+ cursor: pointer;
723
+ }
724
+
725
+ .btn-outline {
726
+ border: 1px solid var(--border-primary);
727
+ background: var(--card-bg);
728
+ color: var(--text-secondary);
729
+ transition: border-color 0.15s;
730
+ }
731
+
732
+ .btn-outline:hover:not(:disabled) {
733
+ border-color: var(--accent-primary);
734
+ color: var(--text-primary);
735
+ }
736
+
737
+ .btn-outline:disabled {
738
+ opacity: 0.4;
739
+ cursor: not-allowed;
740
+ }
741
+
742
+ .btn-ghost {
743
+ border: 1px solid transparent;
744
+ background: transparent;
745
+ color: var(--text-secondary);
746
+ transition: background 0.15s;
747
+ }
748
+
749
+ .btn-ghost:hover {
750
+ background: var(--bg-tertiary);
751
+ color: var(--text-primary);
752
+ }
753
+
754
+
755
+ .dev-dropdown-header {
756
+ position: relative;
757
+ }
758
+
759
+ .dev-panel {
760
+ position: absolute;
761
+ right: 0;
762
+ top: 100%;
763
+ margin-top: 4px;
764
+ min-width: 220px;
765
+ /* Cap the panel at a bit less than the viewport so it never extends
766
+ past the bottom of the window; long content scrolls within. The
767
+ 16px subtraction leaves a comfortable gap for shadow + breathing
768
+ room beneath the header. */
769
+ max-height: calc(100vh - 80px);
770
+ overflow-y: auto;
771
+ overscroll-behavior: contain;
772
+ background: var(--card-bg);
773
+ border: 1px solid var(--border-primary);
774
+ border-radius: 10px;
775
+ padding: 6px;
776
+ z-index: 50;
777
+ box-shadow: var(--shadow-md);
778
+ }
779
+
780
+ .dev-panel button {
781
+ display: block;
782
+ width: 100%;
783
+ text-align: left;
784
+ padding: 8px 10px;
785
+ border: none;
786
+ background: transparent;
787
+ /* Higher-contrast text: was --text-secondary (#4B5563) which read as
788
+ low-contrast against the white panel. --text-primary (#111827) is
789
+ near-black and matches the contrast of the rest of the chat UI. */
790
+ color: var(--text-primary);
791
+ border-radius: 6px;
792
+ cursor: pointer;
793
+ font-size: 13px;
794
+ transition: background 0.1s;
795
+ }
796
+
797
+ .dev-panel button:hover:not(:disabled) {
798
+ background: var(--bg-tertiary);
799
+ color: var(--text-primary);
800
+ }
801
+
802
+ .dev-panel button:disabled {
803
+ /* Slightly less aggressive than 0.4 because the base text is now darker;
804
+ 0.55 keeps disabled items legible-but-distinguishable. */
805
+ opacity: 0.55;
806
+ cursor: not-allowed;
807
+ }
808
+
809
+ .dev-panel-hint {
810
+ font-size: 10px;
811
+ /* Bumped from --text-muted (#9CA3AF) to --text-secondary (#4B5563)
812
+ so inline metadata is actually readable. */
813
+ color: var(--text-secondary);
814
+ margin-left: 6px;
815
+ font-style: italic;
816
+ }
817
+
818
+ .dev-panel-divider {
819
+ height: 1px;
820
+ background: var(--border-muted);
821
+ margin: 4px 0;
822
+ }
823
+
824
+ .dev-panel-label {
825
+ font-size: 10px;
826
+ font-weight: 600;
827
+ text-transform: uppercase;
828
+ letter-spacing: 0.05em;
829
+ /* Bumped from --text-muted to --text-secondary for the same reason. */
830
+ color: var(--text-secondary);
831
+ padding: 4px 10px 2px;
832
+ }
833
+
834
+ /* Clickable section header used for multi-item accordion categories
835
+ inside the settings panel. Matches the .dev-panel-label visual style
836
+ (same uppercase / size / color) so collapsed sections read as plain
837
+ labels at rest; the chevron makes the click affordance discoverable
838
+ and the hover background tells the user it's interactive. */
839
+ .dev-panel-section-header {
840
+ display: flex !important;
841
+ align-items: center;
842
+ justify-content: space-between;
843
+ width: 100%;
844
+ padding: 4px 10px 2px !important;
845
+ border: none;
846
+ background: transparent;
847
+ font-size: 10px !important;
848
+ font-weight: 600 !important;
849
+ text-transform: uppercase;
850
+ letter-spacing: 0.05em;
851
+ color: var(--text-secondary) !important;
852
+ text-align: left;
853
+ cursor: pointer;
854
+ }
855
+
856
+ .dev-panel-section-header:hover {
857
+ background: var(--bg-tertiary);
858
+ color: var(--text-primary) !important;
859
+ }
860
+
861
+ .dev-panel-section-chevron {
862
+ flex-shrink: 0;
863
+ opacity: 0.6;
864
+ }
865
+
866
+ /* Indented row used for every actionable item inside a category, so
867
+ labels (left-aligned at 10px) read as clear group headers and the
868
+ items below them sit at a 22px hang. Matches the look of the
869
+ original .dev-panel-choice rows. */
870
+ .dev-panel-row {
871
+ padding-left: 22px !important;
872
+ display: flex !important;
873
+ align-items: center;
874
+ gap: 8px;
875
+ }
876
+
877
+ /* Two-line variant: name on top, subtitle (model name) on bottom,
878
+ chevron right-aligned. The middle text column is flex:1, min-width:0
879
+ so the subtitle can ellipsis-truncate when it overflows. */
880
+ .dev-panel-row-stack {
881
+ align-items: stretch;
882
+ }
883
+
884
+ .dev-panel-row-text {
885
+ flex: 1;
886
+ min-width: 0;
887
+ display: flex;
888
+ flex-direction: column;
889
+ align-items: flex-start;
890
+ gap: 2px;
891
+ }
892
+
893
+ .dev-panel-row-name {
894
+ font-size: 13px;
895
+ color: var(--text-primary);
896
+ line-height: 1.25;
897
+ }
898
+
899
+ .dev-panel-row-sub {
900
+ font-size: 11px;
901
+ color: var(--text-tertiary);
902
+ font-style: italic;
903
+ white-space: nowrap;
904
+ overflow: hidden;
905
+ text-overflow: ellipsis;
906
+ max-width: 100%;
907
+ line-height: 1.2;
908
+ }
909
+
910
+ .dev-panel-choice {
911
+ padding-left: 22px !important;
912
+ display: flex !important;
913
+ align-items: center;
914
+ gap: 8px;
915
+ }
916
+
917
+ .dev-panel-choice-active {
918
+ color: var(--text-primary) !important;
919
+ opacity: 1 !important;
920
+ }
921
+
922
+ .dev-check-icon {
923
+ flex-shrink: 0;
924
+ }
925
+
926
+ .dev-panel-choice-active .dev-check-icon {
927
+ color: var(--neon-accent, #22c55e);
928
+ }
929
+
930
+ .dev-panel-download-item {
931
+ display: none !important;
932
+ }
933
+
934
+
935
+ /* ── Orchestrator sub-menu ─────────────────────────────────────── */
936
+
937
+ .dev-sub-panel {
938
+ position: absolute;
939
+ right: 100%;
940
+ top: 0;
941
+ margin-right: 4px;
942
+ width: 280px;
943
+ max-height: 70vh;
944
+ display: flex;
945
+ flex-direction: column;
946
+ background: var(--card-bg);
947
+ border: 1px solid var(--border-primary);
948
+ border-radius: 10px;
949
+ padding: 8px;
950
+ z-index: 51;
951
+ box-shadow: var(--shadow-md);
952
+ }
953
+
954
+ .dev-sub-header {
955
+ display: flex;
956
+ flex-direction: column;
957
+ gap: 2px;
958
+ padding: 4px 6px 8px;
959
+ border-bottom: 1px solid var(--border-primary);
960
+ margin-bottom: 6px;
961
+ }
962
+
963
+ .dev-sub-title {
964
+ font-size: 12px;
965
+ font-weight: 600;
966
+ color: var(--text-primary);
967
+ text-transform: uppercase;
968
+ letter-spacing: 0.5px;
969
+ }
970
+
971
+ .dev-sub-current {
972
+ font-size: 11px;
973
+ color: var(--text-muted);
974
+ font-style: italic;
975
+ }
976
+
977
+ .dev-sub-search {
978
+ position: relative;
979
+ margin-bottom: 6px;
980
+ }
981
+
982
+ .dev-sub-search-icon {
983
+ position: absolute;
984
+ left: 8px;
985
+ top: 50%;
986
+ transform: translateY(-50%);
987
+ color: var(--text-muted);
988
+ opacity: 0.5;
989
+ pointer-events: none;
990
+ }
991
+
992
+ .dev-sub-search input {
993
+ width: 100%;
994
+ box-sizing: border-box;
995
+ padding: 7px 8px 7px 30px;
996
+ border: 1px solid var(--border-primary);
997
+ border-radius: 8px;
998
+ background: var(--bg-primary);
999
+ color: var(--text-primary);
1000
+ font-size: 12px;
1001
+ }
1002
+
1003
+ .dev-sub-search input:focus {
1004
+ outline: none;
1005
+ border-color: var(--accent-primary);
1006
+ }
1007
+
1008
+ .dev-sub-search input::placeholder {
1009
+ color: var(--text-muted);
1010
+ }
1011
+
1012
+ .dev-sub-list {
1013
+ list-style: none;
1014
+ margin: 0;
1015
+ padding: 0;
1016
+ overflow-y: auto;
1017
+ flex: 1;
1018
+ }
1019
+
1020
+ .dev-sub-list li {
1021
+ margin-bottom: 2px;
1022
+ }
1023
+
1024
+ .dev-sub-item {
1025
+ width: 100%;
1026
+ text-align: left;
1027
+ padding: 6px 8px;
1028
+ border: 1px solid transparent;
1029
+ border-radius: 6px;
1030
+ background: transparent;
1031
+ color: var(--text-primary);
1032
+ cursor: pointer;
1033
+ display: flex;
1034
+ flex-direction: column;
1035
+ gap: 1px;
1036
+ font-size: 12px;
1037
+ transition: background 0.1s;
1038
+ }
1039
+
1040
+ .dev-sub-item:hover {
1041
+ background: var(--bg-tertiary);
1042
+ }
1043
+
1044
+ .dev-sub-item-active {
1045
+ border-color: var(--accent-primary);
1046
+ background: var(--accent-light);
1047
+ }
1048
+
1049
+ .dev-sub-item-active:hover {
1050
+ background: var(--accent-light);
1051
+ }
1052
+
1053
+ .dev-sub-item strong {
1054
+ font-weight: 500;
1055
+ font-size: 12px;
1056
+ }
1057
+
1058
+ .dev-sub-provider {
1059
+ font-size: 10px;
1060
+ color: var(--text-muted);
1061
+ }
1062
+
1063
+ /* responsive rules consolidated below */
1064
+
1065
+ /* ── Status / Loading ──────────────────────────────────────────── */
1066
+
1067
+ .status-bar {
1068
+ display: flex;
1069
+ align-items: center;
1070
+ gap: 8px;
1071
+ padding: 8px 0;
1072
+ color: var(--text-muted);
1073
+ font-size: 13px;
1074
+ }
1075
+
1076
+ .spinner {
1077
+ width: 16px;
1078
+ height: 16px;
1079
+ border: 2px solid var(--border-primary);
1080
+ border-top-color: var(--accent-primary);
1081
+ border-radius: 50%;
1082
+ animation: spin 0.8s linear infinite;
1083
+ }
1084
+
1085
+ @keyframes spin {
1086
+ to { transform: rotate(360deg); }
1087
+ }
1088
+
1089
+ /* ── Auth Badge ───────────────────────────────────────────────── */
1090
+
1091
+ .auth-badge {
1092
+ display: flex;
1093
+ align-items: center;
1094
+ gap: 6px;
1095
+ font-size: 12px;
1096
+ color: var(--text-muted);
1097
+ padding: 4px 8px;
1098
+ border-radius: 8px;
1099
+ background: var(--bg-secondary);
1100
+ border: 1px solid var(--border-primary);
1101
+ }
1102
+
1103
+ .auth-username {
1104
+ font-weight: 500;
1105
+ color: var(--text-secondary);
1106
+ }
1107
+
1108
+ .auth-org-tag {
1109
+ font-size: 10px;
1110
+ font-weight: 600;
1111
+ text-transform: uppercase;
1112
+ padding: 1px 5px;
1113
+ border-radius: 4px;
1114
+ background: var(--accent-primary);
1115
+ color: #fff;
1116
+ letter-spacing: 0.5px;
1117
+ }
1118
+
1119
+ .auth-remaining {
1120
+ font-size: 11px;
1121
+ color: var(--text-muted);
1122
+ white-space: nowrap;
1123
+ }
1124
+
1125
+ .auth-link {
1126
+ display: inline-flex;
1127
+ align-items: center;
1128
+ gap: 4px;
1129
+ color: var(--text-muted);
1130
+ text-decoration: none;
1131
+ transition: color 0.15s;
1132
+ }
1133
+
1134
+ .auth-link:hover {
1135
+ color: var(--text-primary);
1136
+ }
1137
+
1138
+ .auth-login {
1139
+ color: var(--accent-primary);
1140
+ font-weight: 500;
1141
+ }
1142
+
1143
+ .auth-login:hover {
1144
+ color: var(--text-primary);
1145
+ }
1146
+
1147
+ .auth-badge-end {
1148
+ margin-left: 4px;
1149
+ }
1150
+
1151
+ /* ── Footer ─────────────────────────────────────────────────────── */
1152
+
1153
+ .app-footer {
1154
+ padding: 8px 24px;
1155
+ text-align: center;
1156
+ font-size: 10px;
1157
+ color: var(--text-muted);
1158
+ border-top: 1px solid var(--border-primary);
1159
+ background: var(--bg-secondary);
1160
+ flex-shrink: 0;
1161
+ }
1162
+
1163
+ .app-footer a {
1164
+ color: var(--text-muted);
1165
+ text-decoration: underline;
1166
+ transition: color 0.15s;
1167
+ }
1168
+
1169
+ .app-footer a:hover {
1170
+ color: var(--text-secondary);
1171
+ }
1172
+
1173
+ /* ── Scrollbar ──────────────────────────────────────────────────── */
1174
+
1175
+ ::-webkit-scrollbar {
1176
+ width: 6px;
1177
+ height: 6px;
1178
+ }
1179
+
1180
+ ::-webkit-scrollbar-track {
1181
+ background: transparent;
1182
+ }
1183
+
1184
+ ::-webkit-scrollbar-thumb {
1185
+ background: var(--text-muted);
1186
+ border-radius: 3px;
1187
+ }
1188
+
1189
+ ::-webkit-scrollbar-thumb:hover {
1190
+ background: var(--text-tertiary);
1191
+ }
1192
+
1193
+ /* ══════════════════════════════════════════════════════════════════
1194
+ RESPONSIVE BREAKPOINTS
1195
+ ══════════════════════════════════════════════════════════════════ */
1196
+
1197
+ /* ── Tablet (<900px) ──────────────────────────────────────────── */
1198
+ @media (max-width: 900px) {
1199
+ .persona-panels {
1200
+ grid-template-columns: 1fr;
1201
+ }
1202
+ .persona-field textarea {
1203
+ min-height: 60px;
1204
+ }
1205
+ .persona-field textarea.tall {
1206
+ min-height: 80px;
1207
+ }
1208
+ .freeform-textarea {
1209
+ min-height: 160px;
1210
+ }
1211
+ .accordion-header {
1212
+ padding: 12px 16px;
1213
+ font-size: 14px;
1214
+ }
1215
+ .accordion-body.open {
1216
+ padding: 12px 16px 16px;
1217
+ }
1218
+ .chat-controls {
1219
+ flex-wrap: wrap;
1220
+ gap: 8px;
1221
+ }
1222
+ .chat-controls input {
1223
+ flex-basis: 100%;
1224
+ font-size: 13px;
1225
+ }
1226
+ .message-row {
1227
+ gap: 8px;
1228
+ }
1229
+ .avatar {
1230
+ width: 32px;
1231
+ height: 32px;
1232
+ font-size: 12px;
1233
+ }
1234
+ }
1235
+
1236
+ /* ── Small tablet / large phone (<600px) ──────────────────────── */
1237
+ @media (max-width: 600px) {
1238
+ /* The standalone .dev-download-btns header strip and the
1239
+ .dev-panel-download-item mobile fallback inside the settings menu
1240
+ have been replaced by a dedicated <DownloadMenu> dropdown that
1241
+ works on all viewports, so the responsive fallback logic that used
1242
+ to live here is no longer needed. */
1243
+ .dev-sub-panel {
1244
+ right: 0;
1245
+ top: 100%;
1246
+ margin-right: 0;
1247
+ margin-top: 4px;
1248
+ width: min(280px, calc(100vw - 24px));
1249
+ }
1250
+ .dev-panel {
1251
+ min-width: 200px;
1252
+ }
1253
+ .auth-badge {
1254
+ font-size: 11px;
1255
+ padding: 3px 6px;
1256
+ gap: 4px;
1257
+ }
1258
+ .auth-remaining {
1259
+ display: none;
1260
+ }
1261
+ }
1262
+
1263
+ /* ── Phone (<480px) ───────────────────────────────────────────── */
1264
+ @media (max-width: 480px) {
1265
+ .persona-panels {
1266
+ gap: 12px;
1267
+ }
1268
+ .persona-panel {
1269
+ gap: 8px;
1270
+ }
1271
+ .persona-panel-header {
1272
+ font-size: 13px;
1273
+ padding-bottom: 6px;
1274
+ }
1275
+ .persona-field label {
1276
+ font-size: 11px;
1277
+ }
1278
+ .persona-field input,
1279
+ .persona-field textarea {
1280
+ padding: 8px 10px;
1281
+ font-size: 12px;
1282
+ }
1283
+ .persona-field textarea {
1284
+ min-height: 50px;
1285
+ }
1286
+ .persona-field textarea.tall {
1287
+ min-height: 60px;
1288
+ }
1289
+ .freeform-textarea {
1290
+ min-height: 120px;
1291
+ }
1292
+ .accordion {
1293
+ border-radius: 8px;
1294
+ margin-bottom: 10px;
1295
+ }
1296
+ .accordion-header {
1297
+ padding: 10px 12px;
1298
+ font-size: 13px;
1299
+ }
1300
+ .accordion-body.open {
1301
+ padding: 10px 12px 14px;
1302
+ }
1303
+ .chat-controls {
1304
+ gap: 6px;
1305
+ padding: 8px 0;
1306
+ }
1307
+ .btn-primary,
1308
+ .btn-secondary,
1309
+ .btn-stop {
1310
+ padding: 8px 14px;
1311
+ font-size: 13px;
1312
+ }
1313
+ .message-bubble {
1314
+ padding: 10px 12px;
1315
+ font-size: 13px;
1316
+ border-radius: 12px;
1317
+ }
1318
+ .message-row {
1319
+ gap: 6px;
1320
+ margin-bottom: 6px;
1321
+ }
1322
+ .avatar {
1323
+ width: 28px;
1324
+ height: 28px;
1325
+ font-size: 11px;
1326
+ }
1327
+ .system-message {
1328
+ font-size: 13px;
1329
+ padding: 8px;
1330
+ }
1331
+ .system-message.end-of-chat {
1332
+ font-size: 14px;
1333
+ padding: 14px;
1334
+ }
1335
+ /* On phones the download strip stacks so each label is fully
1336
+ readable; on wider viewports it stays inline-wrap. */
1337
+ .chat-end-downloads {
1338
+ flex-direction: column;
1339
+ align-items: stretch;
1340
+ gap: 6px;
1341
+ }
1342
+ .chat-end-download-btn {
1343
+ justify-content: center;
1344
+ }
1345
+ .btn-sm {
1346
+ padding: 5px 8px;
1347
+ font-size: 11px;
1348
+ }
1349
+ .btn-ghost {
1350
+ font-size: 12px;
1351
+ }
1352
+ .auth-badge {
1353
+ display: none;
1354
+ }
1355
+ .app-footer {
1356
+ padding: 6px 12px;
1357
+ }
1358
+ }
1359
+
1360
+ /* ── Role Prompt Modal ─────────────────────────────────────────── */
1361
+
1362
+ .modal-overlay {
1363
+ position: fixed;
1364
+ inset: 0;
1365
+ background: rgba(0, 0, 0, 0.5);
1366
+ display: flex;
1367
+ align-items: center;
1368
+ justify-content: center;
1369
+ z-index: 1000;
1370
+ animation: fadeIn 0.15s ease;
1371
+ }
1372
+
1373
+ .modal-content {
1374
+ background: var(--bg-primary);
1375
+ border: 1px solid var(--border-primary);
1376
+ border-radius: 12px;
1377
+ width: min(680px, 90vw);
1378
+ max-height: 80vh;
1379
+ display: flex;
1380
+ flex-direction: column;
1381
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
1382
+ }
1383
+
1384
+ .modal-header {
1385
+ display: flex;
1386
+ align-items: center;
1387
+ justify-content: space-between;
1388
+ padding: 16px 20px;
1389
+ border-bottom: 1px solid var(--border-primary);
1390
+ }
1391
+
1392
+ .modal-header h2 {
1393
+ margin: 0;
1394
+ font-size: 16px;
1395
+ font-weight: 600;
1396
+ color: var(--text-primary);
1397
+ }
1398
+
1399
+ .modal-close {
1400
+ background: none;
1401
+ border: none;
1402
+ font-size: 22px;
1403
+ cursor: pointer;
1404
+ color: var(--text-muted);
1405
+ padding: 0 4px;
1406
+ line-height: 1;
1407
+ border-radius: 4px;
1408
+ transition: color 0.15s, background 0.15s;
1409
+ }
1410
+
1411
+ .modal-close:hover {
1412
+ color: var(--text-primary);
1413
+ background: var(--bg-secondary);
1414
+ }
1415
+
1416
+ .modal-body {
1417
+ padding: 20px;
1418
+ overflow-y: auto;
1419
+ display: flex;
1420
+ flex-direction: column;
1421
+ gap: 20px;
1422
+ }
1423
+
1424
+ .role-prompt-section h3 {
1425
+ margin: 0 0 8px;
1426
+ font-size: 14px;
1427
+ font-weight: 600;
1428
+ color: var(--text-primary);
1429
+ }
1430
+
1431
+ .role-prompt-model {
1432
+ font-weight: 400;
1433
+ color: var(--text-muted);
1434
+ font-size: 12px;
1435
+ }
1436
+
1437
+ .role-prompt-text {
1438
+ background: var(--bg-secondary);
1439
+ border: 1px solid var(--border-primary);
1440
+ border-radius: 8px;
1441
+ padding: 12px 14px;
1442
+ font-size: 13px;
1443
+ line-height: 1.6;
1444
+ color: var(--text-secondary);
1445
+ white-space: pre-wrap;
1446
+ word-break: break-word;
1447
+ margin: 0;
1448
+ max-height: 260px;
1449
+ overflow-y: auto;
1450
+ }
1451
+
1452
+ @keyframes fadeIn {
1453
+ from { opacity: 0; }
1454
+ to { opacity: 1; }
1455
+ }
frontend/src/styles/decidron.css ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .decidron-app {
2
+ width: 100%;
3
+ max-width: 1280px;
4
+ margin: 0 auto;
5
+ padding: 1rem 1.25rem 3rem;
6
+ /* The app shell locks .app/.app-main to 100vh with overflow:hidden, so
7
+ this container must fill the main area and scroll internally - keeps
8
+ the whole simulator (Results, Commands, Stats) reachable at any zoom. */
9
+ height: 100%;
10
+ overflow-y: auto;
11
+ }
12
+
13
+ .decidron-grid {
14
+ display: grid;
15
+ grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
16
+ gap: 1rem;
17
+ }
18
+
19
+ @media (max-width: 980px) {
20
+ .decidron-grid { grid-template-columns: 1fr; }
21
+ }
22
+
23
+ .decidron-panel {
24
+ background: var(--card-bg);
25
+ border: 1px solid var(--border-primary);
26
+ border-radius: 12px;
27
+ box-shadow: var(--shadow-sm);
28
+ padding: 1rem;
29
+ margin-bottom: 1rem;
30
+ }
31
+
32
+ .decidron-panel h2 {
33
+ font-size: 0.95rem;
34
+ font-weight: 700;
35
+ color: var(--text-primary);
36
+ margin-bottom: 0.25rem;
37
+ }
38
+
39
+ .decidron-panel .panel-hint {
40
+ font-size: 0.78rem;
41
+ color: var(--text-tertiary);
42
+ margin-bottom: 0.75rem;
43
+ }
44
+
45
+ .decidron-diagram {
46
+ background: var(--bg-secondary);
47
+ border: 1px solid var(--border-primary);
48
+ border-radius: 10px;
49
+ }
50
+
51
+ .decidron-field {
52
+ display: flex;
53
+ flex-direction: column;
54
+ gap: 0.25rem;
55
+ margin-bottom: 0.6rem;
56
+ }
57
+
58
+ .decidron-field label {
59
+ font-size: 0.75rem;
60
+ font-weight: 600;
61
+ color: var(--text-secondary);
62
+ }
63
+
64
+ .decidron-field input,
65
+ .decidron-field select {
66
+ padding: 0.45rem 0.55rem;
67
+ border: 1px solid var(--border-primary);
68
+ border-radius: 8px;
69
+ background: var(--bg-primary);
70
+ color: var(--text-primary);
71
+ font-size: 0.85rem;
72
+ }
73
+
74
+ .decidron-row {
75
+ display: flex;
76
+ gap: 0.5rem;
77
+ flex-wrap: wrap;
78
+ align-items: flex-end;
79
+ }
80
+
81
+ .decidron-btn {
82
+ padding: 0.5rem 0.9rem;
83
+ border: none;
84
+ border-radius: 8px;
85
+ background: var(--accent-gradient);
86
+ color: #fff;
87
+ font-size: 0.85rem;
88
+ font-weight: 600;
89
+ }
90
+
91
+ .decidron-btn:disabled { opacity: 0.5; cursor: not-allowed; }
92
+
93
+ .decidron-btn.secondary {
94
+ background: transparent;
95
+ color: var(--text-secondary);
96
+ border: 1px solid var(--border-primary);
97
+ }
98
+
99
+ .decidron-table {
100
+ width: 100%;
101
+ border-collapse: collapse;
102
+ font-size: 0.8rem;
103
+ }
104
+
105
+ .decidron-table th,
106
+ .decidron-table td {
107
+ text-align: left;
108
+ padding: 0.4rem 0.5rem;
109
+ border-bottom: 1px solid var(--border-muted);
110
+ color: var(--text-secondary);
111
+ }
112
+
113
+ .decidron-table th { color: var(--text-tertiary); font-weight: 600; }
114
+
115
+ .decidron-chip {
116
+ display: inline-block;
117
+ padding: 0.1rem 0.45rem;
118
+ border-radius: 999px;
119
+ font-size: 0.7rem;
120
+ font-weight: 600;
121
+ background: var(--accent-light);
122
+ color: var(--accent-primary);
123
+ }
124
+
125
+ .decidron-chip.match { background: var(--speaker-b-bg); color: var(--speaker-b-color); }
126
+ .decidron-chip.nomatch { background: var(--bg-tertiary); color: var(--text-tertiary); }
127
+
128
+ .decidron-pending {
129
+ list-style: none;
130
+ display: flex;
131
+ flex-wrap: wrap;
132
+ gap: 0.35rem;
133
+ margin: 0.4rem 0;
134
+ }
135
+
136
+ .decidron-pending li {
137
+ font-size: 0.75rem;
138
+ padding: 0.15rem 0.5rem;
139
+ border-radius: 6px;
140
+ background: var(--comp-bg);
141
+ border: 1px solid var(--comp-border);
142
+ color: var(--text-secondary);
143
+ }
144
+
145
+ .decidron-history-item {
146
+ padding: 0.5rem 0.6rem;
147
+ border: 1px solid var(--border-primary);
148
+ border-radius: 8px;
149
+ margin-bottom: 0.4rem;
150
+ cursor: pointer;
151
+ font-size: 0.8rem;
152
+ color: var(--text-secondary);
153
+ }
154
+
155
+ .decidron-history-item.active {
156
+ border-color: var(--accent-primary);
157
+ background: var(--accent-light);
158
+ }
159
+
160
+ .decidron-error {
161
+ background: var(--error-bg);
162
+ border: 1px solid var(--error-border);
163
+ color: var(--error-text);
164
+ padding: 0.5rem 0.75rem;
165
+ border-radius: 8px;
166
+ font-size: 0.82rem;
167
+ margin-bottom: 0.75rem;
168
+ }
169
+
170
+ .decidron-cmd {
171
+ border: 1px solid var(--border-primary);
172
+ border-radius: 8px;
173
+ padding: 0.5rem 0.6rem;
174
+ margin-bottom: 0.4rem;
175
+ font-size: 0.8rem;
176
+ color: var(--text-secondary);
177
+ }
178
+
179
+ .decidron-cmd code {
180
+ background: var(--bg-tertiary);
181
+ padding: 0.05rem 0.3rem;
182
+ border-radius: 4px;
183
+ font-size: 0.75rem;
184
+ }
185
+
186
+ /* Header settings menu */
187
+ .decidron-settings-wrap .decidron-settings-trigger {
188
+ width: 36px;
189
+ height: 36px;
190
+ padding: 0;
191
+ display: inline-flex;
192
+ align-items: center;
193
+ justify-content: center;
194
+ }
195
+
196
+ .decidron-settings-panel {
197
+ width: 300px;
198
+ }
199
+
200
+ .decidron-settings-radio {
201
+ display: flex;
202
+ align-items: flex-start;
203
+ gap: 0.5rem;
204
+ padding: 0.45rem 0.5rem;
205
+ border-radius: 8px;
206
+ cursor: pointer;
207
+ font-size: 0.82rem;
208
+ color: var(--text-secondary);
209
+ }
210
+
211
+ .decidron-settings-radio:hover {
212
+ background: var(--bg-tertiary);
213
+ }
214
+
215
+ .decidron-settings-radio input {
216
+ margin-top: 0.2rem;
217
+ flex-shrink: 0;
218
+ }
219
+
220
+ .decidron-settings-radio strong {
221
+ display: block;
222
+ color: var(--text-primary);
223
+ font-weight: 600;
224
+ font-size: 0.82rem;
225
+ }
226
+
227
+ .decidron-settings-hint {
228
+ display: block;
229
+ font-size: 0.72rem;
230
+ color: var(--text-tertiary);
231
+ margin-top: 0.1rem;
232
+ }
233
+
234
+ .decidron-settings-check {
235
+ cursor: pointer;
236
+ }
frontend/src/styles/layout.css ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .app {
2
+ height: 100vh;
3
+ display: flex;
4
+ flex-direction: column;
5
+ overflow: hidden;
6
+ }
7
+
8
+ .app-header {
9
+ display: flex;
10
+ align-items: center;
11
+ justify-content: space-between;
12
+ padding: 12px 24px;
13
+ background: var(--card-bg);
14
+ border-bottom: 1px solid var(--border-primary);
15
+ box-shadow: var(--shadow-sm);
16
+ flex-shrink: 0;
17
+ z-index: 100;
18
+ backdrop-filter: blur(12px);
19
+ }
20
+
21
+ .header-left {
22
+ display: flex;
23
+ align-items: center;
24
+ gap: 10px;
25
+ min-width: 0;
26
+ }
27
+
28
+ .header-brand-link {
29
+ display: flex;
30
+ align-items: center;
31
+ text-decoration: none;
32
+ transition: opacity 0.15s;
33
+ flex-shrink: 0;
34
+ }
35
+
36
+ .header-brand-link:hover {
37
+ opacity: 0.8;
38
+ }
39
+
40
+ .app-logo {
41
+ height: 32px;
42
+ width: auto;
43
+ }
44
+
45
+ .app-title {
46
+ font-size: 20px;
47
+ font-weight: 700;
48
+ background: var(--accent-gradient);
49
+ -webkit-background-clip: text;
50
+ -webkit-text-fill-color: transparent;
51
+ background-clip: text;
52
+ white-space: nowrap;
53
+ overflow: hidden;
54
+ text-overflow: ellipsis;
55
+ min-width: 0;
56
+ margin: 0;
57
+ }
58
+
59
+ .app-title-link {
60
+ all: unset;
61
+ cursor: pointer;
62
+ }
63
+
64
+ .app-title-link:hover {
65
+ opacity: 0.8;
66
+ }
67
+
68
+ .header-right {
69
+ display: flex;
70
+ align-items: center;
71
+ gap: 8px;
72
+ flex-shrink: 0;
73
+ }
74
+
75
+ .icon-btn {
76
+ display: flex;
77
+ align-items: center;
78
+ justify-content: center;
79
+ width: 36px;
80
+ height: 36px;
81
+ border: 1px solid var(--border-primary);
82
+ border-radius: 8px;
83
+ background: var(--bg-primary);
84
+ color: var(--text-secondary);
85
+ transition: all 0.15s;
86
+ }
87
+
88
+ .icon-btn:hover {
89
+ background: var(--bg-tertiary);
90
+ color: var(--text-primary);
91
+ border-color: var(--accent-primary);
92
+ }
93
+
94
+ .app-main {
95
+ display: flex;
96
+ flex: 1;
97
+ min-height: 0;
98
+ overflow: hidden;
99
+ }
100
+
101
+ .sidebar {
102
+ width: 300px;
103
+ min-width: 300px;
104
+ flex-shrink: 0;
105
+ padding: 16px;
106
+ overflow-y: auto;
107
+ overflow-x: hidden;
108
+ border-right: 1px solid var(--border-primary);
109
+ background: var(--bg-secondary);
110
+ display: flex;
111
+ flex-direction: column;
112
+ gap: 16px;
113
+ max-height: calc(100vh - 57px);
114
+ }
115
+
116
+ .content {
117
+ flex: 1;
118
+ min-height: 0;
119
+ padding: 20px 24px;
120
+ overflow-y: auto;
121
+ display: flex;
122
+ flex-direction: column;
123
+ gap: 0;
124
+ }
125
+
126
+ /* ── Tablet: stack sidebar above content ──────────────────────── */
127
+ @media (max-width: 900px) {
128
+ .app-header {
129
+ padding: 10px 16px;
130
+ }
131
+ .app-main {
132
+ flex-direction: column;
133
+ }
134
+ .sidebar {
135
+ width: 100%;
136
+ min-width: auto;
137
+ max-height: 200px;
138
+ flex-shrink: 0;
139
+ border-right: none;
140
+ border-bottom: 1px solid var(--border-primary);
141
+ }
142
+ .content {
143
+ padding: 16px;
144
+ }
145
+ }
146
+
147
+ /* ── Phone ────────────────────────────────────────────────────── */
148
+ @media (max-width: 480px) {
149
+ .app-header {
150
+ padding: 8px 12px;
151
+ gap: 4px;
152
+ }
153
+ .app-logo {
154
+ height: 24px;
155
+ }
156
+ .app-title {
157
+ font-size: 14px;
158
+ }
159
+ .header-left {
160
+ gap: 6px;
161
+ }
162
+ .header-right {
163
+ gap: 4px;
164
+ }
165
+ .icon-btn {
166
+ width: 32px;
167
+ height: 32px;
168
+ }
169
+ .sidebar {
170
+ max-height: 160px;
171
+ padding: 8px;
172
+ }
173
+ .content {
174
+ padding: 12px;
175
+ }
176
+ }
frontend/src/styles/variables.css ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root[data-theme="light"] {
2
+ --bg-primary: #FFFFFF;
3
+ --bg-secondary: #F9FAFB;
4
+ --bg-tertiary: #F3F4F6;
5
+ --bg-gradient: linear-gradient(135deg, #EFF6FF 0%, #FFFFFF 50%, #F3E8FF 100%);
6
+
7
+ --text-primary: #111827;
8
+ --text-secondary: #4B5563;
9
+ --text-tertiary: #6B7280;
10
+ --text-muted: #9CA3AF;
11
+
12
+ --accent-primary: #6366F1;
13
+ --accent-secondary: #8B5CF6;
14
+ --accent-gradient: linear-gradient(135deg, #5558E3, #7C4FE8);
15
+ --accent-light: #EEF2FF;
16
+
17
+ --border-primary: #E5E7EB;
18
+ --border-muted: #F3F4F6;
19
+
20
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
21
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
22
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
23
+
24
+ --card-bg: #FFFFFF;
25
+ --card-hover: #F9FAFB;
26
+
27
+ --neon-accent: #7C4FE8;
28
+ --neon-bg: #EAE0FD;
29
+ --neon-border: #C4B5F0;
30
+
31
+ --comp-bg: #E0EAFD;
32
+ --comp-border: #B8CFF0;
33
+
34
+ --speaker-a-color: #6366F1;
35
+ --speaker-a-bg: #EEF2FF;
36
+ --speaker-b-color: #059669;
37
+ --speaker-b-bg: #ECFDF5;
38
+
39
+ --error-bg: #FEE2E2;
40
+ --error-border: #FECACA;
41
+ --error-text: #991B1B;
42
+ }
43
+
44
+ :root[data-theme="dark"] {
45
+ --bg-primary: #1F2937;
46
+ --bg-secondary: #111827;
47
+ --bg-tertiary: #374151;
48
+ --bg-gradient: linear-gradient(135deg, #1F2937 0%, #111827 50%, #374151 100%);
49
+
50
+ --text-primary: #F9FAFB;
51
+ --text-secondary: #D1D5DB;
52
+ --text-tertiary: #9CA3AF;
53
+ --text-muted: #6B7280;
54
+
55
+ --accent-primary: #818CF8;
56
+ --accent-secondary: #A78BFA;
57
+ --accent-gradient: linear-gradient(135deg, #818CF8, #A78BFA);
58
+ --accent-light: #312E81;
59
+
60
+ --border-primary: #374151;
61
+ --border-muted: #1F2937;
62
+
63
+ --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
64
+ --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
65
+ --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5);
66
+
67
+ --card-bg: #1F2937;
68
+ --card-hover: #374151;
69
+
70
+ --neon-accent: #A78BFA;
71
+ --neon-bg: #2D2640;
72
+ --neon-border: #8B77C0;
73
+
74
+ --comp-bg: #1E2A40;
75
+ --comp-border: #3D5A80;
76
+
77
+ --speaker-a-color: #818CF8;
78
+ --speaker-a-bg: #312E81;
79
+ --speaker-b-color: #34D399;
80
+ --speaker-b-bg: #064E3B;
81
+
82
+ --error-bg: #450A0A;
83
+ --error-border: #7F1D1D;
84
+ --error-text: #FCA5A5;
85
+ }
86
+
87
+ *, *::before, *::after {
88
+ box-sizing: border-box;
89
+ margin: 0;
90
+ padding: 0;
91
+ }
92
+
93
+ body {
94
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
95
+ background: var(--bg-gradient);
96
+ color: var(--text-primary);
97
+ line-height: 1.5;
98
+ -webkit-font-smoothing: antialiased;
99
+ }
100
+
101
+ button {
102
+ cursor: pointer;
103
+ font-family: inherit;
104
+ }
105
+
106
+ textarea, input {
107
+ font-family: inherit;
108
+ }