6 Commits

Author SHA1 Message Date
Armen Rohalov f754afff46 annotations v2: redesign
ci/woodpecker/push/build-arm Pipeline failed
Reskin to v2 surface/accent tokens + JetBrains Mono headings to match
_docs/ui_design/v2/plugin/annotations.html. Add scrubber with class-colored
annotation marks, canvas top bar (zoom/cursor/dims), floating AI-detection
banner, multi-band gradient rows in the annotations sidebar, class-distribution
summary footer, and DOM-overlay bbox labels with affiliation icon + readiness
dot. Split VideoPlayer chrome out into the page-level controls row
(transport/frame-step/save/delete/AI-detect/mute/volume) and a new Scrubber
component; player events replace 200ms polling.

Other:
- Auth dev bypass via VITE_DEV_AUTH_BYPASS (gated on import.meta.env.DEV).
- Mount SavedAnnotationsProvider in App so AnnotationsPage doesn't crash.
- Extract hexToRgba to src/class-colors and time helpers to
  src/features/annotations/time.ts (dedup across CanvasEditor / Sidebar /
  AnnotationsPage).
- CanvasEditor: shallow-compare label chips before commit, NaN-guard
  annotation-time parser, cancel cursor RAF on unmount.
- AnnotationsPage: track AI-banner close timer, push initial volume to the
  <video> on media change, drop the duplicate parent muted state.
- Fixed sidebar widths (resize handles removed per design).
2026-05-28 02:28:10 +03:00
Armen Rohalov cfffb4bdd7 settings v2: implement design
ci/woodpecker/push/build-arm Pipeline failed
- Rewrite SettingsPage to 5-panel v2 layout: Tenant, Directories,
  Aircrafts, Language, Session — corner-bracket panels, sticky footer
  pinned to viewport bottom (Cancel + Save Changes), live dirty-state
  indicator.
- Wire try/catch/finally + role="alert" in save handler so AZ-477's
  three it.fails contract tests flip to passing; remove the obsolete
  v1-drift control test and its unhandledRejection harness.
- Add EN/UA language toggle; persist to localStorage('azaion.lang')
  and read on i18n init. Export LANG_STORAGE_KEY from src/i18n.
- Add Add-Aircraft flow (reuses admin Modal) and view-only star
  default toggle.
- Extend the v2 design system with .btn-danger-ghost, .star,
  .path-wrap/.browse classes. Scope settings.html-spec button
  proportions (padding 7px 14px, weight 400, letter-spacing 0.10em,
  line-height 1.5) under .settings-page so the admin spec is unaffected.
- Restore module-scoped bootstrapInflight declaration in
  src/auth/AuthContext.tsx (deleted in 2a62415 while references
  remained — every test using tests/setup.ts was throwing
  ReferenceError).
2026-05-26 00:25:27 +03:00
Armen Rohalov 5c3c06aad8 Merge branch 'feat/admin-page' into dev
ci/woodpecker/push/build-arm Pipeline failed
2026-05-19 02:04:09 +03:00
Oleksandr Bezdieniezhnykh a943b508f6 Merge branch 'dev' of https://github.com/azaion/ui into dev
ci/woodpecker/push/build-arm Pipeline failed
2026-05-17 13:19:30 +03:00
Oleksandr Bezdieniezhnykh 8e90e24f5a [no-ticket] Sync .cursor with suite root
Bring this repo's .cursor/ in line with the suite monorepo root .cursor/
so rules, skills, and autodev artifacts stay consistent across
submodules and sibling repos.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-17 13:11:01 +03:00
Oleksandr Bezdieniezhnykh eb1e8a8581 [AZ-512] Cycle 4 closure: deploy + retro + lessons + state
Closes cycle 4 (AZ-512 admin class inline edit).

Steps 16-17 artifacts:
- deploy_cycle4_report.md: ui/ dev pushed (09449bd..8737491, 4 commits,
  fast-forward); stage/main and admin/ dev deferred at the push-scope
  gate (option A; same as cycle 3). AZ-513 admin/ implementation +
  deploy gate stays open as the cross-workspace prerequisite.
- retro_2026-05-13_cycle4.md: PASS_WITH_WARNINGS verdict carries;
  243 PASS / 13 SKIP / 0 FAIL; bundle 291 332 B (+757 B / +0.26%);
  net architecture delta 0; user-action backlog 7 -> 9 (rate
  decelerating from +4 to +2); first cycle where the user explicitly
  overrode a spec-conservative default (AZ-512 Option B).
- structure_2026-05-13_cycle4.md: identity-copy snapshot; no new
  components, no new gates, no new barrels, no new wire-contract
  assertions, no new architecture findings.
- LESSONS.md: top-3 cycle-4 lessons appended (testing/testing/process),
  ring buffer at 12 of 15.
- _autodev_state.md: cycle 4 closed, cycle 5 entered awaiting New Task.

Jira AZ-512: In Testing -> Done with cycle-4 closing comment.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-13 04:56:42 +03:00
34 changed files with 2863 additions and 662 deletions
+1
View File
@@ -39,6 +39,7 @@ alwaysApply: true
- When you think you are done with changes, run the full test suite. Every failure in tests that cover code you modified or that depend on code you modified is a **blocking gate**. For pre-existing failures in unrelated areas, report them to the user but do not block on them. Never silently ignore or skip a failure without reporting it. On any blocking failure, stop and ask the user to choose one of:
- **Investigate and fix** the failing test or source code
- **Remove the test** if it is obsolete or no longer relevant
- **Iterative-skill exception**: when an iterative loop skill is active (e.g. autodev / `implement/SKILL.md` batch loop, `refactor/SKILL.md` batch loop), the skill governs full-suite cadence — typically focused tests per task/batch and a single full-suite gate at the very end of the implementation phase, NOT after each batch. "Done with changes" means done with the entire implementation phase the skill is running, not done with one batch. Do not run the full suite per batch unless the skill explicitly says to.
- Do not rename any databases or tables or table columns without confirmation. Avoid such renaming if possible.
- Make sure we don't commit binaries, create and keep .gitignore up to date and delete binaries after you are done with the task
+41
View File
@@ -0,0 +1,41 @@
---
description: "Use chunked writes (Write + StrReplace marker pattern) for large generated files, especially after a monolithic Write fails"
alwaysApply: true
---
# Large File Writes — Chunk on Failure
When a `Write` call to a single file fails (timeout, payload limit, "Invalid arguments", or any tool error) and the intended content is large (>~500 lines or >~50 KB), do NOT retry the same monolithic Write. Switch to chunked writes:
1. **First Write** — create the file with header + table of contents (if applicable) + an explicit append marker, e.g.
```
<!-- INSERTION_POINT do-not-remove-until-final-chunk -->
```
2. **Each subsequent chunk** — use `StrReplace` to replace the marker with `<new content>\n<marker>` so the marker stays at the end. This is idempotent: if a chunk fails, retry it without losing earlier chunks.
3. **Final chunk** — `StrReplace` removes the marker.
## Why
- Tool argument size limits and transient failures hit large monolithic writes hardest. Retrying the same large payload typically fails for the same reason.
- Chunked writes are recoverable per chunk. The earlier chunks are durable on disk.
- A unique marker is greppable, visible in diffs, and stops accidental insertion in the wrong place.
## Triggers
- Generated documentation that aggregates per-component content (epics, design docs, multi-section architecture summaries, traceability dumps).
- Large fixture or test-data files written from a template.
- Any single-file artifact you can pre-estimate at >~500 lines.
## Do NOT chunk
- Files under ~200 lines — a single `Write` is faster, clearer, and easier to review.
- Source code files where appending breaks module structure (functions, classes, imports). Split into multiple files instead.
- Files where ordering of sections is computed late and inserting in the middle is required — use a single `Write` once the full content is known.
## Anti-patterns
- Retrying the same failed monolithic `Write` more than once. Twice is the limit; on the second failure, switch strategies.
- Using `Shell` with heredoc (`cat <<EOF`) or `echo >>` to append — these bypass the editor diff view and break the StrReplace contract for the next chunk.
- Embedding the marker so deep inside structured content that a chunk's `StrReplace` becomes ambiguous. Place the marker on its own line at the very end of the file.
+6 -3
View File
@@ -14,11 +14,14 @@ alwaysApply: true
- Issue types: Epic, Story, Task, Bug, Subtask
## Tracker Availability Gate
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, or any non-success response: **STOP** tracker operations and notify the user via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
- If Jira MCP returns **Unauthorized**, **errored**, **connection refused**, **timeout**, a non-2xx status code, an empty body, or any response shape that does not clearly confirm the requested change: **STOP IMMEDIATELY** — no automatic retry, no silent continuation. Surface the full raw error/response to the user verbatim and notify via the Choose A/B/C/D format documented in `.cursor/skills/autodev/protocols.md`.
- A minimal `{"success": true}` body with no echoed issue state is NOT a confirmed transition. When a transition's success matters (status moves, ticket creation, blocking link), follow it with a read-back call (`getJiraIssue` or equivalent) and confirm the new state matches what you asked for. If the read-back disagrees → STOP and ASK.
- Do NOT loop "retry up to N times before asking". One call, one verification. On failure, the user decides whether to retry.
- The user may choose to:
- **Retry authentication** — preferred; the tracker remains the source of truth.
- **Retry the same operation** — once, after the user authorizes it. If it fails again, surface both responses.
- **Retry authentication** — preferred when the failure looks like an auth/credentials problem; the tracker remains the source of truth.
- **Continue in `tracker: local` mode** — only when the user explicitly accepts this option. In that mode all tasks keep numeric prefixes and a `Tracker: pending` marker is written into each task header. The state file records `tracker: local`. The mode is NOT silent — the user has been asked and has acknowledged the trade-off.
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. If the user is unreachable (e.g., non-interactive run), stop and wait.
- Do NOT auto-fall-back to `tracker: local` without a user decision. Do not pretend a write succeeded. Do not paper over an opaque response by moving on. If the user is unreachable (e.g., non-interactive run), stop and wait.
- When the tracker becomes available again, any `Tracker: pending` tasks should be synced — this is done at the start of the next `/autodev` invocation via the Leftovers Mechanism below.
## Leftovers Mechanism (non-user-input blockers only)
+3 -2
View File
@@ -67,8 +67,9 @@ B3. Read state — `_docs/_autodev_state.md` (if it exists).
B4. Read File Index — `state.md`, `protocols.md`, and the active flow file.
### Resolve (once per invocation, after Bootstrap)
R1. Reconcile state — verify state file against `_docs/` contents; on disagreement, trust the folders
and update the state file (rules: `state.md` → "State File Rules" #4).
R1. Reconcile state — verify state file against `_docs/` contents; probe `<workspace-root>/../docs`
(parent suite `docs/` — see `state.md` → "State File Rules" #4); on disagreement,
trust the folders and update the state file (rules: `state.md` → "State File Rules" #4).
After this step, `state.step` / `state.status` are authoritative.
R2. Resolve flow — see §Flow Resolution above.
R3. Resolve current step — when a state file exists, `state.step` drives detection.
+158 -13
View File
@@ -5,7 +5,8 @@ Workflow for **meta-repositories** — repos that aggregate multiple components
This flow differs fundamentally from `greenfield` and `existing-code`:
- **No problem/research/plan phases** — meta-repos don't build features, they coordinate existing ones
- **No test spec / implement / run tests** — the meta-repo has no code to test
- **No test spec / run tests** — the meta-repo has no code to test
- **`implement` is scoped to suite-level work only** — cross-repo concerns, repo/folder renames, suite-root infra additions (e.g., `.gitmodules`, `_infra/`, suite `e2e/`). Per-component implementation lives in each component's own workspace `/autodev` cycle. The meta-repo's implement step (Step 3.5) executes only when `_docs/tasks/todo/` is non-empty AND the user explicitly opts in; placement is **before** the sync skills so subsequent Doc/E2E/CICD sync propagates the post-implementation state.
- **No `_docs/00_problem/` artifacts** — documentation target is `_docs/*.md` unified docs, not per-feature `_docs/NN_feature/` folders
- **Primary artifact is `_docs/_repo-config.yaml`** — generated by `monorepo-discover`, read by every other step
@@ -17,6 +18,7 @@ This flow differs fundamentally from `greenfield` and `existing-code`:
| 2 | Config Review | (human checkpoint, no sub-skill) | — |
| 2.5 | Glossary & Architecture Vision | (inline, no sub-skill) | Steps 15 |
| 3 | Status | monorepo-status/SKILL.md | Sections 15 |
| 3.5 | Suite Implement | implement/SKILL.md (suite-level invocation context) | Steps 114 + 16 (Step 14.5 + Step 15 skipped); conditional on `_docs/tasks/todo/` non-empty AND user opt-in |
| 4 | Document Sync | monorepo-document/SKILL.md | Phase 17 (conditional on doc drift) |
| 4.5 | Integration Test Sync | monorepo-e2e/SKILL.md | Phase 16 (conditional on suite-e2e drift; skipped if `suite_e2e:` block absent in config) |
| 5 | CICD Sync | monorepo-cicd/SKILL.md | Phase 17 (conditional on CI drift) |
@@ -184,11 +186,16 @@ The status report identifies:
- Registry/config mismatches
- Unresolved questions
Based on the report, auto-chain branches:
Based on the report, auto-chain branches in this evaluation order (first match wins):
- If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
- Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
- Else if **registry mismatch** found (new components not in config) → present Choose format:
1. **Registry mismatch** (new components not in config, or config component not in registry) → present the Choose format below FIRST. After the user resolves it (A: refresh discover, B: onboard, C: continue with mismatch acknowledged), proceed to the next rule. This rule has priority because a stale config would mislead Step 3.5's ownership-envelope synthesis and any sync skill's component scope.
2. **Pre-routing gate (Step 3.5 detection)** — check `_docs/tasks/todo/` for suite-level task files (`*.md` excluding files starting with `_`). If ≥1 task is present, auto-chain to **Step 3.5 (Suite Implement)**. After Step 3.5 returns (regardless of A/B outcome), the post-implement re-status applies rules 36 below to the post-implementation state.
3. If **doc drift** found → auto-chain to **Step 4 (Document Sync)**
4. Else if **CI drift** (only) found → auto-chain to **Step 5 (CICD Sync)**
5. Else if **suite-e2e drift** (only) found → auto-chain to **Step 4.5 (Integration Test Sync)** (only when `suite_e2e:` block exists in config)
6. Else → **workflow done for this cycle**.
**Registry mismatch Choose format** (rule 1):
```
══════════════════════════════════════
@@ -205,7 +212,134 @@ Based on the report, auto-chain branches:
══════════════════════════════════════
```
- Else → **workflow done for this cycle**. Report "No drift. Meta-repo is in sync." Loop waits for next invocation.
When rule 6 fires (no drift, no todo tasks), report "No drift. Meta-repo is in sync." and end the cycle. Loop waits for next invocation.
---
**Step 3.5 — Suite Implement**
Condition (folder fallback): `_docs/tasks/todo/` exists AND contains ≥1 file matching `*.md` excluding files starting with `_` (e.g., `_dependencies_table.md` is excluded by convention).
State-driven: reached by auto-chain from Step 3 when the pre-routing gate detected todo tasks. Inserted **before** the sync skills (Step 4 / 4.5 / 5) by deliberate design: implementing renames + cross-repo edits first means the subsequent sync skills propagate the actual landed state rather than the pre-change state, avoiding a second cycle to fix downstream drift.
**Skip condition**: `_docs/tasks/todo/` is empty, missing, or contains only `_*` files. In that case Step 3.5 is skipped entirely and the cycle proceeds with Step 3's existing drift-based routing.
**Goal**: Execute suite-level implementation tasks — cross-repo concerns (e.g., `autopilot` + `ui` + suite `e2e/` cutover in a coordinated change-set), folder renames (e.g., `git mv flights missions` + `.gitmodules` edit + `_infra/` path refs), and suite-root infrastructure additions (e.g., `_infra/dev/docker-compose.dev.yml`). Per-component implementation work stays in each component's own workspace `/autodev` cycle.
**Why this exists**: the meta-repo's existing sync skills (`monorepo-document`, `monorepo-cicd`, `monorepo-e2e`) only **propagate** changes that already landed. They cannot **execute** a task spec. Without Step 3.5, suite-level tickets like AZ-543 (B4 repo rename) or AZ-506 (new dev compose) have no flow path forward — they require operator action outside autodev.
**Inputs**:
- `_docs/tasks/todo/*.md` (excluding `_*`) — task specs in the existing format (`Task` / `Component` / `Dependencies` / `Acceptance criteria` headers)
- `_docs/_repo-config.yaml` — `components[].path` list, used to compute the suite-level OWNED envelope (workspace root EXCLUDING any path under a component's folder)
- `_docs/tasks/_dependencies_table.md` — synthesized by this step if missing (see Procedure)
- `_docs/tasks/_suite_module_layout.md` — synthesized by this step if missing (see Procedure)
**Procedure**:
1. **Detection (already done by Step 3 pre-routing gate)**. List task files in `_docs/tasks/todo/` (excluding `_*`). If 0 → skip Step 3.5. If ≥1 → continue.
2. **Present Choose**:
```
══════════════════════════════════════
DECISION REQUIRED: <N> suite-level task(s) in _docs/tasks/todo/
══════════════════════════════════════
Task(s) detected:
- AZ-XXX: <title> (deps: <list or "—">)
- AZ-YYY: <title> (deps: <list or "—">)
...
A) Run implement skill on these task(s) now (then continue to Doc / E2E / CICD sync)
B) Skip implement this cycle — continue to Doc / E2E / CICD sync without executing tasks
C) Pause — review the tasks before deciding (end session, no state changes)
══════════════════════════════════════
Recommendation: A — running implement BEFORE syncs means subsequent
sync skills propagate the post-implementation state.
B is appropriate when tasks are blocked on user input
or external coordination. C when the tasks themselves
need owner clarification before execution.
══════════════════════════════════════
```
3. **On user A — Pre-flight**:
a. **Working tree clean check**. Run `git status --porcelain`. If non-empty, surface to the user with a Choose A/B/C identical to the implement skill's prerequisite gate (commit/stash manually; agent commits as `chore: WIP pre-implement`; abort).
b. **Synthesize `_docs/tasks/_dependencies_table.md`** if missing. Parse each in-scope task's `Dependencies:` field. Write a minimal table of the form:
```markdown
# Suite-Level Task Dependencies
| Task ID | Depends on | Notes |
|---------|------------|-------|
| AZ-XXX | (none) | — |
| AZ-YYY | AZ-XXX | — |
```
If a task lists a dependency that is neither in `todo/` nor `done/`, log a warning in the synthesized file but do not block — implement skill's Step 1 (Parse) will surface the issue if it actually blocks execution.
c. **Synthesize `_docs/tasks/_suite_module_layout.md`** if missing. Default content:
```markdown
# Suite-Level Module Layout (synthetic)
Generated by autodev meta-repo Step 3.5. The suite root has no per-feature decomposition; ownership is defined at the component-boundary level only.
## Per-Component Mapping
| Component | Owns | Imports from |
|-----------|----------------------------------|--------------|
| suite | (workspace root) excluding any path listed under `_repo-config.yaml.components[].path` | (read-only) every component's primary doc + `_docs/*.md` |
Suite-level tasks operate on: `.gitmodules`, `_infra/**`, `_docs/**` (excluding `_docs/tasks/_*` regenerated files), root `README.md`, `e2e/**` (suite e2e harness only).
Forbidden paths for suite-level tasks: `<component>/**` for every component listed in `_repo-config.yaml.components[].path` — those edits live in the component's own workspace `/autodev` cycle.
```
d. **Prepare invocation context**:
```
suite_level: true
TASKS_DIR: _docs/tasks/
module_layout_path: _docs/tasks/_suite_module_layout.md
```
4. **Invoke implement skill**. Read and execute `.cursor/skills/implement/SKILL.md` with the prepared context. The skill's "Suite-level invocation context" subsection (added in tandem with this flow change) honors the three flags above and skips:
- Step 14.5 (cumulative code review) — no `architecture_compliance_baseline.md` exists at the suite level; cross-task drift is captured by the next `monorepo-status` cycle instead.
- Step 15 (Product Implementation Completeness Gate) — the gate's inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite tasks are infrastructure / coordination work, not feature implementation.
All other implement skill steps (114, 16) execute unchanged. Tracker integration (Step 5: In Progress, Step 12: In Testing) runs normally.
5. **Post-implement re-status**. After the implement skill completes (last batch committed, all originally-todo tasks moved to `_docs/tasks/done/`), silently re-run Step 3's drift detection logic — do NOT re-render the full Status report; just re-evaluate the drift signals against the post-implementation tree. Then auto-chain per the post-implementation drift findings:
- Doc drift → Step 4 (Document Sync)
- Suite-e2e drift only → Step 4.5
- CI drift only → Step 5
- No drift → cycle complete
Note: the post-implement re-status is exactly why Step 3.5 is placed before sync. A repo rename will typically introduce doc + CI drift; the next invocation of Step 4 / Step 5 catches it on the same cycle.
6. **On user B (skip)** → mark Step 3.5 `skipped` in state file. Apply Step 3's original drift-based routing (compute from the pre-Step-3.5 Status report).
7. **On user C (pause)** → end session. Update state to `step: 3.5, status: in_progress, sub_step: {phase: 0, name: awaiting-task-review, detail: "<N> tasks pending review"}`. Tell the user to invoke `/autodev` again after deciding. **Do NOT modify any files** — pre-flight has not run yet.
**Self-verification** (executed before invoking implement):
- [ ] Working tree is clean (or user explicitly chose B in the WIP-stash sub-Choose)
- [ ] `_docs/tasks/_dependencies_table.md` exists (synthesized if it didn't)
- [ ] `_docs/tasks/_suite_module_layout.md` exists (synthesized if it didn't)
- [ ] All in-scope task files have a `Component:` field (skip + report any that don't — don't guess ownership)
- [ ] Tracker availability gate satisfied per `protocols.md` (or `tracker: local` previously chosen)
**Failure handling**:
- If implement returns FAILED → standard Failure Handling (`protocols.md`): retry up to 3 times, then escalate.
- If implement is interrupted mid-batch → next invocation re-detects via the implement skill's resumability protocol (read latest `_docs/03_implementation/suite_batch_*.md`). Step 3.5 itself is reentrant: on re-entry, if `todo/` still has tasks, it presents the Choose again with the remaining set.
- **Half-applied state risk** (acknowledged): if implement is interrupted between commits, the working tree is clean at the last commit boundary but the in-flight batch is lost. The user is responsible for inspecting and re-invoking. This is intentional — automated rollback of suite-level renames + `.gitmodules` edits is more dangerous than a human-driven recovery.
**Idempotency**: if `_docs/tasks/todo/` becomes empty after this step (all tasks moved to `done/`), the next `/autodev` invocation skips Step 3.5 entirely and proceeds with normal Status → sync flow.
---
@@ -287,11 +421,16 @@ After onboarding completes, the config is updated. Auto-chain back to **Step 3 (
| Config Review (2, user picked A, confirmed_by_user: true) | Auto-chain → Glossary & Architecture Vision (2.5) |
| Config Review (2, user picked B) | **Session boundary** — end session, await re-invocation |
| Glossary & Architecture Vision (2.5) | Auto-chain → Status (3) |
| Status (3, doc drift) | Auto-chain → Document Sync (4) |
| Status (3, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
| Status (3, CI drift only) | Auto-chain → CICD Sync (5) |
| Status (3, no drift) | **Cycle complete** — end session, await re-invocation |
| Status (3, todo tasks present) | Auto-chain → Suite Implement (3.5) — pre-routing gate fires before drift-based routing |
| Status (3, no todo tasks, doc drift) | Auto-chain → Document Sync (4) |
| Status (3, no todo tasks, suite-e2e drift only) | Auto-chain → Integration Test Sync (4.5) |
| Status (3, no todo tasks, CI drift only) | Auto-chain → CICD Sync (5) |
| Status (3, no todo tasks, no drift) | **Cycle complete** — end session, await re-invocation |
| Status (3, registry mismatch) | Ask user (A: discover, B: onboard, C: continue) |
| Suite Implement (3.5, user picked A, success) | Silent re-status; auto-chain per post-implementation drift (Step 4 / 4.5 / 5 / cycle complete) |
| Suite Implement (3.5, user picked B) | Mark `skipped`; auto-chain per Step 3's original drift findings |
| Suite Implement (3.5, user picked C) | **Session boundary** — end session, await re-invocation |
| Suite Implement (3.5, FAILED ×3) | Standard Failure Handling escalation (`protocols.md`) |
| Document Sync (4) + suite-e2e drift pending | Auto-chain → Integration Test Sync (4.5) |
| Document Sync (4) + CI drift only pending | Auto-chain → CICD Sync (5) |
| Document Sync (4) + no further drift | **Cycle complete** |
@@ -317,11 +456,12 @@ Flow-specific slot values:
| 2 | Config Review | `IN PROGRESS (awaiting human)` |
| 2.5 | Glossary & Architecture Vision | `SKIPPED (already captured)` |
| 3 | Status | `DONE (no drift)`, `DONE (N drifts)` |
| 3.5 | Suite Implement | `DONE (N tasks)`, `SKIPPED (no todo tasks)`, `SKIPPED (user picked B)`, `IN PROGRESS (batch M of ~N)`, `IN PROGRESS (awaiting-task-review)` |
| 4 | Document Sync | `DONE (N docs)`, `SKIPPED (no doc drift)` |
| 4.5 | Integration Test Sync | `DONE (N files)`, `SKIPPED (no suite-e2e drift)`, `SKIPPED (no suite_e2e config block)` |
| 5 | CICD Sync | `DONE (N files)`, `SKIPPED (no CI drift)` |
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
All rows accept the shared state tokens (`DONE`, `IN PROGRESS`, `NOT STARTED`, `FAILED (retry N/3)`); rows 2.5, 3.5, 4, 4.5, and 5 additionally accept `SKIPPED`.
Row rendering format:
@@ -330,6 +470,7 @@ Row rendering format:
Step 2 Config Review [<state token>]
Step 2.5 Glossary & Architecture Vision [<state token>]
Step 3 Status [<state token>]
Step 3.5 Suite Implement [<state token>]
Step 4 Document Sync [<state token>]
Step 4.5 Integration Test Sync [<state token>]
Step 5 CICD Sync [<state token>]
@@ -337,8 +478,12 @@ Row rendering format:
## Notes for the meta-repo flow
- **No session boundary except Step 2 and Step 2.5**: unlike existing-code flow (which has boundaries around decompose), meta-repo flow only pauses at config review and the one-shot glossary/vision capture. Once both are confirmed, syncing is fast enough to complete in one session and Step 2.5 idempotently no-ops on every subsequent invocation.
- **Session boundaries**: Step 2 (Config Review pending), Step 2.5 (one-shot glossary/vision review), and Step 3.5 (when user picks C "Pause"). Step 3.5's A/B picks do NOT cross a session boundary — they auto-chain to syncs in the same session.
- **Cyclical, not terminal**: no "done forever" state. Each invocation completes a drift cycle; next invocation starts fresh.
- **No tracker integration**: this flow does NOT create Jira/ADO tickets. Maintenance is not a feature — if a feature-level ticket spans the meta-repo's concerns, it lives in the per-component workspace.
- **Tracker integration scope**: this flow does NOT create Jira/ADO tickets in its sync skills (Status / Document Sync / E2E / CICD). Step 3.5 (Suite Implement) IS tracker-integrated — it transitions existing tickets In Progress → In Testing per the implement skill's standard tracker handling. Suite-level tickets are authored manually by the operator (typically as children of an Epic that spans multiple components, like AZ-539); the flow doesn't auto-create them.
- **Per-component vs. suite-level work**:
- Tickets that touch component source code (`<component>/src/**`) belong in that component's own workspace `/autodev` cycle. The meta-repo flow does NOT execute them.
- Tickets that touch suite-root paths only (`.gitmodules`, `_infra/**`, suite `e2e/**`, root `README.md`, suite `_docs/**` outside `tasks/_*`) are eligible for Step 3.5.
- Tickets that span both (e.g., AZ-550 B11 consumer cutover, which touches `autopilot/`, `ui/`, AND suite `e2e/`) are NOT executable from a single workspace by design — split the ticket so the suite-level slice can run in Step 3.5 and the component slices run in their owning workspaces.
- **Onboarding is opt-in**: never auto-onboarded. User must explicitly request.
- **Failure handling**: uses the same retry/escalation protocol as other flows (see `protocols.md`).
+2 -1
View File
@@ -114,6 +114,7 @@ Before entering a step from this table for the first time in a session, verify t
| greenfield | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
| existing-code | Decompose Tests | Step 1t + Step 3 — All test tasks | Create ticket per task, link to epic |
| existing-code | New Task | Step 7 — Ticket | Create ticket per task, link to epic |
| meta-repo | Suite Implement | Step 3.5 — implement skill Step 5 / Step 12 | Transition existing tickets In Progress → In Testing per implement skill (does NOT create new tickets — operator authors them) |
### State File Marker
@@ -388,7 +389,7 @@ The banner shell is defined here once. Each flow file contributes only its step-
where `<state token>` comes from the state-token set defined per row in the flow's step-list table.
- `<current-suffix>` — optional, flow-specific. The existing-code flow appends ` (cycle <N>)` when `state.cycle > 1`; other flows leave it empty.
- `Retry:` row — omit entirely when `retry_count` is 0. Include it with `<N>/3` otherwise.
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty.
- `<footer-extras>` — optional, flow-specific. The meta-repo flow adds a `Config:` line with `_docs/_repo-config.yaml` state; other flows leave it empty unless **parent suite docs** apply: if `<workspace-root>/../docs` exists and is a directory, append `Suite docs (parent): <absolute path>` on its own line (or `Suite docs (parent): absent` is **not** required — omit when missing). This line is orthogonal to flow-specific footer lines; both may appear.
### State token set (shared)
+15 -2
View File
@@ -13,7 +13,7 @@ The autodev persists its position to `_docs/_autodev_state.md`. This is a lightw
## Current Step
flow: [greenfield | existing-code | meta-repo]
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo, or "done"]
step: [1-17 for greenfield, 1-17 for existing-code, 1-6 for meta-repo (incl. fractional 2.5 and 3.5), or "done"]
name: [step name from the active flow's Step Reference Table]
status: [not_started / in_progress / completed / skipped / failed]
sub_step:
@@ -82,6 +82,19 @@ retry_count: 0
cycle: 1
```
```
flow: meta-repo
step: 3.5
name: Suite Implement
status: in_progress
sub_step:
phase: 7
name: batch-loop
detail: "AZ-543 batch 1 of 1; suite-level"
retry_count: 0
cycle: 1
```
```
flow: existing-code
step: 10
@@ -100,7 +113,7 @@ cycle: 3
1. **Create** on the first autodev invocation (after state detection determines Step 1)
2. **Update** after every change — this includes: batch completion, sub-step progress, step completion, session boundary, failed retry, or any meaningful state transition. The state file must always reflect the current reality.
3. **Read** as the first action on every invocation — before folder scanning
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file
4. **Cross-check**: verify against actual `_docs/` folder contents. If they disagree, trust the folder structure and update the state file. **Parent suite `docs/`**: on every invocation, also probe `<workspace-root>/../docs` (the parent directorys `docs` folder — typical suite-level shared documentation next to a component repo). If it exists, mention it in the Status Summary footer per `protocols.md`; use it only as supplemental reading context unless a flow step explicitly ties detection to it. It never replaces workspace `_docs/` for step detection by default.
5. **Never delete** the state file
6. **Retry tracking**: increment `retry_count` on each failed auto-retry; reset to `0` on success. If `retry_count` reaches 3, set `status: failed`
7. **Failed state on re-entry**: if `status: failed` with `retry_count: 3`, do NOT auto-retry — present the issue to the user first
+27 -3
View File
@@ -64,6 +64,27 @@ TASKS_DIR/
└── done/ ← completed tasks (moved here after implementation)
```
### Suite-level invocation context (meta-repo flow)
When invoked from `.cursor/skills/autodev/flows/meta-repo.md` Step 3.5 (or any caller that supplies the same context envelope), the skill receives:
```
suite_level: true
TASKS_DIR: <override> # e.g., _docs/tasks/ (vs. default _docs/02_tasks/)
module_layout_path: <override> # e.g., _docs/tasks/_suite_module_layout.md
```
When `suite_level: true` is present, the following gate adjustments apply — and ONLY these. All other steps (114, 16) execute unchanged:
1. **TASKS_DIR override** is honored throughout the skill (Step 1 Parse, Step 13 Archive, Step 15 input paths if it ran). Default `_docs/02_tasks/` is replaced by the supplied path.
2. **module_layout_path override** is read instead of the hardcoded `_docs/02_document/module-layout.md` in Step 4 (Assign File Ownership). The supplied file uses the same `Per-Component Mapping` schema. If both the override and the hardcoded path are missing, behavior is unchanged from default mode (STOP and instruct).
3. **Step 14.5 (Cumulative Code Review) — SKIPPED**. The meta-repo has no `_docs/02_document/architecture_compliance_baseline.md`; cross-task drift is captured by the next `monorepo-status` cycle instead.
4. **Step 15 (Product Implementation Completeness Gate) — SKIPPED**. The gate's hard inputs (`_docs/02_document/architecture.md`, `system-flows.md`, `components/*/description.md`) do not exist in the meta-repo artifact layout. Suite-level tasks are infrastructure / coordination work (renames, cross-repo edits, suite-root infra additions), not feature implementation; the equivalent completeness signal is the next `monorepo-status` drift report (which the meta-repo flow re-runs immediately after Step 3.5 returns).
5. **Final report filename**: `_docs/03_implementation/suite_implementation_report_{run_name}.md` (in addition to the existing feature/test/refactor variants). Batch reports follow `_docs/03_implementation/suite_batch_{NN}_report.md`.
6. **Tracker integration** (Step 5: In Progress, Step 12: In Testing) runs unchanged — suite-level tickets follow the same tracker rules as any other.
Without `suite_level: true`, none of these adjustments apply and the skill runs exactly as documented in default mode.
## Prerequisite Checks (BLOCKING)
1. `TASKS_DIR/todo/` exists and contains at least one task file for the selected context — **STOP if missing**
@@ -103,7 +124,7 @@ TASKS_DIR/
### 4. Assign File Ownership
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
The authoritative file-ownership map is `_docs/02_document/module-layout.md` (produced by the decompose skill's Step 1.5), unless `suite_level: true` was supplied in the invocation context — in which case the `module_layout_path` override is read instead (see "Suite-level invocation context" above). Task specs are purely behavioral — they do NOT carry file paths. Derive ownership from the layout, not from the task spec's prose.
For each task in the batch:
- Read the task spec's **Component** field.
@@ -222,6 +243,8 @@ For product implementation, this archive means "batch implementation accepted."
### 14.5. Cumulative Code Review (every K batches)
**Skipped entirely when `suite_level: true`** (see "Suite-level invocation context" above) — the meta-repo has no `architecture_compliance_baseline.md` to evaluate against; cross-task drift is captured by the next `monorepo-status` cycle.
- **Trigger**: every K completed batches (default `K = 3`; configurable per run via a `cumulative_review_interval` knob in the invocation context)
- **Purpose**: per-batch review (Step 9) catches batch-local issues; cumulative review catches issues that only appear when tasks are combined — architecture drift, cross-task inconsistency, duplicate symbols introduced across different batches, contracts that drifted across producer/consumer batches
- **Scope**: the union of files changed since the **last** cumulative review (or since the start of the run if this is the first)
@@ -239,7 +262,7 @@ For product implementation, this archive means "batch implementation accepted."
### 15. Product Implementation Completeness Gate
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate only when the remaining context is explicitly test implementation or refactoring, as determined by the task files and report filename rules.
Run this gate after all **product implementation** tasks are complete and before writing any final product implementation report or allowing autodev to proceed to testability/test decomposition. Skip this gate when (a) the remaining context is explicitly test implementation or refactoring (as determined by the task files and report filename rules), OR (b) `suite_level: true` was supplied in the invocation context (the gate's inputs do not exist in the meta-repo artifact layout — see "Suite-level invocation context" above).
**Goal**: catch the failure mode where narrow tests validate scaffold behavior while the task's actual outcome, included scope, architecture promise, or named integration remains unimplemented.
@@ -309,8 +332,9 @@ After each batch completes, save the batch report to `_docs/03_implementation/ba
- **Test implementation** (tasks from test decomposition): `_docs/03_implementation/implementation_report_tests.md`
- **Feature implementation**: `_docs/03_implementation/implementation_report_{feature_slug}_cycle{N}.md` where `{feature_slug}` is derived from the batch task names (e.g., `implementation_report_core_api_cycle2.md`) and `{N}` is the current `state.cycle` from `_docs/_autodev_state.md`. If `state.cycle` is absent (pre-migration), default to `cycle1`.
- **Refactoring**: `_docs/03_implementation/implementation_report_refactor_{run_name}.md`
- **Suite-level** (when `suite_level: true` was supplied — see "Suite-level invocation context" above): `_docs/03_implementation/suite_implementation_report_{run_name}.md`. Batch reports use `_docs/03_implementation/suite_batch_{NN}_report.md`. `{run_name}` is derived from the batch task IDs (e.g., `suite_implementation_report_az543_az549_az550.md`).
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; otherwise derive the feature slug from the component names and append the cycle suffix.
Determine the context from the task files being implemented: if all tasks have test-related names or belong to a test epic, use the tests filename; if `suite_level: true` was supplied, use the suite filename; otherwise derive the feature slug from the component names and append the cycle suffix.
Batch report filenames must also include the cycle counter when running feature implementation: `_docs/03_implementation/batch_{NN}_cycle{N}_report.md` (test and refactor runs may use the plain `batch_{NN}_report.md` form since they are not cycle-scoped).
@@ -0,0 +1,74 @@
# Cycle 4 Step 16 — Deploy Report
**Date**: 2026-05-13
**Cycle**: 4 (autodev existing-code Step 16)
**Mode chosen**: real cutover (option A in the cycle-4 deploy gate — "Push to ui/ dev only")
**Outcome**: ui/ dev pushed; stage/prod cutover deferred to a later turn; admin/ dev NOT pushed; cross-workspace AZ-513 still un-implemented.
## What was actually deployed
| Repo | Branch | Commits pushed | Pipeline triggered |
|------|--------|----------------|--------------------|
| `ui/` | `dev` (`09449bd..8737491`) | 4 | Woodpecker dev build for `ui/` |
| `admin/` | — | 0 | none |
### Commits pushed to `ui/` `origin/dev`
```
8737491 [AZ-512] Cycle 4 Steps 12-15: test-spec sync + docs + sec + perf
ecacfa8 [AZ-512] Admin class inline edit form + PATCH wiring (cy4 batch 16)
ef56d9c [AZ-512] chore: reactivate for cycle 4 (Option B path)
eef3bdf [AZ-509][AZ-510][AZ-511] Cycle 3 closure: deploy + retro + state
```
The cycle-3 closure commit `eef3bdf` was locally ahead since cycle 3's deploy step (deferred at the cycle-3 push-scope gate), and gets pushed now alongside cycle-4's three commits as a single fast-forward.
## What was NOT done (deferred / pending)
| ID | Item | Reason | Owner |
|----|------|--------|-------|
| D-CY4-STAGE | `ui/` `dev → stage → push origin/stage` | User chose option A (dev-only) at the cycle-4 deploy gate. Stage cutover deferred. **Will compound with cycle-3 stage deferral** — when stage cutover lands, it will ship cycles 3 + 4 simultaneously. | User |
| D-CY4-MAIN | `ui/` `stage → main → push origin/main` (prod cutover) | Same reason. Devices will not auto-pull cycle-3 + cycle-4 changes until this completes. | User |
| D-CY4-AZ513-IMPL | Implementation of AZ-513 (admin/ POST + PATCH + DELETE /classes routes) | Cross-workspace dependency: `admin/` workspace must implement before AZ-512 is functionally usable in any environment. Filed in Jira (AZ-513, parent epic AZ-509, Blocks AZ-512). UI ships with MSW-stubbed tests under user-authorized Option B — the live PATCH endpoint does not exist server-side yet, so the deployed `ui/` dev build will surface `admin.classes.updateFailed` on real edits. | admin/ team |
| D-CY4-ADMIN-PUSH | `admin/` `dev push origin/dev` | User did not select option C at the cycle-4 deploy gate. The AZ-513 task spec sits locally on `admin/` `dev` (since cycle 3). | User |
## Carry-forward from cycles 2 and 3
Cycle 2's `deploy_planning_sync_cycle2.md` deferred 3 items to leftovers in `_docs/_process_leftovers/2026-05-12_az-498-deploy-and-key-revocations.md`. Cycle 3 did not close any of them. Cycle 4 also did not close them:
| ID (origin) | Item | Status as of 2026-05-13 (cycle 4 close) |
|----|------|-------|
| L-AZ-498-DEPLOY | UI tile-swap prod cutover | Still deferred — cross-workspace satellite-provider gate unchanged. **Will compound with cycle-3 + cycle-4 stage/prod deferrals** when finally promoted. |
| L-AZ-499-OWM-REVOKE | OpenWeatherMap key revocation at owm dashboard | Still pending — manual third-party action; owner: user. |
| L-AZ-501-GOOGLE-REVOKE | Google Geocode key revocation at Google Cloud Console | Still pending — manual third-party action; owner: user. |
| L-AZ-512-ADMIN-PREREQ | AZ-513 implementation + ship in `admin/` workspace | Re-opened cycle 4 under user-authorized Option B. UI implementation now landed; gate stays open until admin/ AZ-513 ships AND deploys. |
These leftovers need a status sweep at the start of the next `/autodev` invocation per `tracker.mdc` Leftovers Mechanism.
## Cycle-4 deployment-doc deltas (NOT written this cycle)
In strict autodev terms, Step 16 in this cycle was a real cutover (option A), not a planning sync. The cycle-2 pattern of patching `_docs/02_document/deployment/*` was therefore skipped here because:
- AZ-512 introduced **no** changes to Dockerfile, `.woodpecker/`, env vars, or `nginx.conf` (verified inline during Step 14 security audit; the cycle-4 delta `security_report_cycle4_delta.md` enumerates the changed files).
- AZ-512's only wire-shape change is one new HTTP method on an existing URL (`PATCH /api/admin/classes/{id}` — already routed to `admin/` by `nginx.conf` since cycle 2 because `DELETE /api/admin/classes/{id}` was already proxied through the same route block).
- No new env vars, no new container, no new exposed port.
If a future cycle adds env vars, infra changes, or new services, the cycle-2 planning-sync pattern (update `environment_strategy.md`, `ci_cd_pipeline.md`, `containerization.md`, `observability.md`) should be applied.
## Verification
- `git push origin dev` for `ui/` returned `09449bd..8737491 dev -> dev` (4 commits, fast-forward).
- `git status -sb` for `ui/` confirms `dev` and `origin/dev` are synced post-push (no `[ahead N]`).
- Functional test suite green pre-push (243 passed, 13 quarantined skips, 0 failed — see `test-output/summary.csv` and `test-output/fast-report.xml`). Up +12 vs cycle 3 from the new `tests/admin_class_edit.test.tsx` suite.
- Static perf NFT-PERF-01 green pre-push (291 332 B gzipped vs ≤ 2 097 152 B threshold — see `test-output/performance-summary.txt` and `_docs/06_metrics/perf_2026-05-13_cycle4.md`).
- Security cycle-4 delta verdict PASS_WITH_WARNINGS pre-push (see `_docs/05_security/security_report_cycle4_delta.md`).
- No nginx/Docker/CI config changes in cycle 4.
- Cross-workspace deploy gate (AZ-513) explicitly acknowledged and re-recorded in this report and in the leftover entry. The deployed UI on `ui/` dev will return `admin.classes.updateFailed` on real PATCH attempts until `admin/` AZ-513 ships — by design under user-authorized Option B.
## Cycle-3 → cycle-4 push-scope progression
Cycle 3 deploy gate: user picked option A (ui/ dev only). Cycle 4 deploy gate: user picked option A again (ui/ dev only). The same trade-off applies — stage/prod cutover is being collected for a single later promotion. Two consecutive cycles of dev-only pushes means the eventual stage promotion will batch AZ-510 + AZ-511 + AZ-512 deltas into one stage build, with the additional gate that AZ-513 must have shipped in admin/ by that time (otherwise the AZ-512 edit feature renders but cannot complete saves).
## Auto-chain
→ Step 17 (Retrospective) for cycle 4.
+192
View File
@@ -0,0 +1,192 @@
# Retrospective — 2026-05-13 (Phase B Cycle 4)
**Mode**: cycle-end (autodev existing-code Step 17)
**Scope**: Phase B, cycle 4 (`state.cycle = 4`)
**Epic / theme**: AZ-509 (UI workspace cycle 3 epic, continued) — single carry-over task AZ-512 reactivated under user-authorized Option B after cycle 3's Cross-Workspace Verification BLOCKING gate
**Cycle duration**: 1 batch (batch 16) over 1 working day (2026-05-13)
**Previous retro**: `_docs/06_metrics/retro_2026-05-13_cycle3.md` (cycle 3, same calendar day)
## Implementation Summary
| Metric | Value | Δ vs cycle 3 |
|--------|-------|--------------|
| Tasks attempted | 1 (AZ-512) | 2 |
| Tasks delivered | 1 (AZ-512) | 1 (cycle 3 shipped 2, deferred 1) |
| Tasks deferred at spec gate | 0 (the only deferral was the cycle-3 carry, already reactivated this cycle) | 1 |
| Total batches | 1 (batch 16) | 2 |
| Total complexity points planned | 3 | 6 |
| Total complexity points delivered | 3 | 3 |
| Avg tasks per batch | 1 | 0 |
| Avg complexity per (completed) batch | 3 | 0 |
| Source files mutated | 5 production + test + 1 component-doc + 5 cross-cutting docs | n/a (different shape from cycle 3) |
| Cycle shape | Single-task reactivation cycle — user explicitly overrode the cycle-3 conservative-path default | new pattern |
Sources: `batch_16_cycle4_report.md`, `implementation_report_admin_class_edit_cycle4.md`, `deploy_cycle4_report.md`, `security_report_cycle4_delta.md`, `perf_2026-05-13_cycle4.md`, `structure_2026-05-13_cycle4.md`.
## Quality Metrics
### Code Review Results
| Verdict | Count | Percentage | Δ vs cycle 3 |
|---------|-------|-----------|--------------|
| PASS (inline self-review per batch report) | 1 (batch 16) | 100 % | +1 (cycle 3 had 2 PASS) |
| PASS_WITH_WARNINGS | 0 | 0 % | 0 |
| FAIL | 0 | 0 % | 0 |
| (no review — deferred at gate) | 0 | 0 % | 1 |
Note: batch 16 used inline self-review (3-point single-task batch). A formal `/code-review` skill run is scheduled for batch 18 (cumulative-review cadence is every K=3 batches).
### Findings by Severity (code review only)
| Severity | Count | Δ vs cycle 3 |
|----------|-------|--------------|
| Critical | 0 | 0 |
| High | 0 | 0 |
| Medium | 0 | 0 |
| Low | 0 | 0 |
### Findings by Category (code review)
All zero (cycle 3 was also all-zero). No new pattern.
### Security-Audit Findings (Step 14 — cycle 4 delta against cycle 3)
| Status change | Count | Notable IDs |
|---------------|-------|-------------|
| Closed | 0 | — |
| Newly introduced (LOW) | 1 | F-SAST-CY4-1 — lost-update / mid-air-collision on `PATCH /api/admin/classes/{id}` (by design per AZ-512 spec; promotes to a UI ticket only when AZ-513 lands and the backend's concurrency model is known) |
| Carried forward unchanged | 12 | F-SAST-1 (HIGH, git-history), F-SAST-CY3-1 (LOW, test-only barrel export), F-SAST-2/3/4, F-INF-1..5 |
**Security verdict trajectory**: cycle 3 PASS_WITH_WARNINGS → cycle 4 **PASS_WITH_WARNINGS** (unchanged). `bun audit` re-run clean. No OWASP category status flipped.
## Structural Metrics
Source: `_docs/06_metrics/structure_2026-05-13_cycle4.md` (this cycle), compared against `structure_2026-05-13.md` (cycle 3 close).
| Metric | Cycle 2 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|--------|---------------|---------------|---------------|--------------|
| Component count | 12 | 12 | 12 | 0 |
| Public-API barrels | 11 / 11 | 11 / 11 | 11 / 11 | 0 |
| STC-ARCH-01 carve-out exemptions | 1 | 0 | 0 | 0 (held at zero) |
| Commit-time static gates | 33 / 33 PASS | 33 / 33 PASS | 33 / 33 PASS | 0 |
| Architecture cycles | 0 | 0 | 0 | 0 |
| Architecture findings open (baseline F1F9) | 7 of 9 | 6 of 9 | 6 of 9 | 0 |
| Newly introduced architecture violations | 0 | 0 | 0 | 0 |
| Net architecture delta this cycle | 0 | 1 | **0** | reverted to net-zero |
| Wire-contract assertions (`endpoints.test.ts`) | 36 | 37 | 37 | 0 (AZ-512 reused `endpoints.admin.class(id)`) |
| Fast-profile suite | 229 PASS / 13 SKIP / 0 FAIL | 231 PASS / 13 SKIP / 0 FAIL | **243 PASS / 13 SKIP / 0 FAIL** | **+12 PASS**, 0 SKIP |
| Bundle (gzipped initial JS) | 290 465 B | 290 575 B | **291 332 B** | +757 B (+0.26 %; ~13.89 % budget) |
### Auto-lesson triggers (per skill Step 1)
- Net Architecture delta > 0? **No** — delta is 0.
- Structural metric regression > 20 %? **No** — every structural metric held; test count +5.2% (improvement); bundle +0.26% (well within budget).
- Contract coverage % decreased? **No** — wire-contract assertions held at 37.
- New finding category emerged? **No** — security audit ran in delta mode; categories stable.
**Zero auto-lesson triggers fired.** Manual lessons (3 picked) appear in §LESSONS Append below.
## Efficiency
| Metric | Value | Δ vs cycle 3 |
|--------|-------|--------------|
| Blocked tasks (cycle-internal) | 0 | 0 |
| Tasks deferred to backlog at spec gate | 0 | 1 (the cycle-3 deferral was the one reactivated here) |
| Cross-workspace prerequisite tickets filed | 0 | 1 (AZ-513 already filed in cycle 3) |
| Pre-existing bugs surfaced as side observations | 1 (MSW `/api/admin/users` paginated vs `AdminPage.tsx` flat-array expectation) | 0 |
| Tasks pending external user action (cycle-4 close) | **9** | +2 vs cycle 3's 7 |
| Tasks requiring fixes after review | 0 | 0 |
| Batch with most findings | none — 0 findings cycle-wide | n/a |
| Auto-fix loops invoked | 0 | 0 |
| Stuck-agent incidents | 0 | 0 |
| Unplanned implementation-time test stabilization loops | 1 — selector-target fix in `destructive_ux.test.tsx` after the ✎ button was inserted before `×` | 3 (cycle 3 had 4 for AZ-510's module-scoped state ripple) |
### Blocker Analysis
| Blocker Type | Count | Prevention |
|--------------|-------|-----------|
| Pre-existing bug surfaced during test writing | 1 | New cycle-4 lesson: when a new test mounts the full container component, run it once *without* defensive fixture overrides and let the natural crashes surface latent fixture-vs-source drift, then either fix or document — never silently work around. See Improvement Action #3. |
| Selector regression in adjacent test from new affordance | 1 | New cycle-4 lesson: adding a new control to a DOM row that already holds existing controls requires auditing the test corpus for selectors like `querySelector('button')` or `getByRole('button')` without disambiguation. See Improvement Action #2. |
| Cycle-3 deferred deploy items (carry) | 3 (D-CY3-STAGE/MAIN/ADMIN-PUSH) | Still not actioned. Cycle 4 added 3 more deploy-deferred items (D-CY4-STAGE/MAIN/ADMIN-PUSH). Compounding. |
| Cross-workspace deploy gate (carry from cycles 2 and 3) | 4 (L-AZ-498-DEPLOY, L-AZ-499-OWM-REVOKE, L-AZ-501-GOOGLE-REVOKE, L-AZ-512-ADMIN-PREREQ — last one re-opened cycle 4) | Same as cycle-3 retro Action #3 — drain mechanism still not implemented. |
### User-action backlog at cycle close
| Category | Count | Items |
|----------|-------|-------|
| Manual third-party console action | 2 | L-AZ-499-OWM-REVOKE, L-AZ-501-GOOGLE-REVOKE (carry from cycle 2) |
| Cross-workspace deploy gate (satellite-provider) | 1 | L-AZ-498-DEPLOY (carry from cycle 2) |
| Cross-workspace prerequisite ticket awaiting sibling-team work | 1 | AZ-513 implementation on `admin/` (re-opened cycle 4 under user-authorized Option B) |
| Cycle deploy-push pending | 5 | D-CY3-STAGE, D-CY3-MAIN, D-CY3-ADMIN-PUSH (carry); D-CY4-STAGE, D-CY4-MAIN, D-CY4-AZ513-IMPL — note the cycle-4 AZ-513 deploy gate is the same item as the cross-workspace prereq above when counted only once (de-dup) |
| **Total (de-duplicated)** | **9** | (cycle 1 close: 0 → cycle 2 close: 3 → cycle 3 close: 7 → cycle 4 close: **9**) |
> Trajectory continues: 0 → 3 → 7 → **9**. Net growth +2 this cycle (cycle 4 added D-CY4-STAGE + D-CY4-MAIN; AZ-513 re-opened as `cross-workspace prerequisite`; D-CY4-ADMIN-PUSH was carried-not-added because the user kept the same dev-only push scope as cycle 3). Cycle-3 retro Improvement Action #3 (track backlog as first-class metric) is now being applied — but the drain mechanism (step-0 sweep that closes items, not just notices them) is still pending. **Backlog growth is decelerating** (+3, +4, **+2**); even so, the gap between accumulated and drained remains the dominant signal.
### User-decision points (cycle 4 only)
- Cycle-4 entry: user **explicitly overrode** the cycle-3 conservative-path default for AZ-512 ("implement 512, 513 would be implemented in minutes. You can write mocks for backend data anyway for testing."). The spec was updated to record this as user-authorized Option B; the leftover entry was re-opened with the Option-B rationale. This is the first cross-cycle override of a spec-conservative default in cycles 1-4.
- Step 13 → Steps 14+15 gate: user chose **D** (run both Security Audit AND Performance Test) — first time across cycles 1-4 that BOTH optional gates ran inline. Cycle 3 also ran both via auto-chain but Step 15 emitted no separate report; cycle 4 produced standalone `perf_2026-05-13_cycle4.md` for the first time.
- Cycle-4 Step 16 deploy gate: user chose **A** (push to ui/ dev only) — same option as cycle 3. Stage / main / admin/ dev push deferred.
## Trend Comparison
| Trend | Cycle 1 | Cycle 2 | Cycle 3 | Cycle 4 | Direction |
|-------|---------|---------|---------|---------|-----------|
| Code review pass rate (formally-reviewed batches) | 100 % | 50 % | 100 % | 100 % (self-review) | held |
| Test count (cumulative this cycle delta) | +46 | +20 | +2 | **+12** | rebounded from cycle-3 low |
| Static gate count | +2 | +2 | 0 | **0** | held |
| Architecture findings open (baseline) | 7 (2) | 7 (0) | 6 (1) | **6 (0)** | held flat |
| STC-ARCH-01 exemptions | 1 | 1 | 0 | **0** | held at zero |
| Wire-contract assertions | 36 | 36 | 37 (+1) | **37 (0)** | held |
| Pending USER actions at cycle close | 0 | 3 | 7 | **9** | ⬆ still growing (rate decelerating) |
| Tasks deferred to backlog at spec gate | 0 | 0 | 1 | **0** | reverted (the cycle-3 deferral was the one reactivated) |
| Cycles where user overrode a spec-conservative default | 0 | 0 | 0 | **1** (AZ-512 Option B) | new pattern |
| Bundle (gzipped initial JS, B) | — | 290 465 | 290 575 (+110) | **291 332 (+757)** | growing in line with feature delta; far within budget |
Cycle 4 is the first single-task reactivation cycle (vs cycle 3's three-task fresh cycle). The cycle-3 retro called out that the AZ-512 gate worked as designed; cycle 4 confirms the *other half* of the design: a user-authorized override path can flow through the entire 9→17 step sequence without regressions, while preserving the deploy gate. Both halves of the gate design are now field-validated.
## Top 3 Improvement Actions
1. **Codify a "pre-existing-bug surface lifting" routine — observe-then-document, never silently work-around.**
While writing `tests/admin_class_edit.test.tsx`, I discovered the `/api/admin/users` MSW handler's paginated response vs `AdminPage.tsx`'s flat-array expectation by hitting a `users.map is not a function` render crash. The route taken was: document in batch_16_cycle4_report.md "Pre-existing bug noted", apply a local workaround (`stubUsersAsPlainArray()` in `beforeEach`), and recommend filing a separate UI-workspace ticket. This was the right tactical move, but the **systematic routine** is missing — there's no checklist anywhere that says "when a new test mounts a container component, run it once with default fixtures only, name any crashes, and decide explicitly fix-now vs document-and-defer." Without that routine, future cycles will keep accumulating quiet local workarounds and the side-observed bug list grows without a tracking artifact.
- Impact: medium — the failure mode (silent test-fixture overrides masking real source bugs) is the test-side analog of "client-side validation only" — looks green, but tested against a fake. Two distinct cycles (3 and 4) already surfaced one bug each through this route.
- Effort: low — add a section "Pre-existing-bug surfacing during test writing" to `_docs/02_tasks/_templates/module_scoped_state_introduction.md` (created in cycle 3) and to the implementation skill's batch-report template; require the batch report to either list "Pre-existing bug noted" entries or affirm "None observed; ran with default fixtures only".
2. **Audit test selectors that pick "the button" / "the link" / "the input" without disambiguation, before adding a new affordance to an existing DOM region.**
The cycle-4 ✎ edit button was inserted into the same `<td>` that holds the `×` delete button. `tests/destructive_ux.test.tsx` had three call sites using `firstRow.querySelector('button')` — each silently rebound to the new ✎ button instead of the old `×` button, and the tests would have shipped green-but-meaningless if not caught by the test run. The fix was 1-line per site (`Array.from(...).find(b => b.textContent === '×')`). The deeper lesson is that the failure mode is **invisible at code-review time** — a code reviewer reading the source diff has no view into which test selectors will resolve to which DOM element, only the test run reveals it. The cheap structural prevention: before adding a new control to a DOM region, grep the test corpus for `querySelector('button|input|a')` / `getByRole('button')` without name/text disambiguation, in the same file / sibling files, and add disambiguating selectors *in the affordance batch*.
- Impact: medium — saves one stabilization loop per affordance addition; the cost of NOT catching it is silent test-meaning-drift in destructive-UX assertions, which is exactly the kind of bug Finding B4 (cycle 1) was filed for.
- Effort: low — add a 3-bullet checklist to the implement skill's "Adjacent hygiene" rules: (a) before inserting a new button/input into an existing row/region, grep for non-disambiguated selectors targeting that region; (b) update them in the same commit; (c) if you can't make them disambiguated without changing the source DOM, prefer giving the new control a stable `data-testid` over rewriting test selectors.
3. **Add a "user-action backlog drain rate" to the retrospective metric set.**
Cycle 3 retro added "user-action backlog at cycle close" as an absolute count. Cycle 4 now has two consecutive data points (7 → 9). The signal in absolute count is being applied — but the signal that matters for process-shape is the **drain rate**: how many items got closed *this* cycle vs how many got added? Cycle 4: 1 item state-transitioned (L-AZ-512-ADMIN-PREREQ moved from "deferred awaiting AZ-513" to "re-opened under Option B"; technically still open), +2 net new (D-CY4-STAGE, D-CY4-MAIN). So drain = 0, add = 2, net = +2. Tracking drain explicitly will make the drain-mechanism conversation concrete — today the retro just says "backlog is +2, drain mechanism still pending" with no metric to optimize.
- Impact: medium — operationalizes cycle-3 Action #3. Makes the drain-mechanism design (which is presumably a step-0 sweep that closes items, not just notices them) measurable from the first cycle it runs.
- Effort: low — extend `.cursor/skills/retrospective/SKILL.md` Step 1 metric collection with a "User-action backlog drain rate" subsection (count of items added this cycle vs items closed this cycle vs net change vs absolute close-count), and add to the retrospective-report template.
## Suggested Rule / Skill Updates
| File | Change | Rationale |
|------|--------|-----------|
| `.cursor/skills/implement/SKILL.md` (Adjacent Hygiene section) | Add the 3-bullet "test selector audit" checklist for inserting a new control into an existing DOM region. | §Top 3 Improvement Action #2. |
| `.cursor/skills/implement/templates/batch_report.md` (if it exists) or `_docs/02_tasks/_templates/module_scoped_state_introduction.md` | Add "Pre-existing-bug surfacing during test writing" subsection requirement: batch report must explicitly list observed pre-existing bugs OR affirm "None observed; ran with default fixtures only". | §Top 3 Improvement Action #1. |
| `.cursor/skills/retrospective/SKILL.md` (Step 1 metrics) | Add **"User-action backlog drain rate"** metric: items added this cycle / items closed this cycle / net delta / absolute close-count; track alongside the absolute count introduced in cycle 3. | §Top 3 Improvement Action #3. |
| `.cursor/skills/retrospective/templates/retrospective-report.md` | Add a "User-action backlog drain rate" sub-table alongside the absolute table under Efficiency. | §Top 3 Improvement Action #3. |
| `_docs/LESSONS.md` (top) | Append the 3 lessons in §LESSONS Append below; trim to ≤ 15 entries. | Skill Step 4. |
## Notes — Step 16 outcome
Step 16 (Deploy) ran in **real-cutover mode (option A)** for the second consecutive cycle. Push scope was ui/ `dev` only (4 commits, fast-forward `09449bd..8737491`). The cycle-3 closure commit `eef3bdf` (which had been locally ahead since cycle 3's push) shipped this cycle alongside cycle-4's three commits. Stage / main / admin/ `dev` pushes were deferred at the push-scope sub-gate (user chose option A — ui/ dev only).
- Devices will not auto-pull cycle-3 + cycle-4 changes until `dev → stage → main` completes (D-CY4-STAGE, D-CY4-MAIN).
- AZ-513 task spec still sits locally on `admin/` `dev` — admin/ team cannot pick it up until D-CY3-ADMIN-PUSH lands (now carried into cycle 5).
- No Dockerfile / `.woodpecker/` / nginx / env changes in cycle 4 — verified inline by the security audit (Step 14 enumerated the changed-file set as 6 source/test + 5 doc files only).
- The deployed ui/ dev build will surface `admin.classes.updateFailed` on real edits until AZ-513 ships in admin/ — by design under the user-authorized Option B path.
These items add to the user-action backlog; see §Efficiency → User-action backlog table.
## LESSONS Append (top 3, single-sentence, tagged)
1. **[testing]** When inserting a new control (button, input, link) into an existing DOM row or region that already holds other controls, audit the test corpus *before* the commit for non-disambiguated selectors targeting that region (`querySelector('button')`, `getByRole('button')` without `name`/`text`, indexed `querySelectorAll('button')[0]`) and either update them with disambiguating text/role/name in the same affordance commit or give the new control a stable `data-testid` — otherwise the new control silently rebinds existing assertions to the wrong element and the tests ship green-but-meaningless, exactly as cycle 4's `destructive_ux.test.tsx` did when the AZ-512 ✎ button became the new first button in the class-row action cell.
2. **[testing]** When a new test mounts a container component end-to-end, run it once with the project's default test fixtures only (no per-test override) and explicitly name any natural crashes ("`users.map is not a function`") in the batch report as "Pre-existing bug noted" — never silently apply a local fixture workaround without recording the latent drift, because each silent workaround hides a source-vs-fixture mismatch that future authors will re-encounter as a "mysterious test setup", and cycle 4's `tests/admin_class_edit.test.tsx` was the second cycle to surface one through this route.
3. **[process]** When the user explicitly overrides a spec-conservative cycle-defer decision (the AZ-512 Option B authorization: "implement now, write mocks for backend"), the autodev MUST preserve every downstream gate that the conservative path would have enforced — re-record the override rationale in the leftover entry, keep the cross-workspace deploy gate visible at Step 16, mark the carried tickets distinctly from cycle-internal carries, and surface the override as a first-class retrospective trend ("Cycles where user overrode a spec-conservative default") — so the operating cost of the override stays measurable and the user's downstream visibility is unchanged from the conservative path.
@@ -0,0 +1,95 @@
# Structural Snapshot — 2026-05-13 (Phase B Cycle 4 close)
**Cycle**: Phase B, cycle 4 (`state.cycle = 4`)
**Source-of-truth files**: `_docs/02_document/module-layout.md`, `_docs/02_document/architecture_compliance_baseline.md`, `scripts/check-arch-imports.mjs`, `scripts/run-tests.sh`, `src/api/endpoints.test.ts`.
**Previous snapshot**: `_docs/06_metrics/structure_2026-05-13.md` (Phase B cycle 3 close).
> Cycle 4 was a single-task, contained UI-feature cycle (AZ-512 admin class inline edit). It introduced **no new components**, **no new gates**, **no new barrels**, **no new wire-contract assertions**, and **no new architecture findings**. The structural snapshot is therefore a near-identity copy of cycle 3 close with two non-structural deltas: test count (+12) and bundle size (+757 B).
## Component Inventory
| Metric | Cycle 1 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|--------|---------------|---------------|---------------|--------------|
| Component count | 12 | 12 | 12 | 0 |
| Components with Public API barrels | 11 | 11 | 11 | 0 |
| Barrel coverage (eligible components) | 100 % | 100 % | 100 % | 0 |
| Documented feature→feature edges (grandfathered) | 1 | 1 | 1 | 0 |
| Documented STC-ARCH-01 carve-out exemptions | 1 | 0 | 0 | 0 (held at zero) |
| Cycles in component import graph | 0 | 0 | 0 | 0 |
## Architecture Gates (cycle 4 close)
| Gate | Added in | Enforces | Status (cycle 4 close) |
|------|----------|----------|------------------------|
| `STC-ARCH-01` | Cycle 1 / AZ-485 | No cross-component deep imports; barrels are the Public API | PASS (zero exemptions — held since cycle 3) |
| `STC-ARCH-02` | Cycle 1 / AZ-486 | No hardcoded `/api/<service>/...` literals in production source | PASS |
| `STC-SEC1C` | Cycle 2 / AZ-499 | Banned literal: OpenWeatherMap key | PASS |
| `STC-SEC1D` | Cycle 2 / AZ-501 | Banned literal: Google Geocode key | PASS |
| `FT-P-22` (key parity) | (i18n coverage gate) | `en.json``ua.json` key parity | PASS (extended cycle 4: covers `admin.classes.{title,edit,save,cancel,nameRequired,maxSizeMustBePositive,updateFailed}`) |
| `FT-P-23` (no hardcoded strings) | (i18n coverage gate) | No raw English strings outside i18n bundles | PASS (the aria-label-as-hardcoded-English failure during cycle-4 implementation was caught by this gate and fixed before commit — see batch_16_cycle4_report.md "Pre-existing bug noted") |
Total commit-time static gates: **33** (cycle 3 close = 33; cycle 4 close = 33 — no new gates this cycle, **all existing gates green**).
## Architecture Baseline Delta vs `architecture_compliance_baseline.md`
| Finding | Category | Cycle 1 close | Cycle 2 close | Cycle 3 close | Cycle 4 close |
|---------|----------|---------------|---------------|---------------|---------------|
| F1 — mission-planner vs flights duplication | Architecture | Open | Open | Open | Open |
| F2 — cross-feature edge `07_dataset → 06_annotations` | Architecture | Open (grandfathered) | Open | Open | Open |
| F3 — classColors physical/logical owner split | Architecture | Open | Open | RESOLVED (AZ-511) | RESOLVED |
| F4 — No Public API barrels | Architecture | RESOLVED (AZ-485) | RESOLVED | RESOLVED | RESOLVED |
| F5 — Pre-existing cycle inside `mission-planner` | Architecture | Open | Open | Open | Open |
| F6 — No `src/shared/` | Architecture | Open | Open | Open | Open |
| F7 — Hardcoded `/api/<service>/` literals | Architecture | RESOLVED (AZ-486) | RESOLVED | RESOLVED | RESOLVED |
| F8 — Layering-table inconsistency | Architecture | Open | Open | Open | Open |
| F9 — Inert second Vite entry tree | Architecture | Open | Open | Open | Open |
- **Resolved this cycle**: 0
- **Newly introduced this cycle**: 0
- **Architecture findings open at cycle 4 close**: 6 of 9 baseline (F1, F2, F5, F6, F8, F9) — unchanged
- **Net architecture delta cycle 4**: 0 (no movement)
## Contract Coverage
- `_docs/02_document/contracts/` does NOT exist; project uses **code-derived contracts pattern** via `src/api/endpoints.test.ts`.
- Wire-contract assertions count: cycle 1 = 36, cycle 2 = 36, cycle 3 = 37, cycle 4 = **37** (no change — AZ-512 reused the existing `endpoints.admin.class(id)` builder for PATCH; no new builder introduced per task constraint).
## Test Suite Snapshot
| Profile | Cycle 1 close | Cycle 2 close | Cycle 3 close | Cycle 4 close | Δ vs cycle 3 |
|---------|---------------|---------------|---------------|---------------|--------------|
| Fast (count) | 209 PASS / 13 SKIP / 0 FAIL | 229 PASS / 13 SKIP / 0 FAIL | 231 PASS / 13 SKIP / 0 FAIL | **243 PASS / 13 SKIP / 0 FAIL** | **+12 PASS**, 0 SKIP |
| Static (gates) | 31 / 31 PASS | 33 / 33 PASS | 33 / 33 PASS | 33 / 33 PASS | 0 |
| Build | green | green | green | green | 0 |
| Bundle (gzipped initial JS) | not measured | 290 465 B | 290 575 B | **291 332 B** | **+757 B** (+0.26 %) |
The +12 PASS comes from `tests/admin_class_edit.test.tsx` (the entire AZ-512 suite). No other test files changed counts; `tests/destructive_ux.test.tsx`'s selector fix kept its existing 6 cases (2 fixed, 4 carried).
The +757 B bundle delta is explained at byte-level in `_docs/06_metrics/perf_2026-05-13_cycle4.md` (~500600 B from the new `AdminPage` handlers + JSX, ~150200 B from 7 i18n keys × 2 locales).
## Cycle 4 Source-of-Truth Mutations
| File / area | Mutation | Driver |
|-------------|----------|--------|
| `src/features/admin/AdminPage.tsx` | Inline edit state (4 hooks), 4 new handlers, conditional colspan row JSX, pencil affordance, `t('admin.classes')``t('admin.classes.title')` | AZ-512 |
| `src/i18n/en.json`, `src/i18n/ua.json` | `admin.classes` flat string → nested object (`title` + 6 keys for edit UI in both locales) | AZ-512 |
| `tests/msw/handlers/admin.ts` | New `http.patch('/api/admin/classes/:id', ...)` partial-merge handler | AZ-512 (test infra) |
| `tests/admin_class_edit.test.tsx` | NEW — 12 tests covering AC-1..AC-6, AC-8 | AZ-512 |
| `tests/destructive_ux.test.tsx` | Selector fix at 3 call sites (`querySelector('button')``Array.from(...).find(b => b.textContent === '×')`) | Adjacent hygiene from AZ-512 |
| `_docs/02_document/components/08_admin/description.md` | Edit affordance + PATCH wiring recorded | AZ-512 (spec-authorized) |
| `_docs/02_document/architecture.md` (row 272) | `08_admin/AdminPage` row gains PATCH /api/admin/classes/{id} with AZ-513 deploy-gate caveat | Step 13 (Update Docs) |
| `_docs/02_document/modules/src__features__admin__AdminPage.md` | Header cycle-4 banner; new state slots, four new handlers, layout note, PATCH integrations row, expanded i18n key list, Tests section | Step 13 (Update Docs) |
| `_docs/02_document/tests/blackbox-tests.md` | Added FT-P-62, FT-N-18 | Step 12 (Test-Spec Sync) |
| `_docs/02_document/tests/traceability-matrix.md` | O9 → Covered; references FT-P-62 + FT-N-18 + AZ-513 deploy gate | Step 12 (Test-Spec Sync) |
| `_docs/05_security/security_report_cycle4_delta.md` | NEW — cycle-4 delta; verdict PASS_WITH_WARNINGS; one new LOW finding (F-SAST-CY4-1) | Step 14 (Security Audit) |
| `_docs/05_security/security_report.md` | Cycle-4 amendment banner | Step 14 (Security Audit) |
| `_docs/06_metrics/perf_2026-05-13_cycle4.md` | NEW — NFT-PERF-01 PASS at 291 332 B | Step 15 (Performance Test) |
## Auto-lesson triggers (per skill Step 1)
- Net Architecture delta > 0? **No** — delta is 0. No `architecture` lesson trigger.
- Structural metric regression > 20%? **No** — every structural metric held at its cycle-3 value, except test count (+5.2%) and bundle (+0.26%), both improvements / within-budget.
- Contract coverage % decreased? **No** — same 37 assertions (no builder added, no builder removed).
- New finding category emerged? **No** — security audit ran in delta mode; categories are stable.
No auto-lesson triggers fired in cycle 4. Manual lessons (3 picked) appear in the retro report.
+36
View File
@@ -8,6 +8,42 @@ Categories: estimation · architecture · testing · dependencies · tooling ·
---
- [2026-05-13] [testing] When inserting a new control (button, input, link)
into an existing DOM row or region that already holds other controls, audit
the test corpus *before* the commit for non-disambiguated selectors
targeting that region (`querySelector('button')`, `getByRole('button')`
without `name`/`text`, indexed `querySelectorAll('button')[0]`) and either
update them with disambiguating text/role/name in the same affordance
commit or give the new control a stable `data-testid` — otherwise the new
control silently rebinds existing assertions to the wrong element and the
tests ship green-but-meaningless, as cycle 4's `destructive_ux.test.tsx`
did when the AZ-512 ✎ button became the new first button in the class-row
action cell.
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
- [2026-05-13] [testing] When a new test mounts a container component
end-to-end, run it once with the project's default test fixtures only (no
per-test override) and explicitly name any natural crashes ("`users.map is
not a function`") in the batch report as "Pre-existing bug noted" — never
silently apply a local fixture workaround without recording the latent
drift, because each silent workaround hides a source-vs-fixture mismatch
that future authors will re-encounter as a "mysterious test setup", and
cycle 4's `tests/admin_class_edit.test.tsx` was the second cycle to
surface one through this route.
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
- [2026-05-13] [process] When the user explicitly overrides a
spec-conservative cycle-defer decision (the AZ-512 Option B authorization:
"implement now, write mocks for backend"), the autodev MUST preserve every
downstream gate that the conservative path would have enforced — re-record
the override rationale in the leftover entry, keep the cross-workspace
deploy gate visible at Step 16, mark the carried tickets distinctly from
cycle-internal carries, and surface the override as a first-class
retrospective trend ("Cycles where user overrode a spec-conservative
default") — so the operating cost of the override stays measurable and
the user's downstream visibility is unchanged from the conservative path.
Source: _docs/06_metrics/retro_2026-05-13_cycle4.md
- [2026-05-13] [process] When a task spec defines a Cross-Workspace Verification
BLOCKING gate and the user skips the choice prompt, the autodev MUST default
to the most conservative spec-aligned option (Option A: file prerequisite
+12 -7
View File
@@ -2,19 +2,24 @@
## Current Step
flow: existing-code
step: 16
name: Deploy
step: 9
name: New Task
status: not_started
sub_step:
phase: 0
name: awaiting-invocation
detail: ""
retry_count: 0
cycle: 4
cycle: 5
tracker: jira
## Notes
- Cycle 4 batch 16 shipped (commit ecacfa8): AZ-512 — 3/3 pts. Jira: To Do → In Testing.
- Cross-workspace: AZ-513 on admin/ NOT shipped. Step 16 (Deploy) gates on it.
- Leftovers: `2026-05-12_az-498-deploy-and-key-revocations.md` (manual), `2026-05-13_az-512-admin-classes-prereq.md` (re-opened).
- Pre-existing bug surfaced during AZ-512: `/api/admin/users` MSW shape (paginated) vs `AdminPage` consumption (flat `User[]`) mismatch. Flagged in batch + impl reports; needs separate UI ticket triage.
- Cycle 4 CLOSED: AZ-512 — 3/3 pts. Jira: In Testing → Done. Retro `retro_2026-05-13_cycle4.md` + deploy report `deploy_cycle4_report.md` + perf `perf_2026-05-13_cycle4.md` + security delta `security_report_cycle4_delta.md` + structure snapshot `structure_2026-05-13_cycle4.md` written. Cycle-4 push: `09449bd..8737491` (4 commits) → origin/dev.
- Cycle 5 awaiting next `/autodev` New Task invocation.
- Leftovers carried into cycle 5 (replay at Step 0):
- `2026-05-12_az-498-deploy-and-key-revocations.md` — 3 manual third-party items (UI satellite-provider deploy gate; OWM revoke; Google Geocode revoke).
- `2026-05-13_az-512-admin-classes-prereq.md` — re-opened under user-authorized Option B; closes when AZ-513 ships AND deploys in admin/.
- Cross-workspace status: AZ-513 (admin/) still not implemented. UI's PATCH /api/admin/classes/{id} returns 404 in any env until admin/ ships AZ-513.
- User-action backlog at cycle-4 close (per retro): 9 items (de-duplicated). +2 vs cycle 3.
- Pre-existing bug surfaced during AZ-512: `/api/admin/users` MSW shape (paginated) vs `AdminPage` consumption (flat `User[]`). Awaiting separate UI-workspace ticket triage; pre-existing, not introduced by AZ-512.
- Cycle-3 deferred deploy items still carry: D-CY3-STAGE, D-CY3-MAIN, D-CY3-ADMIN-PUSH. Cycle 4 added: D-CY4-STAGE, D-CY4-MAIN (D-CY4-ADMIN-PUSH not added — user kept same ui/-dev-only scope).
+15 -13
View File
@@ -1,6 +1,6 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { AuthProvider, ProtectedRoute } from './auth'
import { Header, FlightProvider } from './components'
import { Header, FlightProvider, SavedAnnotationsProvider } from './components'
import { LoginPage } from './features/login'
import { FlightsPage } from './features/flights'
import { AnnotationsPage } from './features/annotations'
@@ -18,19 +18,21 @@ export default function App() {
element={
<ProtectedRoute>
<FlightProvider>
<div className="flex flex-col h-screen">
<Header />
<div className="flex-1 overflow-hidden">
<Routes>
<Route path="/flights" element={<FlightsPage />} />
<Route path="/annotations" element={<AnnotationsPage />} />
<Route path="/dataset" element={<DatasetPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/flights" replace />} />
</Routes>
<SavedAnnotationsProvider>
<div className="flex flex-col h-screen">
<Header />
<div className="flex-1 overflow-hidden">
<Routes>
<Route path="/flights" element={<FlightsPage />} />
<Route path="/annotations" element={<AnnotationsPage />} />
<Route path="/dataset" element={<DatasetPage />} />
<Route path="/admin" element={<AdminPage />} />
<Route path="/settings" element={<SettingsPage />} />
<Route path="*" element={<Navigate to="/flights" replace />} />
</Routes>
</div>
</div>
</div>
</SavedAnnotationsProvider>
</FlightProvider>
</ProtectedRoute>
}
+30
View File
@@ -16,11 +16,41 @@ export function useAuth() {
return useContext(AuthContext)
}
// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount),
// and the backend rotates the refresh cookie on every successful POST. Two
// concurrent bootstraps would race the rotation and leave the second one with
// a stale cookie. The module-scoped in-flight promise lets the second mount
// await the first's network round-trip instead of duplicating it. Risk 4 in
// AZ-510 spec.
let bootstrapInflight: Promise<AuthUser | null> | null = null
export function __resetBootstrapInflightForTests(): void {
bootstrapInflight = null
}
// Dev-only escape hatch: `VITE_DEV_AUTH_BYPASS=true` skips the backend round
// trip and injects a fake admin user so the SPA renders authenticated. Lives
// in this file so the bypass is gated by the same effect that owns auth state;
// the import.meta.env check is also tree-shaken out of production builds when
// the flag is unset at build time.
const DEV_BYPASS_USER: AuthUser = {
id: 'dev-bypass',
email: 'dev@azaion.local',
name: 'Dev Bypass',
role: 'admin',
// Permission codes are short identifiers checked via hasPermission(code) —
// currently used by the Header to gate the nav tabs (FL, ANN, DATASET, ADM).
permissions: ['FL', 'ANN', 'DATASET', 'ADM'],
}
async function runBootstrap(): Promise<AuthUser | null> {
// Gated on import.meta.env.DEV so a leaked VITE_DEV_AUTH_BYPASS=true in a
// production build cannot grant admin access. Vite tree-shakes the entire
// branch when DEV is false at build time.
if (import.meta.env.DEV && import.meta.env.VITE_DEV_AUTH_BYPASS === 'true') {
setToken('dev-bypass-token')
return DEV_BYPASS_USER
}
// POST refresh with credentials — the whole point of the consolidation. Goes
// through fetch() directly (not api.post) because api.post does not thread
// credentials:'include'; widening api.post would change CORS posture for
+8
View File
@@ -22,3 +22,11 @@ export function getClassNameFallback(classNum: number): string {
const base = classNum % 20
return FALLBACK_CLASS_NAMES[base % FALLBACK_CLASS_NAMES.length] ?? `#${classNum}`
}
export function hexToRgba(hex: string, alpha: number): string {
const h = hex.replace('#', '')
const r = parseInt(h.slice(0, 2), 16)
const g = parseInt(h.slice(2, 4), 16)
const b = parseInt(h.slice(4, 6), 16)
return `rgba(${r},${g},${b},${alpha})`
}
+1
View File
@@ -2,5 +2,6 @@ export {
getClassColor,
getPhotoModeSuffix,
getClassNameFallback,
hexToRgba,
FALLBACK_CLASS_NAMES,
} from './classColors'
+60 -34
View File
@@ -1,7 +1,5 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
import { FaRegSnowflake } from 'react-icons/fa'
import { api, endpoints } from '../api'
// classColors lives under 06_annotations until F3 moves it to its own home.
// Importing through the 06_annotations barrel would create a cycle
@@ -60,43 +58,71 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
}
}, [classes, photoMode, selectedClassNum, onSelect])
const modeClasses = classes.filter(c => c.photoMode === photoMode)
const modes = [
{ value: 0, label: t('annotations.regular'), icon: <MdOutlineWbSunny />, activeClass: 'bg-az-orange text-white', iconColor: 'text-az-orange' },
{ value: 20, label: t('annotations.winter'), icon: <FaRegSnowflake />, activeClass: 'bg-az-blue text-white', iconColor: 'text-az-blue' },
{ value: 40, label: t('annotations.night'), icon: <MdOutlineNightlightRound />, activeClass: 'bg-purple-600 text-white', iconColor: 'text-purple-400' },
{ value: 0, label: t('annotations.regular') },
{ value: 20, label: t('annotations.winter') },
{ value: 40, label: t('annotations.night') },
]
return (
<div className="border-t border-az-border p-2">
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.classes')}</div>
<div className="space-y-0.5 max-h-48 overflow-y-auto mb-2">
{classes.filter(c => c.photoMode === photoMode).map((c, i) => (
<button
key={c.id}
onClick={() => onSelect(c.id)}
className={`w-full flex items-center gap-1.5 px-1.5 py-0.5 rounded text-xs text-left ${
selectedClassNum === c.id ? 'bg-az-border text-white' : 'text-az-text hover:bg-az-bg'
}`}
>
<span className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: getClassColor(c.id) }} />
<span className="text-az-muted">{i + 1}.</span>
<span className="truncate">{c.name}</span>
<span className="text-az-muted ml-auto">{c.shortName}</span>
</button>
))}
<div className="border-t border-border-hair">
{/* Section header */}
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.classes')}</span>
<span className="mono text-[10px] text-text-muted">{modeClasses.length.toString().padStart(2, '0')}</span>
</div>
</div>
<div className="text-xs text-az-muted mb-1 font-semibold">{t('annotations.photoMode')}</div>
<div className="flex gap-1">
{modes.map(m => (
<button
key={m.value}
onClick={() => onPhotoModeChange(m.value)}
title={m.label}
className={`flex-1 flex items-center justify-center px-2 py-1 rounded text-base ${photoMode === m.value ? m.activeClass : `bg-az-bg ${m.iconColor} hover:brightness-125`}`}
>
{m.icon}
</button>
))}
{/* Column headers */}
<div className="grid grid-cols-[28px_1fr_auto] px-3 h-6 items-center border-b border-border-hair gap-2">
<span className="micro">{t('annotations.colNum')}</span>
<span className="micro">{t('annotations.colName')}</span>
<span className="micro">{t('annotations.colKey')}</span>
</div>
{/* Class rows */}
<div>
{modeClasses.map((c, i) => {
const isActive = selectedClassNum === c.id
return (
<div
key={c.id}
role="button"
tabIndex={0}
onClick={() => onSelect(c.id)}
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') onSelect(c.id) }}
className={`class-row${isActive ? ' active' : ''}`}
>
<span className="swatch" style={{ background: getClassColor(c.id) }} />
<span className={`truncate${isActive ? ' text-text-primary font-medium' : ' text-text-primary'}`}>
{c.name}
</span>
<span className="kbd">{i + 1}</span>
</div>
)
})}
</div>
{/* PhotoMode segmented control */}
<div className="p-3 border-t border-border-hair">
<div className="flex items-center justify-between mb-2">
<span className="micro">{t('annotations.photoMode')}</span>
</div>
<div className="seg" style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', width: '100%' }}>
{modes.map(m => (
<button
key={m.value}
type="button"
className={`seg-btn${photoMode === m.value ? ' active' : ''}`}
onClick={() => onPhotoModeChange(m.value)}
>
{m.label}
</button>
))}
</div>
</div>
</div>
)
+1
View File
@@ -3,3 +3,4 @@ export { default as HelpModal } from './HelpModal'
export { default as ConfirmDialog } from './ConfirmDialog'
export { default as DetectionClasses } from './DetectionClasses'
export { FlightProvider, useFlight } from './FlightContext'
export { SavedAnnotationsProvider, useSavedAnnotations } from './SavedAnnotationsContext'
+355 -72
View File
@@ -1,38 +1,108 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import { useResizablePanel } from '../../hooks'
import { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import { api, endpoints } from '../../api'
import MediaList from './MediaList'
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
import AnnotationsSidebar from './AnnotationsSidebar'
import Scrubber, { type ScrubberMark } from './Scrubber'
import { DetectionClasses, useFlight } from '../../components'
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
import { captureThumbnails } from './thumbnail'
import { formatTime, formatTicks, parseAnnotationTime } from './time'
import type { Media, AnnotationListItem, Detection } from '../../types'
const FRAME_STEPS = [1, 5, 10, 30, 60]
const FAKE_LOG_LINES = [
'[tile 04/16] 2 candidates',
'[tile 05/16] 1 candidate (conf 0.94)',
'[filter] min_conf=0.25…',
]
export default function AnnotationsPage() {
const { t } = useTranslation()
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [annotations, setAnnotations] = useState<AnnotationListItem[]>([])
const [selectedAnnotation, setSelectedAnnotation] = useState<AnnotationListItem | null>(null)
const [selectedClassNum, setSelectedClassNum] = useState(0)
const [photoMode, setPhotoMode] = useState(0)
const [detections, setDetections] = useState<Detection[]>([])
const leftPanel = useResizablePanel(250, 200, 400)
const rightPanel = useResizablePanel(200, 150, 350)
const [zoom, setZoom] = useState(1)
const [cursor, setCursor] = useState<{ x: number; y: number } | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [volume, setVolume] = useState(0.62)
const [muted, setMuted] = useState(false)
const [aiDetecting, setAiDetecting] = useState(false)
const [aiLog, setAiLog] = useState<string[]>([])
const [aiProgress, setAiProgress] = useState(0)
const aiStartRef = useRef<number>(0)
const aiCloseTimerRef = useRef<number | null>(null)
const [aiElapsed, setAiElapsed] = useState(0)
const videoPlayerRef = useRef<VideoPlayerHandle>(null)
const canvasRef = useRef<CanvasEditorHandle>(null)
const { addMany } = useSavedAnnotations()
const { selectedFlight } = useFlight()
const isVideo = selectedMedia?.mediaType === MediaType.Video
useEffect(() => {
setDetections([])
setSelectedAnnotation(null)
setCurrentTime(0)
setDuration(0)
setIsPlaying(false)
setMuted(false)
}, [selectedMedia])
// Push the page's initial volume into the <video> element once the player
// is mounted — otherwise the slider shows 62% while audio plays at 100%.
useEffect(() => {
if (!selectedMedia || !isVideo) return
videoPlayerRef.current?.setVolume(volume)
// Only on media change — subsequent slider drags push via onVolumeChange.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedMedia, isVideo])
// AI detection fake-log progress
useEffect(() => {
if (!aiDetecting) return
aiStartRef.current = performance.now()
setAiElapsed(0)
setAiLog([])
setAiProgress(0)
let i = 0
const logTimer = window.setInterval(() => {
if (i < FAKE_LOG_LINES.length) {
setAiLog(prev => [...prev, FAKE_LOG_LINES[i]])
i++
}
}, 700)
const tickTimer = window.setInterval(() => {
setAiElapsed((performance.now() - aiStartRef.current) / 1000)
setAiProgress(p => Math.min(0.95, p + 0.04))
}, 100)
return () => {
window.clearInterval(logTimer)
window.clearInterval(tickTimer)
}
}, [aiDetecting])
const scrubberMarks = useMemo<ScrubberMark[]>(() => {
return annotations
.map(a => {
const sec = parseAnnotationTime(a.time)
if (sec == null) return null
const first = a.detections[0]
return { time: sec, color: first ? getClassColor(first.classNum) : '#9AA4B2' }
})
.filter((m): m is ScrubberMark => m !== null)
}, [annotations])
const handleSave = useCallback(async () => {
if (!selectedMedia || !detections.length) return
const time = selectedMedia.mediaType === MediaType.Video ? formatTicks(currentTime) : null
@@ -108,7 +178,6 @@ export default function AnnotationsPage() {
txtA.click()
URL.revokeObjectURL(txtUrl)
// Build the image: video frame or image with rectangles drawn
const videoEl = videoPlayerRef.current?.getVideoElement() ?? null
let w = 0, h = 0
const canvas = document.createElement('canvas')
@@ -181,11 +250,10 @@ export default function AnnotationsPage() {
const handleAnnotationSelect = useCallback((ann: AnnotationListItem) => {
setSelectedAnnotation(ann)
setDetections(ann.detections)
if (ann.time) {
const parts = ann.time.split(':').map(Number)
const seconds = (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
videoPlayerRef.current?.seek(seconds)
setCurrentTime(seconds)
const sec = parseAnnotationTime(ann.time)
if (sec != null) {
videoPlayerRef.current?.seek(sec)
setCurrentTime(sec)
}
}, [])
@@ -193,20 +261,68 @@ export default function AnnotationsPage() {
setDetections(dets)
}, [])
const isVideo = selectedMedia?.mediaType === MediaType.Video
const handleAiDetect = useCallback(async () => {
if (!selectedMedia || aiDetecting) return
if (aiCloseTimerRef.current != null) {
window.clearTimeout(aiCloseTimerRef.current)
aiCloseTimerRef.current = null
}
setAiDetecting(true)
try {
await api.post(endpoints.detect.media(selectedMedia.id))
} catch {
// banner stays visible briefly; sidebar SSE refresh will pick up results
} finally {
setAiProgress(1)
aiCloseTimerRef.current = window.setTimeout(() => {
aiCloseTimerRef.current = null
setAiDetecting(false)
}, 500)
}
}, [selectedMedia, aiDetecting])
function formatTicks(seconds: number): string {
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
// Clear any pending AI-banner close timer on unmount.
useEffect(() => () => {
if (aiCloseTimerRef.current != null) {
window.clearTimeout(aiCloseTimerRef.current)
aiCloseTimerRef.current = null
}
}, [])
const togglePlay = () => { videoPlayerRef.current?.toggle() }
const stepFrames = (n: number) => { videoPlayerRef.current?.frameStep(n) }
const seekRel = (sec: number) => {
const p = videoPlayerRef.current
if (!p) return
p.seek(Math.max(0, Math.min(p.getDuration(), p.getCurrentTime() + sec)))
}
const onVolumeChange = (v: number) => {
setVolume(v)
videoPlayerRef.current?.setVolume(v)
}
const toggleMute = () => {
// VideoPlayer.toggleMute() fires onMutedChange, which updates `muted` —
// don't flip parent state independently or the two desync (e.g. M-key
// shortcut already routed via onMutedChange).
videoPlayerRef.current?.toggleMute()
}
const dims = (() => {
const v = videoPlayerRef.current?.getVideoElement()
if (!v || !v.videoWidth) return null
return { w: v.videoWidth, h: v.videoHeight }
})()
const fps = videoPlayerRef.current?.getFrameRate() ?? 30
const currentFrame = isVideo ? Math.floor(currentTime * fps) : 0
const totalFrames = isVideo ? Math.floor(duration * fps) : 0
const detectionsLabel = `${detections.length} det${detections.length !== 1 ? 's' : ''}`
return (
<div className="flex h-full">
{/* Left panel */}
<div style={{ width: leftPanel.width }} className="bg-az-panel border-r border-az-border flex flex-col shrink-0">
{/* LEFT SIDEBAR */}
<div style={{ width: 232 }} className="bg-surface-1 flex flex-col shrink-0 border-r border-border-hair">
<MediaList
selectedMedia={selectedMedia}
onSelect={setSelectedMedia}
@@ -219,42 +335,62 @@ export default function AnnotationsPage() {
onPhotoModeChange={setPhotoMode}
/>
</div>
<div onMouseDown={leftPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
{/* Center - video/canvas */}
<div className="flex-1 flex flex-col min-h-0">
{selectedMedia && (
<div className="bg-az-panel border-b border-az-border px-2 py-1 flex gap-2 items-center shrink-0">
<button
onClick={handleSave}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-green text-az-green text-[11px] hover:bg-az-green/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Save
</button>
<button
onClick={() => canvasRef.current?.deleteSelected()}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Remove
</button>
<button
onClick={() => canvasRef.current?.deleteAll()}
disabled={!detections.length}
className="px-2.5 py-1 rounded border border-az-red text-az-red text-[11px] hover:bg-az-red/10 disabled:opacity-40 disabled:cursor-not-allowed"
>
Remove All
</button>
<span className="text-az-muted text-[10px]">{detections.length} detection{detections.length !== 1 ? 's' : ''}</span>
{/* CENTER */}
<div className="flex-1 flex flex-col min-w-0 bg-surface-0">
{/* Canvas top bar */}
<div className="h-9 flex items-center gap-3 px-4 border-b border-border-hair bg-surface-1 shrink-0">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.canvas')}</span>
{selectedMedia && (
<>
<span className="mono text-[11px] text-text-muted">{selectedMedia.name}</span>
{dims && (
<span className="mono text-[10px] px-1.5 py-0.5 border border-border-hair text-text-secondary">
{dims.w}×{dims.h} · {fps} FPS
</span>
)}
</>
)}
</div>
)}
{selectedMedia && isVideo && (
<VideoPlayer
ref={videoPlayerRef}
media={selectedMedia}
onTimeUpdate={setCurrentTime}
>
<div className="ml-auto flex items-center gap-2">
<span className="micro">{t('annotations.zoom')}</span>
<span className="mono text-[11px] text-text-primary">{Math.round(zoom * 100)}%</span>
<span className="mx-2 h-4 w-px bg-border-hair" />
<span className="micro">{t('annotations.cursor')}</span>
<span className="mono text-[11px] text-text-primary">
{cursor ? `${cursor.x.toFixed(3)}, ${cursor.y.toFixed(3)}` : '—'}
</span>
<span className="mx-2 h-4 w-px bg-border-hair" />
<span className="mono text-[11px] text-text-secondary">{detectionsLabel}</span>
</div>
</div>
{/* Canvas area */}
<div className="flex-1 relative overflow-hidden">
{selectedMedia && isVideo && (
<VideoPlayer
ref={videoPlayerRef}
media={selectedMedia}
onTimeUpdate={setCurrentTime}
onPlayingChange={setIsPlaying}
onDurationChange={setDuration}
onMutedChange={setMuted}
>
<CanvasEditor
ref={canvasRef}
media={selectedMedia}
annotation={selectedAnnotation}
detections={detections}
onDetectionsChange={handleDetectionsChange}
selectedClassNum={selectedClassNum}
currentTime={currentTime}
annotations={annotations}
onZoomChange={setZoom}
onCursorChange={(x, y) => setCursor({ x, y })}
/>
</VideoPlayer>
)}
{selectedMedia && !isVideo && (
<CanvasEditor
ref={canvasRef}
media={selectedMedia}
@@ -264,31 +400,178 @@ export default function AnnotationsPage() {
selectedClassNum={selectedClassNum}
currentTime={currentTime}
annotations={annotations}
onZoomChange={setZoom}
onCursorChange={(x, y) => setCursor({ x, y })}
/>
</VideoPlayer>
)}
{!selectedMedia && (
<div className="absolute inset-0 flex items-center justify-center text-text-muted text-sm">
{t('annotations.selectMedia')}
</div>
)}
{/* AI Detection floating banner */}
{aiDetecting && (
<div className="absolute top-6 right-6 ai-banner px-3 py-2 w-72">
<div className="flex items-center gap-2 mb-1.5">
<span className="live-dot" />
<span className="micro text-accent-cyan">{t('annotations.detectInProgress')}</span>
<span className="ml-auto mono text-[10px] text-text-muted">{aiElapsed.toFixed(1)}s</span>
</div>
<div className="mono text-[10px] space-y-0.5 text-text-secondary">
{aiLog.map((line, i) => <div key={i}>{line}</div>)}
</div>
<div className="mt-2 h-[2px] bg-black/40 overflow-hidden">
<div style={{ height: '100%', width: `${aiProgress * 100}%`, background: 'var(--accent-cyan)' }} />
</div>
</div>
)}
</div>
{/* Scrubber + Controls */}
{selectedMedia && isVideo && (
<div className="border-t border-border-hair bg-surface-1 shrink-0">
<div className="px-4 pt-3 pb-2">
<Scrubber
current={currentTime}
duration={duration}
marks={scrubberMarks}
onSeek={t => { videoPlayerRef.current?.seek(t); setCurrentTime(t) }}
/>
</div>
<div className="px-4 pb-3 flex items-center gap-1.5 min-w-0 whitespace-nowrap overflow-hidden">
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.previousMedia')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/></svg>
</button>
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.back5s')} onClick={() => seekRel(-5)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M11 18V6l-8.5 6zM22 18V6l-8.5 6z"/></svg>
</button>
<button
className="ibtn"
title={isPlaying ? t('annotations.pause') : t('annotations.play')}
onClick={togglePlay}
style={{
width: 28,
height: 28,
background: isPlaying ? 'rgba(255,157,61,0.12)' : 'transparent',
color: isPlaying ? 'var(--accent-amber)' : undefined,
borderColor: isPlaying ? 'var(--accent-amber)' : 'transparent',
}}
>
{isPlaying
? <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M6 5h4v14H6zM14 5h4v14h-4z"/></svg>
: <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>}
</button>
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.forward5s')} onClick={() => seekRel(5)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M13 6v12l8.5-6zM2 6v12l8.5-6z"/></svg>
</button>
<button className="ibtn" style={{ width: 28, height: 28, border: 0, background: 'transparent' }} title={t('annotations.nextMedia')}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><path d="M16 6h2v12h-2zM6 18l8.5-6L6 6z"/></svg>
</button>
</div>
<span className="micro">{t('annotations.frameStep')}</span>
<div className="flex items-center gap-1 p-1 border border-border-hair rounded-[2px]">
{FRAME_STEPS.map(n => (
<button
key={n}
onClick={() => stepFrames(n)}
className="ibtn mono"
style={{ width: 30, height: 28, fontSize: 10, border: 0, background: 'transparent', letterSpacing: 0 }}
>
{n}
</button>
))}
</div>
<span className="mx-1 h-5 w-px bg-border-hair" />
<button
onClick={handleSave}
disabled={!detections.length}
className="btn btn-secondary"
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><path d="M17 21v-8H7v8M7 3v5h8"/></svg>
{t('annotations.save')}
</button>
<button
onClick={() => canvasRef.current?.deleteSelected()}
disabled={!detections.length}
className="btn btn-danger-ghost"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/></svg>
{t('annotations.delete')}
</button>
<button
onClick={() => canvasRef.current?.deleteAll()}
disabled={!detections.length}
className="btn btn-danger-ghost"
title={t('annotations.deleteAllTitle')}
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 6h18"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11l4 6M14 11l-4 6"/></svg>
{t('annotations.deleteAll')}
</button>
<span className="mx-1 h-5 w-px bg-border-hair" />
<button
onClick={handleAiDetect}
disabled={!selectedMedia || aiDetecting}
className="btn btn-primary"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M3 7V3h4"/><path d="M17 3h4v4"/><path d="M21 17v4h-4"/><path d="M7 21H3v-4"/><circle cx="12" cy="12" r="1.6" fill="currentColor" stroke="none"/></svg>
{t('annotations.detect')}
<span className="ml-1 mono opacity-70" style={{ fontSize: 9 }}>[R]</span>
</button>
<span className="mx-1 h-5 w-px bg-border-hair" />
<div className="ml-auto flex items-center gap-2">
<button className="ibtn" style={{ width: 28, height: 28 }} title={t('annotations.mute')} onClick={toggleMute}>
{muted
? <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.21.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51A8.95 8.95 0 0 0 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.17v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>
: <svg width="13" height="13" viewBox="0 0 24 24" fill="currentColor"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z"/></svg>}
</button>
<input
type="range"
className="vol"
min={0}
max={100}
value={Math.round(volume * 100)}
onChange={e => onVolumeChange(Number(e.target.value) / 100)}
/>
<span className="mono text-[10px] text-text-muted" style={{ width: 24 }}>{Math.round(volume * 100)}</span>
</div>
</div>
{/* Status bar */}
<div className="px-4 h-7 flex items-center border-t border-border-hair bg-surface-0">
<span className="mono text-[11px] text-text-primary">{formatTime(currentTime, true)}</span>
<span className="mono text-[11px] mx-1.5 text-text-muted">/</span>
<span className="mono text-[11px] text-text-secondary">{formatTime(duration, true)}</span>
<span className="mx-3 h-4 w-px bg-border-hair" />
<span className="micro">{t('annotations.frame')}</span>
<span className="mono text-[11px] ml-1.5 text-text-primary">{currentFrame} / {totalFrames}</span>
</div>
</div>
)}
{/* Photo-only controls row (save/delete/AI detect) */}
{selectedMedia && !isVideo && (
<CanvasEditor
ref={canvasRef}
media={selectedMedia}
annotation={selectedAnnotation}
detections={detections}
onDetectionsChange={handleDetectionsChange}
selectedClassNum={selectedClassNum}
currentTime={currentTime}
annotations={annotations}
/>
)}
{!selectedMedia && (
<div className="flex-1 flex items-center justify-center text-az-muted text-sm">
Select a media file to start
<div className="border-t border-border-hair bg-surface-1 shrink-0 px-4 py-2 flex items-center gap-3">
<button onClick={handleSave} disabled={!detections.length} className="btn btn-secondary">{t('annotations.save')}</button>
<button onClick={() => canvasRef.current?.deleteSelected()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.delete')}</button>
<button onClick={() => canvasRef.current?.deleteAll()} disabled={!detections.length} className="btn btn-danger-ghost">{t('annotations.deleteAll')}</button>
<span className="mx-1 h-5 w-px bg-border-hair" />
<button onClick={handleAiDetect} disabled={!selectedMedia || aiDetecting} className="btn btn-primary">{t('annotations.detect')}</button>
<span className="ml-auto mono text-[11px] text-text-muted">{detectionsLabel}</span>
</div>
)}
</div>
{/* Right panel */}
<div onMouseDown={rightPanel.onMouseDown} className="w-1 cursor-col-resize bg-az-border hover:bg-az-orange shrink-0" />
<div style={{ width: rightPanel.width }} className="bg-az-panel border-l border-az-border flex flex-col shrink-0">
{/* RIGHT SIDEBAR */}
<div style={{ width: 208 }} className="bg-surface-1 flex flex-col shrink-0 border-l border-border-hair">
<AnnotationsSidebar
media={selectedMedia}
annotations={annotations}
+124 -68
View File
@@ -1,8 +1,8 @@
import { useEffect, useState } from 'react'
import { useEffect, useMemo } from 'react'
import { useTranslation } from 'react-i18next'
import { FaDownload } from 'react-icons/fa'
import { api, createSSE, endpoints } from '../../api'
import { getClassColor } from '../../class-colors'
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
interface Props {
@@ -14,10 +14,46 @@ interface Props {
onDownload?: (ann: AnnotationListItem) => void
}
function getRowGradient(ann: AnnotationListItem): string {
if (ann.detections.length === 0) {
return 'linear-gradient(90deg, rgba(221,221,221,0.10), rgba(221,221,221,0.04))'
}
if (ann.detections.length === 1) {
const c = getClassColor(ann.detections[0].classNum)
return `linear-gradient(90deg, ${hexToRgba(c, 0.55)} 0%, ${hexToRgba(c, 0.10)} 60%, transparent 100%)`
}
const n = ann.detections.length
const bandWidth = 100 / n
const stops: string[] = []
ann.detections.forEach((d, i) => {
const c = getClassColor(d.classNum)
const start = i * bandWidth
const mid = start + bandWidth * 0.6
const end = (i + 1) * bandWidth
stops.push(`${hexToRgba(c, 0.50)} ${start}%`)
stops.push(`${hexToRgba(c, 0.10)} ${mid}%`)
if (i < n - 1) stops.push(`${hexToRgba(c, 0.10)} ${end - 0.01}%`)
})
return `linear-gradient(90deg, ${stops.join(', ')})`
}
interface ClassAgg { classNum: number; color: string; count: number }
function aggregateClasses(annotations: AnnotationListItem[]): ClassAgg[] {
const counts = new Map<number, number>()
for (const ann of annotations) {
for (const d of ann.detections) {
counts.set(d.classNum, (counts.get(d.classNum) ?? 0) + 1)
}
}
return [...counts.entries()]
.map(([classNum, count]) => ({ classNum, color: getClassColor(classNum), count }))
.sort((a, b) => b.count - a.count)
.slice(0, 6)
}
export default function AnnotationsSidebar({ media, annotations, selectedAnnotation, onSelect, onAnnotationsUpdate, onDownload }: Props) {
const { t } = useTranslation()
const [detecting, setDetecting] = useState(false)
const [detectLog, setDetectLog] = useState<string[]>([])
useEffect(() => {
if (!media) return
@@ -30,85 +66,105 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
})
}, [media, onAnnotationsUpdate])
const handleDetect = async () => {
if (!media) return
setDetecting(true)
setDetectLog(['Starting AI detection...'])
try {
await api.post(endpoints.detect.media(media.id))
setDetectLog(prev => [...prev, 'Detection complete.'])
} catch (e: any) {
setDetectLog(prev => [...prev, `Error: ${e.message}`])
}
}
const totals = useMemo(() => ({
total: annotations.length,
empty: annotations.filter(a => a.detections.length === 0).length,
}), [annotations])
const getRowGradient = (ann: AnnotationListItem) => {
if (ann.detections.length === 0) return 'rgba(221,221,221,0.25)'
const stops = ann.detections.map((d, i) => {
const pct = (i / Math.max(ann.detections.length - 1, 1)) * 100
const alpha = Math.min(1, d.confidence)
return `${getClassColor(d.classNum)}${Math.round(alpha * 40).toString(16).padStart(2, '0')} ${pct}%`
})
return `linear-gradient(to right, ${stops.join(', ')})`
}
const classDist = useMemo(() => aggregateClasses(annotations), [annotations])
return (
<div className="flex flex-col h-full">
<div className="p-2 border-b border-az-border flex items-center justify-between gap-1">
<span className="text-xs font-semibold text-az-muted">{t('annotations.title')}</span>
<div className="flex flex-col h-full bg-surface-1">
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.title')}</span>
<span className="mono text-[10px] text-text-muted">{String(annotations.length).padStart(2, '0')}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={handleDetect}
disabled={!media}
className="text-xs bg-az-blue text-white px-2 py-0.5 rounded disabled:opacity-50"
>
{t('annotations.detect')}
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.filter')}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polygon points="22 3 2 3 10 12.5 10 19 14 21 14 12.5"/></svg>
</button>
<button
onClick={() => selectedAnnotation && onDownload?.(selectedAnnotation)}
disabled={!selectedAnnotation}
title="Download annotation"
className="text-xs bg-az-orange text-white p-1 rounded disabled:opacity-50"
>
<FaDownload size={12} />
<button className="ibtn" style={{ width: 22, height: 22 }} title={t('annotations.sort')}>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M3 6h13M3 12h9M3 18h5M17 8l4-4 4 4M21 4v16"/></svg>
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto">
{annotations.map(ann => (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs ${
selectedAnnotation?.id === ann.id ? 'ring-1 ring-az-orange ring-inset' : ''
}`}
style={{ background: getRowGradient(ann) }}
>
<div className="flex items-center justify-between">
<span className="text-az-text font-mono">{ann.time || ''}</span>
<span className="text-az-muted">{ann.detections.length > 0 ? ann.detections[0].label : '—'}</span>
<div className="grid grid-cols-[44px_1fr_auto] gap-2 px-3 h-6 items-center border-b border-border-hair">
<span className="micro">{t('annotations.colTime')}</span>
<span className="micro">{t('annotations.colClass')}</span>
<span className="micro">{t('annotations.colConf')}</span>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{annotations.map(ann => {
const isSelected = selectedAnnotation?.id === ann.id
const isEmpty = ann.detections.length === 0
const first = ann.detections[0]
const extra = ann.detections.length > 1 ? ` +${ann.detections.length - 1}` : ''
const maxConf = ann.detections.reduce((m, d) => Math.max(m, d.confidence ?? 0), 0)
const className = first ? (first.label || getClassNameFallback(first.classNum)) : ''
return (
<div
key={ann.id}
onClick={() => onSelect(ann)}
className={`ann-row${isSelected ? ' active' : ''}`}
style={{ ['--row-grad' as string]: getRowGradient(ann) }}
>
<span className={`mono text-[11px] ${isSelected ? 'text-accent-amber font-semibold' : isEmpty ? 'text-text-muted' : 'text-text-secondary'}`}>
{ann.time || '—'}
</span>
{isEmpty
? <span className="text-text-muted italic">{t('annotations.emptyFrame')}</span>
: <span className={`truncate ${isSelected ? 'text-text-primary font-semibold' : 'text-text-primary'}`}>{className}{extra}</span>
}
<div className="flex items-center gap-1.5">
{isSelected && !isEmpty && onDownload && (
<button
onClick={e => { e.stopPropagation(); onDownload(ann) }}
className="ibtn"
style={{ width: 18, height: 18 }}
title="Download annotation"
>
<FaDownload size={9} />
</button>
)}
<span className={`mono text-[10px] ${isEmpty ? 'text-text-muted' : isSelected ? 'text-accent-amber' : 'text-text-secondary'}`}>
{isEmpty ? '—' : `${Math.round(maxConf * 100)}%`}
</span>
</div>
</div>
</div>
))}
)
})}
{annotations.length === 0 && (
<div className="p-2 text-az-muted text-xs text-center">{t('common.noData')}</div>
<div className="p-3 text-text-muted text-xs text-center">{t('common.noData')}</div>
)}
</div>
{detecting && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[100]">
<div className="bg-az-panel border border-az-border rounded-lg p-4 w-96 max-h-80 flex flex-col">
<h3 className="text-white font-semibold mb-2">{t('annotations.detect')}</h3>
<div className="flex-1 overflow-y-auto bg-az-bg rounded p-2 text-xs text-az-text font-mono space-y-0.5 mb-2">
{detectLog.map((line, i) => <div key={i}>{line}</div>)}
</div>
<button onClick={() => setDetecting(false)} className="self-end text-xs bg-az-border text-az-text px-3 py-1 rounded">
Close
</button>
</div>
<div className="border-t border-border-hair px-3 py-2.5 bg-surface-0">
<div className="flex items-center justify-between mb-2">
<span className="micro">{t('annotations.summary')}</span>
<span className="mono text-[10px] text-text-muted">
{t('annotations.annCount', { count: totals.total })} · {t('annotations.emptyCount', { count: totals.empty })}
</span>
</div>
)}
{classDist.length > 0 && (
<>
<div className="flex items-center gap-1 h-2">
{classDist.map(c => (
<span key={c.classNum} style={{ flex: c.count, background: c.color, height: '100%' }} />
))}
</div>
<div className="flex items-center justify-between mt-2 mono text-[10px] text-text-muted">
{classDist.map(c => (
<span key={c.classNum} className="flex items-center gap-1">
<span style={{ color: c.color }}></span> {c.count}
</span>
))}
</div>
</>
)}
</div>
</div>
)
}
+159 -62
View File
@@ -2,7 +2,8 @@ import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHand
import { endpoints } from '../../api'
import { MediaType } from '../../types'
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
import { getClassColor, getClassNameFallback, hexToRgba } from '../../class-colors'
import { parseAnnotationTime } from './time'
interface Props {
media: Media
@@ -12,6 +13,8 @@ interface Props {
selectedClassNum: number
currentTime: number
annotations: AnnotationListItem[]
onZoomChange?: (zoom: number) => void
onCursorChange?: (nx: number, ny: number) => void
}
export interface CanvasEditorHandle {
@@ -28,28 +31,60 @@ interface DragState {
handle?: string
}
interface LabelChip {
leftPct: number
topPct: number
color: string
name: string
conf: number
combatReady: boolean
}
const HANDLE_SIZE = 6
const MIN_BOX_SIZE = 12
const AFFILIATION_COLORS: Record<number, string> = {
0: '#FFD700',
1: '#228be6',
2: '#fa5252',
const HOSTILE_HEXES = new Set(['#FF0000', '#FFFF00', '#FF00FF', '#800000', '#808000', '#800080'])
const FRIENDLY_HEXES = new Set(['#00FF00', '#0000FF', '#00FFFF', '#008000', '#000080', '#008080'])
function affiliationIcon(hex: string) {
const up = hex.toUpperCase()
if (HOSTILE_HEXES.has(up)) {
return (
<svg width="11" height="11" viewBox="0 0 11 11" aria-hidden="true">
<polygon points="5.5,0.7 10.3,5.5 5.5,10.3 0.7,5.5" fill="#FF0000" stroke="#0A0D10" strokeWidth="1"/>
</svg>
)
}
if (FRIENDLY_HEXES.has(up)) {
return (
<svg width="11" height="9" viewBox="0 0 11 9" aria-hidden="true">
<rect x="0.5" y="0.5" width="10" height="8" fill="#87CEEB" stroke="#0A0D10" strokeWidth="1"/>
</svg>
)
}
return (
<svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">
<circle cx="5" cy="5" r="3.5" fill="none" stroke="currentColor" strokeWidth="1.2"/>
</svg>
)
}
const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor(
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations },
{ media, annotation, detections, onDetectionsChange, selectedClassNum, currentTime, annotations, onZoomChange, onCursorChange },
ref,
) {
const canvasRef = useRef<HTMLCanvasElement>(null)
const containerRef = useRef<HTMLDivElement>(null)
const imgRef = useRef<HTMLImageElement | null>(null)
const cursorRafRef = useRef<number | null>(null)
const cursorLatestRef = useRef<{ x: number; y: number } | null>(null)
const [zoom, setZoom] = useState(1)
const [pan, setPan] = useState({ x: 0, y: 0 })
const [selected, setSelected] = useState<Set<number>>(new Set())
const [dragState, setDragState] = useState<DragState | null>(null)
const [drawRect, setDrawRect] = useState<{ x: number; y: number; w: number; h: number } | null>(null)
const [imgSize, setImgSize] = useState({ w: 0, h: 0 })
const [labelChips, setLabelChips] = useState<LabelChip[]>([])
useImperativeHandle(ref, () => ({
deleteSelected() {
@@ -70,7 +105,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
const loadImage = useCallback(() => {
if (isVideo) {
// Use natural size based on container; no image load
imgRef.current = null
return
}
@@ -116,16 +150,45 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
return () => ro.disconnect()
}, [isVideo])
const toCanvas = useCallback((nx: number, ny: number) => ({
x: nx * imgSize.w * zoom + pan.x,
y: ny * imgSize.h * zoom + pan.y,
}), [imgSize, zoom, pan])
useEffect(() => { onZoomChange?.(zoom) }, [zoom, onZoomChange])
// Cancel any pending cursor RAF on unmount so the callback can't fire after.
useEffect(() => () => {
if (cursorRafRef.current != null) {
cancelAnimationFrame(cursorRafRef.current)
cursorRafRef.current = null
}
}, [])
const fromCanvas = useCallback((cx: number, cy: number) => ({
x: Math.max(0, Math.min(1, (cx - pan.x) / (imgSize.w * zoom))),
y: Math.max(0, Math.min(1, (cy - pan.y) / (imgSize.h * zoom))),
}), [imgSize, zoom, pan])
const getTimeWindowDetections = useCallback((): Detection[] => {
if (media.mediaType !== MediaType.Video) return []
if (annotation) return []
const timeTicks = currentTime * 10_000_000
return annotations
.filter(a => {
const sec = parseAnnotationTime(a.time)
if (sec == null) return false
return Math.abs(sec * 10_000_000 - timeTicks) < 2_000_000
})
.flatMap(a => a.detections)
}, [media.mediaType, annotation, annotations, currentTime])
const getHandles = (x: number, y: number, w: number, h: number) => [
{ x, y, cursor: 'nw-resize', name: 'tl' },
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
]
const draw = useCallback(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
@@ -146,9 +209,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
const timeWindowDets = getTimeWindowDetections()
const allDets = [...detections, ...timeWindowDets]
const chips: LabelChip[] = []
allDets.forEach((det, i) => {
const isSelected = selected.has(i) && i < detections.length
const isOwn = i < detections.length
const isSelected = selected.has(i) && isOwn
const cx = (det.centerX - det.width / 2) * imgSize.w * zoom + pan.x
const cy = (det.centerY - det.height / 2) * imgSize.h * zoom + pan.y
const w = det.width * imgSize.w * zoom
@@ -160,45 +225,51 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
ctx.strokeRect(cx, cy, w, h)
ctx.fillStyle = color
ctx.globalAlpha = 0.1
ctx.globalAlpha = 0.06
ctx.fillRect(cx, cy, w, h)
ctx.globalAlpha = 1
const name = det.label || getClassNameFallback(det.classNum)
const modeSuffix = getPhotoModeSuffix(det.classNum)
const confSuffix = det.confidence < 0.995 ? ` ${(det.confidence * 100).toFixed(0)}%` : ''
const label = `${name}${modeSuffix}${confSuffix}`
ctx.font = '11px sans-serif'
const metrics = ctx.measureText(label)
const padX = 3
const labelH = 14
const labelW = metrics.width + padX * 2
ctx.fillStyle = color
ctx.fillRect(cx, cy - labelH, labelW, labelH)
ctx.fillStyle = '#000'
ctx.fillText(label, cx + padX, cy - 3)
if (det.combatReadiness === 1) {
ctx.fillStyle = '#40c057'
// Corner brackets — 8px legs (skipped in environments lacking path API, e.g. JSDOM)
if (typeof ctx.moveTo === 'function' && typeof ctx.beginPath === 'function') {
const legLen = 8
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(cx + w - 6, cy + 6, 3, 0, Math.PI * 2)
ctx.fill()
ctx.moveTo(cx, cy + legLen); ctx.lineTo(cx, cy); ctx.lineTo(cx + legLen, cy)
ctx.moveTo(cx + w - legLen, cy); ctx.lineTo(cx + w, cy); ctx.lineTo(cx + w, cy + legLen)
ctx.moveTo(cx + w, cy + h - legLen); ctx.lineTo(cx + w, cy + h); ctx.lineTo(cx + w - legLen, cy + h)
ctx.moveTo(cx + legLen, cy + h); ctx.lineTo(cx, cy + h); ctx.lineTo(cx, cy + h - legLen)
ctx.strokeStyle = color
ctx.stroke()
ctx.lineWidth = 1
}
if (isOwn) {
const container = containerRef.current
if (container && container.clientWidth && container.clientHeight) {
chips.push({
leftPct: (cx / container.clientWidth) * 100,
topPct: (cy / container.clientHeight) * 100,
color,
name: det.label || getClassNameFallback(det.classNum),
conf: det.confidence,
combatReady: det.combatReadiness === 1,
})
}
}
if (isSelected) {
const handles = getHandles(cx, cy, w, h)
handles.forEach(hp => {
ctx.fillStyle = '#fff'
ctx.fillStyle = '#FF9D3D'
ctx.fillRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
ctx.strokeStyle = color
ctx.strokeStyle = '#0A0D10'
ctx.strokeRect(hp.x - HANDLE_SIZE / 2, hp.y - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE)
})
}
})
if (drawRect) {
ctx.strokeStyle = '#fd7e14'
ctx.strokeStyle = '#FF9D3D'
ctx.lineWidth = 1
ctx.setLineDash([4, 4])
ctx.strokeRect(drawRect.x, drawRect.y, drawRect.w, drawRect.h)
@@ -206,7 +277,23 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
}
ctx.restore()
}, [detections, selected, zoom, pan, imgSize, drawRect, currentTime, annotations])
// Only setState when chips actually changed — prevents a render storm
// during video playback (draw runs on every time-update; without this
// guard React would commit a new array reference on every paint).
setLabelChips(prev => {
if (prev.length !== chips.length) return chips
for (let i = 0; i < chips.length; i++) {
const a = prev[i], b = chips[i]
if (
a.leftPct !== b.leftPct || a.topPct !== b.topPct ||
a.color !== b.color || a.name !== b.name ||
a.conf !== b.conf || a.combatReady !== b.combatReady
) return chips
}
return prev
})
}, [detections, selected, zoom, pan, imgSize, drawRect, isVideo, getTimeWindowDetections])
useEffect(() => {
const id = requestAnimationFrame(draw)
@@ -221,31 +308,6 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
return () => obs.disconnect()
}, [draw])
const getTimeWindowDetections = (): Detection[] => {
if (media.mediaType !== MediaType.Video) return []
if (annotation) return []
const timeTicks = currentTime * 10_000_000
return annotations
.filter(a => {
if (!a.time) return false
const parts = a.time.split(':').map(Number)
const annTime = (parts[0] * 3600 + parts[1] * 60 + parts[2]) * 10_000_000
return Math.abs(annTime - timeTicks) < 2_000_000
})
.flatMap(a => a.detections)
}
const getHandles = (x: number, y: number, w: number, h: number) => [
{ x, y, cursor: 'nw-resize', name: 'tl' },
{ x: x + w / 2, y, cursor: 'n-resize', name: 'tc' },
{ x: x + w, y, cursor: 'ne-resize', name: 'tr' },
{ x: x + w, y: y + h / 2, cursor: 'e-resize', name: 'mr' },
{ x: x + w, y: y + h, cursor: 'se-resize', name: 'br' },
{ x: x + w / 2, y: y + h, cursor: 's-resize', name: 'bc' },
{ x, y: y + h, cursor: 'sw-resize', name: 'bl' },
{ x, y: y + h / 2, cursor: 'w-resize', name: 'ml' },
]
const hitTest = (cx: number, cy: number) => {
for (let i = detections.length - 1; i >= 0; i--) {
const d = detections[i]
@@ -298,12 +360,28 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
}
const handleMouseMove = (e: React.MouseEvent) => {
if (!dragState) return
const rect = canvasRef.current?.getBoundingClientRect()
if (!rect) return
const mx = e.clientX - rect.left
const my = e.clientY - rect.top
if (onCursorChange && imgSize.w && imgSize.h) {
const nx = (mx - pan.x) / (imgSize.w * zoom)
const ny = (my - pan.y) / (imgSize.h * zoom)
if (nx >= 0 && nx <= 1 && ny >= 0 && ny <= 1) {
cursorLatestRef.current = { x: nx, y: ny }
if (cursorRafRef.current == null) {
cursorRafRef.current = requestAnimationFrame(() => {
const v = cursorLatestRef.current
cursorRafRef.current = null
if (v) onCursorChange(v.x, v.y)
})
}
}
}
if (!dragState) return
if (dragState.type === 'draw') {
setDrawRect({
x: Math.min(dragState.startX, mx),
@@ -415,6 +493,25 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
onMouseLeave={handleMouseUp}
onWheel={handleWheel}
/>
<div className="absolute inset-0 pointer-events-none">
{labelChips.map((chip, i) => (
<div
key={i}
className="bbox-label"
style={{
position: 'absolute',
left: `${chip.leftPct}%`,
top: `calc(${chip.topPct}% - 26px)`,
borderColor: hexToRgba(chip.color, 0.6),
}}
>
<span style={{ color: chip.color, display: 'inline-flex' }}>{affiliationIcon(chip.color)}</span>
{chip.combatReady && <span style={{ width: 6, height: 6, borderRadius: 999, background: 'var(--accent-green)', display: 'inline-block' }} />}
<span style={{ color: chip.color }}>{chip.name}</span>
{chip.conf < 0.995 && <span className="conf">{(chip.conf * 100).toFixed(1)}%</span>}
</div>
))}
</div>
</div>
)
})
+113 -56
View File
@@ -21,6 +21,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
const debouncedFilter = useDebounce(filter, 300)
const [deleteId, setDeleteId] = useState<string | null>(null)
const folderInputRef = useRef<HTMLInputElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const fetchMedia = useCallback(async () => {
const params = new URLSearchParams({ pageSize: '1000' })
@@ -139,70 +140,126 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
e.target.value = ''
}
const filtered = media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase()))
return (
<div
{...getRootProps({
className: `flex-1 flex flex-col overflow-hidden ${isDragActive ? 'ring-2 ring-az-orange ring-inset' : ''}`,
className: `flex flex-col flex-1 min-h-0 bg-surface-1${isDragActive ? ' ring-2 ring-accent-amber ring-inset' : ''}`,
})}
>
{/* Dropzone hidden input */}
<input {...getInputProps()} />
<div className="p-2 border-b border-az-border flex gap-1">
<input
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder={t('annotations.mediaList')}
className="flex-1 bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none"
/>
</div>
<div className="px-2 pt-2 pb-2 flex gap-1">
<label className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded text-center cursor-pointer hover:brightness-110">
Open File
<input
type="file"
multiple
className="hidden"
onChange={e => {
if (e.target.files?.length) uploadFiles(e.target.files)
e.target.value = ''
}}
/>
</label>
<button
type="button"
onClick={() => folderInputRef.current?.click()}
className="flex-1 bg-az-orange text-white text-[10px] py-1 rounded hover:brightness-110"
>
Open Folder
</button>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is non-standard but widely supported
webkitdirectory=""
directory=""
onChange={handleFolderInput}
/>
</div>
<div className="flex-1 overflow-y-auto">
{media.filter(m => m.name.toLowerCase().includes(filter.toLowerCase())).map(m => (
<div
key={m.id}
onClick={() => handleSelect(m)}
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
className={`px-2 py-1 cursor-pointer border-b border-az-border text-xs flex items-center gap-1.5 ${
selectedMedia?.id === m.id ? 'bg-az-bg text-white' : ''
} ${m.annotationCount > 0 ? 'bg-az-bg/50' : ''} text-az-text hover:bg-az-bg`}
{/* Hidden file inputs */}
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => {
if (e.target.files?.length) uploadFiles(e.target.files)
e.target.value = ''
}}
/>
<input
ref={folderInputRef}
type="file"
multiple
className="hidden"
// @ts-expect-error webkitdirectory is non-standard but widely supported
webkitdirectory=""
directory=""
onChange={handleFolderInput}
/>
{/* Header row */}
<div className="flex items-center justify-between px-3 h-9 border-b border-border-hair shrink-0">
<div className="flex items-center gap-2">
<span className="sect-head">{t('annotations.mediaList')}</span>
<span className="mono text-[10px] text-text-muted">{filtered.length}</span>
</div>
<div className="flex items-center gap-1">
{/* Upload file button */}
<button
type="button"
className="ibtn"
style={{ width: 22, height: 22 }}
title={t('annotations.upload')}
onClick={() => fileInputRef.current?.click()}
>
<span className={`font-mono text-[10px] px-1 rounded ${m.mediaType === MediaType.Video ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{m.mediaType === MediaType.Video ? 'V' : 'P'}
</span>
<span className="truncate flex-1">{m.name}</span>
{m.duration && <span className="text-az-muted">{m.duration}</span>}
</div>
))}
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 5v14M5 12h14"/>
</svg>
</button>
{/* Open folder button */}
<button
type="button"
className="ibtn"
style={{ width: 22, height: 22 }}
title="Open Folder"
onClick={() => folderInputRef.current?.click()}
>
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/>
</svg>
</button>
</div>
</div>
{/* Filter input row */}
<div className="px-3 py-2 border-b border-border-hair shrink-0">
<div className="relative">
<svg
width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"
className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted pointer-events-none"
>
<circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/>
</svg>
<input
className="inp w-full pl-7"
style={{ height: 28, padding: '0 10px 0 28px' }}
value={filter}
onChange={e => setFilter(e.target.value)}
placeholder={t('annotations.filterByName')}
/>
</div>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto min-h-0">
{filtered.map(m => {
const isActive = selectedMedia?.id === m.id
const isVideo = m.mediaType === MediaType.Video
const hasDuration = !!m.duration
const durationColor = isActive
? 'text-accent-amber'
: hasDuration
? 'text-text-secondary'
: 'text-text-muted'
return (
<div
key={m.id}
onClick={() => handleSelect(m)}
onContextMenu={e => { e.preventDefault(); setDeleteId(m.id) }}
className={`media-row${isActive ? ' active' : ''}`}
>
{isVideo
? <span className="chip-video">VIDEO</span>
: <span className="chip-photo">PHOTO</span>
}
<span className={`truncate${isActive ? ' font-medium text-text-primary' : ' text-text-primary'}`}>
{m.name}
</span>
<span className={`mono text-[11px] ${durationColor}`}>
{m.duration ?? '—'}
</span>
</div>
)
})}
</div>
<ConfirmDialog
open={!!deleteId}
title={t('annotations.deleteMedia')}
+63
View File
@@ -0,0 +1,63 @@
import { useCallback, useEffect, useRef, useState } from 'react'
export interface ScrubberMark {
time: number
color: string
}
interface Props {
current: number
duration: number
marks: ScrubberMark[]
onSeek: (time: number) => void
}
const TICK_PERCENTS = [0, 25, 50, 75, 100]
export default function Scrubber({ current, duration, marks, onSeek }: Props) {
const trackRef = useRef<HTMLDivElement>(null)
const [dragging, setDragging] = useState(false)
const safeDuration = duration > 0 ? duration : 1
const pct = Math.max(0, Math.min(100, (current / safeDuration) * 100))
const seekFromClientX = useCallback((clientX: number) => {
const el = trackRef.current
if (!el) return
const rect = el.getBoundingClientRect()
const x = Math.max(0, Math.min(rect.width, clientX - rect.left))
onSeek((x / rect.width) * safeDuration)
}, [onSeek, safeDuration])
const handleMouseDown = (e: React.MouseEvent) => {
e.preventDefault()
setDragging(true)
seekFromClientX(e.clientX)
}
useEffect(() => {
if (!dragging) return
const move = (e: MouseEvent) => seekFromClientX(e.clientX)
const up = () => setDragging(false)
window.addEventListener('mousemove', move)
window.addEventListener('mouseup', up)
return () => {
window.removeEventListener('mousemove', move)
window.removeEventListener('mouseup', up)
}
}, [dragging, seekFromClientX])
return (
<div ref={trackRef} className="scrub" onMouseDown={handleMouseDown}>
<div className="fill" style={{ width: `${pct}%` }} />
{TICK_PERCENTS.map(p => (
<div key={p} className="tick" style={{ left: `${p}%` }} />
))}
{marks.map((m, i) => {
const mpct = Math.max(0, Math.min(100, (m.time / safeDuration) * 100))
return <div key={i} className="mark" style={{ left: `${mpct}%`, background: m.color }} />
})}
<div className="head" style={{ left: `${pct}%` }} />
<div className="head-knob" style={{ left: `${pct}%` }} />
</div>
)
}
+80 -105
View File
@@ -1,42 +1,52 @@
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
import { endpoints } from '../../api'
import type { Media } from '../../types'
interface Props {
media: Media
onTimeUpdate: (time: number) => void
/** Fires when the <video> emits 'play'/'pause' (no polling needed). */
onPlayingChange?: (playing: boolean) => void
/** Fires when the <video> reports a valid duration. */
onDurationChange?: (duration: number) => void
/** Fires when the <video> mute state changes (incl. the M keyboard shortcut). */
onMutedChange?: (muted: boolean) => void
children?: React.ReactNode
}
const STEP_BTN_CLASS = 'w-9 h-8 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-az-text text-xs font-mono'
const ICON_BTN_CLASS = 'w-10 h-10 flex items-center justify-center bg-az-bg rounded hover:bg-az-border text-white'
export interface VideoPlayerHandle {
seek: (seconds: number) => void
getVideoElement: () => HTMLVideoElement | null
play: () => void
pause: () => void
toggle: () => void
isPlaying: () => boolean
frameStep: (deltaFrames: number) => void
getDuration: () => number
getCurrentTime: () => number
getFrameRate: () => number
getCurrentFrame: () => number
getTotalFrames: () => number
getVolume: () => number
setVolume: (v: number) => void
toggleMute: () => void
isMuted: () => boolean
}
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({ media, onTimeUpdate, children }, ref) {
const videoRef = useRef<HTMLVideoElement>(null)
const FPS = 30
useImperativeHandle(ref, () => ({
seek(seconds: number) {
if (videoRef.current) {
videoRef.current.currentTime = seconds
setCurrentTime(seconds)
}
},
getVideoElement() {
return videoRef.current
},
}))
const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
media, onTimeUpdate, onPlayingChange, onDurationChange, onMutedChange, children,
}, ref) {
const videoRef = useRef<HTMLVideoElement>(null)
const [error, setError] = useState<string | null>(null)
const [playing, setPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [duration, setDuration] = useState(0)
const [muted, setMuted] = useState(false)
const notifyMuted = useCallback((m: boolean) => {
setMuted(m)
onMutedChange?.(m)
}, [onMutedChange])
const videoUrl = media.path.startsWith('blob:')
? media.path
: endpoints.annotations.mediaFile(media.id)
@@ -44,24 +54,47 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
const stepFrames = useCallback((count: number) => {
const video = videoRef.current
if (!video) return
const fps = 30
video.currentTime = Math.max(0, Math.min(video.duration, video.currentTime + count / fps))
video.currentTime = Math.max(0, Math.min(video.duration || 0, video.currentTime + count / FPS))
}, [])
const togglePlay = useCallback(() => {
const v = videoRef.current
if (!v) return
if (v.paused) { v.play(); setPlaying(true) }
else { v.pause(); setPlaying(false) }
if (v.paused) v.play().catch(() => {})
else v.pause()
}, [])
const stop = useCallback(() => {
const v = videoRef.current
if (!v) return
v.pause()
v.currentTime = 0
setPlaying(false)
}, [])
useImperativeHandle(ref, () => ({
seek(seconds: number) {
const v = videoRef.current
if (v) v.currentTime = seconds
},
getVideoElement() { return videoRef.current },
play() { videoRef.current?.play().catch(() => {}) },
pause() { videoRef.current?.pause() },
toggle() { togglePlay() },
isPlaying() { return !!videoRef.current && !videoRef.current.paused },
frameStep(delta) { stepFrames(delta) },
getDuration() { return videoRef.current?.duration ?? 0 },
getCurrentTime() { return videoRef.current?.currentTime ?? 0 },
getFrameRate() { return FPS },
getCurrentFrame() { return Math.floor((videoRef.current?.currentTime ?? 0) * FPS) },
getTotalFrames() { return Math.floor((videoRef.current?.duration ?? 0) * FPS) },
getVolume() { return videoRef.current?.volume ?? 1 },
setVolume(v) {
const el = videoRef.current
if (!el) return
el.volume = Math.max(0, Math.min(1, v))
if (el.volume > 0 && el.muted) { el.muted = false; notifyMuted(false) }
},
toggleMute() {
const el = videoRef.current
if (!el) return
el.muted = !el.muted
notifyMuted(el.muted)
},
isMuted() { return !!videoRef.current?.muted },
}))
useEffect(() => {
const handler = (e: KeyboardEvent) => {
@@ -70,22 +103,22 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
case ' ': e.preventDefault(); togglePlay(); break
case 'ArrowLeft': e.preventDefault(); stepFrames(e.ctrlKey ? -150 : -1); break
case 'ArrowRight': e.preventDefault(); stepFrames(e.ctrlKey ? 150 : 1); break
case 'm': case 'M': setMuted(m => !m); break
case 'm': case 'M': {
const v = videoRef.current
if (v) { v.muted = !v.muted; notifyMuted(v.muted) }
break
}
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [togglePlay, stepFrames])
const formatTime = (s: number) => {
const m = Math.floor(s / 60)
const sec = Math.floor(s % 60)
return `${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`
}
return (
<div className="bg-black flex flex-col flex-1 min-h-0">
{error && <div className="bg-az-red/80 text-white text-xs px-2 py-1">{error}</div>}
<div className="flex flex-col flex-1 min-h-0 bg-surface-0">
{error && (
<div className="bg-surface-1 border-b border-border-hair text-accent-red text-xs px-3 py-1">{error}</div>
)}
<div className="relative flex-1 min-h-0 flex items-center justify-center">
<video
ref={videoRef}
@@ -94,76 +127,18 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
controls={false}
playsInline
className="max-w-full max-h-full object-contain"
onTimeUpdate={e => {
const t = (e.target as HTMLVideoElement).currentTime
setCurrentTime(t)
onTimeUpdate(t)
}}
onLoadedMetadata={e => {
setDuration((e.target as HTMLVideoElement).duration)
setError(null)
onTimeUpdate={e => onTimeUpdate((e.target as HTMLVideoElement).currentTime)}
onPlay={() => onPlayingChange?.(true)}
onPause={() => onPlayingChange?.(false)}
onDurationChange={e => {
const d = (e.target as HTMLVideoElement).duration
if (Number.isFinite(d)) onDurationChange?.(d)
}}
onLoadedMetadata={() => setError(null)}
onError={() => setError(`Failed to load video (${media.name})`)}
/>
{children && <div className="absolute inset-0">{children}</div>}
</div>
{/* Progress row: time | slider | remaining */}
<div className="flex items-center gap-3 bg-az-header px-4 py-1.5">
<span className="text-white text-xs font-mono tabular-nums min-w-[40px] text-right">{formatTime(currentTime)}</span>
<input
type="range"
min={0}
max={duration || 1}
step={0.01}
value={currentTime}
onChange={e => {
const v = Number(e.target.value)
setCurrentTime(v)
if (videoRef.current) videoRef.current.currentTime = v
}}
className="flex-1 accent-az-orange h-1 cursor-pointer"
style={{
background: `linear-gradient(to right, #fd7e14 0%, #fd7e14 ${(currentTime / (duration || 1)) * 100}%, #495057 ${(currentTime / (duration || 1)) * 100}%, #495057 100%)`,
}}
/>
<span className="text-white text-xs font-mono tabular-nums min-w-[40px]">-{formatTime(Math.max(0, duration - currentTime))}</span>
</div>
{/* Buttons row */}
<div className="flex items-center justify-center gap-2 bg-az-header pb-2 flex-wrap">
<button onClick={() => stepFrames(-1)} title="Previous frame" className={ICON_BTN_CLASS}>
<FaStepBackward size={14} />
</button>
<button onClick={togglePlay} title={playing ? 'Pause' : 'Play'} className="w-10 h-10 flex items-center justify-center bg-az-orange rounded hover:brightness-110 text-white">
{playing ? <FaPause size={14} /> : <FaPlay size={14} />}
</button>
<button onClick={() => stepFrames(1)} title="Next frame" className={ICON_BTN_CLASS}>
<FaStepForward size={14} />
</button>
<button onClick={stop} title="Stop" className={ICON_BTN_CLASS}>
<FaStop size={14} />
</button>
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`prev-${n}`} onClick={() => stepFrames(-n)} title={`-${n} frames`} className={STEP_BTN_CLASS}>
-{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
{[1, 5, 10, 30, 60].map(n => (
<button key={`next-${n}`} onClick={() => stepFrames(n)} title={`+${n} frames`} className={STEP_BTN_CLASS}>
+{n}
</button>
))}
<span className="w-px h-8 bg-az-border mx-1" />
<button onClick={() => setMuted(m => !m)} title={muted ? 'Unmute' : 'Mute'} className={ICON_BTN_CLASS}>
{muted ? <FaVolumeMute size={14} /> : <FaVolumeUp size={14} />}
</button>
</div>
</div>
)
})
+32
View File
@@ -0,0 +1,32 @@
/**
* Annotation time helpers — shared between AnnotationsPage and CanvasEditor.
* Annotation `time` is the backend's "HH:MM:SS.mmm" tick representation; this
* module owns the conversion to/from seconds + display formatting.
*/
export function parseAnnotationTime(t: string | null | undefined): number | null {
if (!t) return null
const parts = t.split(':').map(Number)
if (parts.length !== 3) return null
if (parts.some(p => !Number.isFinite(p))) return null
return (parts[0] || 0) * 3600 + (parts[1] || 0) * 60 + (parts[2] || 0)
}
export function formatTime(seconds: number, withMs = false): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
const m = Math.floor(seconds / 60)
const s = Math.floor(seconds % 60)
const base = `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
if (!withMs) return base
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
return `${base}.${String(ms).padStart(3, '0')}`
}
export function formatTicks(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) seconds = 0
const h = Math.floor(seconds / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const ms = Math.floor((seconds - Math.floor(seconds)) * 1000)
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(ms).padStart(3, '0')}`
}
+693 -72
View File
@@ -1,106 +1,727 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useMemo, type ReactNode } from 'react'
import { useTranslation } from 'react-i18next'
import { useNavigate } from 'react-router-dom'
import { api, endpoints } from '../../api'
import { useAuth } from '../../auth'
import { LANG_STORAGE_KEY } from '../../i18n'
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
import { Modal } from '../admin/Modal'
type Lang = 'en' | 'ua'
const I18N_BUNDLE_VERSION = 'v2.4.1'
const DASH = '—'
type AircraftDraft = {
model: string
type: Aircraft['type']
resolution: string
maxMinutes: number
isDefault: boolean
}
const NEW_AIRCRAFT_DEFAULTS: AircraftDraft = {
model: '', type: 'Copter', resolution: '4K', maxMinutes: 30, isDefault: false,
}
const AIRCRAFT_TYPES = ['Plane', 'Copter', 'FixedWing'] as const
const RESOLUTIONS = ['HD', '1080P', '4K', '6K'] as const
const TYPE_LEGEND_KEY: Record<Aircraft['type'], 'legendPlane' | 'legendCopter' | 'legendFixedW'> = {
Plane: 'legendPlane', Copter: 'legendCopter', FixedWing: 'legendFixedW',
}
const TYPE_CHIP_COLOR: Record<Aircraft['type'], string> = {
Plane: 'var(--accent-blue)',
Copter: 'var(--accent-green)',
FixedWing: 'var(--accent-amber)',
}
const TYPE_CHIP_BORDER: Record<Aircraft['type'], string> = {
Plane: 'rgba(78,158,255,0.45)',
Copter: 'rgba(61,220,132,0.45)',
FixedWing: 'rgba(255,157,61,0.45)',
}
function FolderIcon() {
return (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M3 6.5A1.5 1.5 0 0 1 4.5 5h4.4l1.6 2H19.5A1.5 1.5 0 0 1 21 8.5v9A1.5 1.5 0 0 1 19.5 19h-15A1.5 1.5 0 0 1 3 17.5v-11Z" />
</svg>
)
}
function SignOutIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
)
}
function CheckIcon() {
return (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<polyline points="20 6 9 17 4 12" />
</svg>
)
}
function dirtyTenant(a: SystemSettings | null, b: SystemSettings | null): boolean {
if (!a || !b) return false
return (
a.militaryUnit !== b.militaryUnit ||
a.name !== b.name ||
a.defaultCameraWidth !== b.defaultCameraWidth ||
a.defaultCameraFoV !== b.defaultCameraFoV
)
}
function dirtyDirs(a: DirectorySettings | null, b: DirectorySettings | null): boolean {
if (!a || !b) return false
return (
a.imagesDir !== b.imagesDir ||
a.labelsDir !== b.labelsDir ||
a.thumbnailsDir !== b.thumbnailsDir
)
}
export default function SettingsPage() {
const { t } = useTranslation()
const { t, i18n } = useTranslation()
const { user, logout } = useAuth()
const navigate = useNavigate()
const [system, setSystem] = useState<SystemSettings | null>(null)
const [systemInitial, setSystemInitial] = useState<SystemSettings | null>(null)
const [dirs, setDirs] = useState<DirectorySettings | null>(null)
const [dirsInitial, setDirsInitial] = useState<DirectorySettings | null>(null)
const [aircrafts, setAircrafts] = useState<Aircraft[]>([])
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState<string | null>(null)
const lang: Lang = i18n.language === 'ua' ? 'ua' : 'en'
const [aircraftModalOpen, setAircraftModalOpen] = useState(false)
const [aircraftDraft, setAircraftDraft] = useState<AircraftDraft>(NEW_AIRCRAFT_DEFAULTS)
const [aircraftSaving, setAircraftSaving] = useState(false)
const [aircraftError, setAircraftError] = useState<'modelRequired' | 'saveFailed' | null>(null)
useEffect(() => {
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(s => {
setSystem(s)
setSystemInitial(s)
}).catch(() => {})
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(d => {
setDirs(d)
setDirsInitial(d)
}).catch(() => {})
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
}, [])
const saveSystem = async () => {
if (!system) return
const tenantDirty = useMemo(() => dirtyTenant(system, systemInitial), [system, systemInitial])
const dirsDirty = useMemo(() => dirtyDirs(dirs, dirsInitial), [dirs, dirsInitial])
const anyDirty = tenantDirty || dirsDirty
const dirtyLabel = useMemo(() => {
if (tenantDirty && dirsDirty) return `${t('settings.unitTenant')} · ${t('settings.unitDirectories')}`
if (tenantDirty) return t('settings.unitTenant')
if (dirsDirty) return t('settings.unitDirectories')
return ''
}, [tenantDirty, dirsDirty, t])
const save = async () => {
setSaving(true)
await api.put(endpoints.annotations.settingsSystem(), system)
setSaving(false)
setSaveError(null)
try {
const tasks: Promise<unknown>[] = []
if (tenantDirty && system) tasks.push(api.put(endpoints.annotations.settingsSystem(), system))
if (dirsDirty && dirs) tasks.push(api.put(endpoints.annotations.settingsDirectories(), dirs))
await Promise.all(tasks)
if (system) setSystemInitial(system)
if (dirs) setDirsInitial(dirs)
} catch {
setSaveError(t('settings.saveError'))
} finally {
setSaving(false)
}
}
const saveDirs = async () => {
if (!dirs) return
setSaving(true)
await api.put(endpoints.annotations.settingsDirectories(), dirs)
setSaving(false)
const cancel = () => {
setSystem(systemInitial)
setDirs(dirsInitial)
setSaveError(null)
}
const handleToggleDefault = async (a: Aircraft) => {
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
try {
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
} catch {
// best-effort — keep UI consistent on failure
}
}
const field = (label: string, value: string | number | null | undefined, onChange: (v: string) => void, type = 'text') => (
const changeLanguage = async (next: Lang) => {
await i18n.changeLanguage(next)
try { localStorage.setItem(LANG_STORAGE_KEY, next) } catch { /* private mode etc. */ }
}
const handleSignOutEverywhere = async () => {
await logout()
navigate('/login')
}
const openAircraftModal = () => {
setAircraftDraft(NEW_AIRCRAFT_DEFAULTS)
setAircraftError(null)
setAircraftModalOpen(true)
}
const closeAircraftModal = () => {
if (aircraftSaving) return
setAircraftModalOpen(false)
}
const saveAircraft = async () => {
if (!aircraftDraft.model.trim()) { setAircraftError('modelRequired'); return }
setAircraftError(null)
setAircraftSaving(true)
try {
const created = await api.post<Aircraft>(endpoints.flights.aircrafts(), aircraftDraft)
setAircrafts(prev => {
if (created.isDefault) return [...prev.map(p => ({ ...p, isDefault: false })), created]
return [...prev, created]
})
setAircraftModalOpen(false)
} catch {
setAircraftError('saveFailed')
} finally {
setAircraftSaving(false)
}
}
return (
<main className="settings-page h-full flex flex-col" style={{ background: 'var(--surface-0)' }}>
<div className="flex-1 overflow-y-auto px-6 pt-5 pb-6 flex flex-col gap-5">
<section className="flex gap-5 items-start flex-wrap">
<div className="w-[300px] shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="sect-head m-0">{t('settings.tenant')}</h2>
<span className="micro">01</span>
</div>
<BracketPanel className="p-4">
<div className="space-y-3">
<FieldText
label={t('settings.militaryUnit')}
hint={t('settings.required')}
value={system?.militaryUnit ?? ''}
onChange={v => setSystem(p => p ? { ...p, militaryUnit: v } : p)}
/>
<FieldText
label={t('settings.unitName')}
value={system?.name ?? ''}
onChange={v => setSystem(p => p ? { ...p, name: v } : p)}
/>
<div className="grid grid-cols-2 gap-3">
<FieldNumber
label={t('settings.camWidth')}
hint="PX"
suffix="px"
value={system?.defaultCameraWidth ?? 0}
onChange={v => setSystem(p => p ? { ...p, defaultCameraWidth: v } : p)}
/>
<FieldNumber
label={t('settings.camFoV')}
hint="DEG"
suffix="°"
step="0.1"
value={system?.defaultCameraFoV ?? 0}
onChange={v => setSystem(p => p ? { ...p, defaultCameraFoV: v } : p)}
/>
</div>
</div>
</BracketPanel>
</div>
<div className="w-[340px] shrink-0">
<div className="flex items-center justify-between mb-2">
<h2 className="sect-head m-0">{t('settings.directories')}</h2>
<span className="micro">02</span>
</div>
<BracketPanel className="p-4">
<div className="space-y-3">
<PathField
label={t('settings.imagesDir')}
statusLabel={t('settings.mounted')}
statusColor="var(--accent-green)"
browseLabel={t('settings.browse')}
value={dirs?.imagesDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, imagesDir: v } : p)}
/>
<PathField
label={t('settings.labelsDir')}
statusLabel={t('settings.mounted')}
statusColor="var(--accent-green)"
browseLabel={t('settings.browse')}
value={dirs?.labelsDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, labelsDir: v } : p)}
/>
<PathField
label={t('settings.thumbnailsDir')}
statusLabel={t('settings.cache')}
statusColor="var(--accent-amber)"
browseLabel={t('settings.browse')}
value={dirs?.thumbnailsDir ?? ''}
onChange={v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p)}
/>
<div
className="mt-3 pt-3 flex items-center justify-between"
style={{ borderTop: '1px solid var(--border-hair)' }}
>
<span className="micro">{t('settings.storageFree')}</span>
<span className="mono tnum" style={{ fontSize: 11, color: 'var(--text-primary)' }}>{DASH}</span>
</div>
</div>
</BracketPanel>
</div>
<div className="flex-1 min-w-[420px]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.aircrafts')}</h2>
<span className="micro">03</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
· {aircrafts.length} {t('settings.aircraftsRegistered')}
</span>
</div>
<button className="btn btn-primary" type="button" onClick={openAircraftModal}>
<span style={{ fontSize: 14, lineHeight: 1 }}>+</span>
<span>{t('settings.addAircraft')}</span>
</button>
</div>
<BracketPanel className="overflow-hidden">
<table className="w-full" style={{ borderCollapse: 'collapse' }}>
<thead>
<tr style={{ background: 'var(--surface-1)' }}>
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: '44%', fontWeight: 500 }}>
{t('settings.colModel')}
</th>
<th className="text-left micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', fontWeight: 500 }}>
{t('settings.colType')}
</th>
<th className="text-center micro" style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-hair)', width: 96, fontWeight: 500 }}>
{t('settings.colDefault')}
</th>
</tr>
</thead>
<tbody>
{aircrafts.map((a, idx) => (
<tr key={a.id} className="row-hover" style={{ borderBottom: idx === aircrafts.length - 1 ? 0 : '1px solid var(--border-hair)' }}>
<td className="mono" style={{ padding: '0 14px', height: 38, fontSize: 12, color: 'var(--text-primary)' }}>{a.model}</td>
<td style={{ padding: '0 14px', height: 38 }}>
<AircraftTypeChip type={a.type} label={t(`admin.aircrafts.${TYPE_LEGEND_KEY[a.type]}`)} />
</td>
<td className="text-center" style={{ padding: '0 14px', height: 38 }}>
<StarButton
active={a.isDefault}
onClick={() => void handleToggleDefault(a)}
aria-label={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
title={a.isDefault ? t('admin.aircrafts.default') : t('admin.aircrafts.setDefault')}
/>
</td>
</tr>
))}
{aircrafts.length === 0 && (
<tr>
<td colSpan={3} className="micro text-center" style={{ padding: '24px 14px' }}>{DASH}</td>
</tr>
)}
</tbody>
</table>
</BracketPanel>
</div>
</section>
<section className="flex gap-5 items-start flex-wrap">
<div className="flex-1 min-w-[420px]">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.language')}</h2>
<span className="micro">04</span>
</div>
<span className="micro">
{t('settings.locale')} · <span style={{ color: 'var(--text-primary)' }}>{lang === 'ua' ? 'UK-UA' : 'EN-US'}</span>
</span>
</div>
<BracketPanel className="p-4">
<div className="flex items-center gap-6 flex-wrap">
<div className="seg" role="group" aria-label={t('settings.language')}>
<button
type="button"
onClick={() => void changeLanguage('en')}
className={`seg-btn${lang === 'en' ? ' active' : ''}`}
aria-pressed={lang === 'en'}
>
EN
</button>
<button
type="button"
onClick={() => void changeLanguage('ua')}
className={`seg-btn${lang === 'ua' ? ' active' : ''}`}
aria-pressed={lang === 'ua'}
>
UA
</button>
</div>
<div className="flex flex-col">
<span className="micro">{t('settings.languageHint')}</span>
<span className="mono" style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 4 }}>
{t('settings.languageNote')}
</span>
</div>
<div className="ml-auto flex items-center gap-2 mono" style={{ fontSize: 10, color: 'var(--text-muted)' }}>
<span
className="dot live"
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-green)' }}
/>
<span>
{t('settings.languageBundle')}{' '}
<span className="tnum" style={{ color: 'var(--text-secondary)' }}>{I18N_BUNDLE_VERSION}</span>
</span>
</div>
</div>
</BracketPanel>
</div>
<div className="w-[380px] shrink-0">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3">
<h2 className="sect-head m-0">{t('settings.session')}</h2>
<span className="micro">05</span>
</div>
<span className="micro" style={{ color: 'var(--accent-cyan)' }}>{t('settings.sessionActive')}</span>
</div>
<BracketPanel className="p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex flex-col min-w-0">
<span className="micro">{t('settings.lastLogin')}</span>
<span className="mono tnum" style={{ fontSize: 12, color: 'var(--text-primary)', marginTop: 4 }}>
{DASH}
</span>
<span
className="mono truncate"
style={{ fontSize: 10, color: 'var(--text-muted)', marginTop: 2 }}
>
{user?.email ?? DASH}
</span>
</div>
<button
type="button"
onClick={() => void handleSignOutEverywhere()}
className="btn btn-danger-ghost shrink-0"
>
<SignOutIcon />
{t('settings.signOutEverywhere')}
</button>
</div>
</BracketPanel>
</div>
</section>
</div>
<div
className="shrink-0 px-6 pb-6"
style={{
background: 'linear-gradient(180deg, rgba(10,13,16,0) 0%, var(--surface-0) 50%)',
paddingTop: 16,
}}
>
<div
className="flex items-center gap-4 pt-4"
style={{ borderTop: '1px solid var(--border-hair)' }}
>
<div className="flex items-center gap-2 mono uppercase" style={{ fontSize: 10, color: 'var(--text-muted)', letterSpacing: '0.14em' }}>
{anyDirty ? (
<>
<span
className="dot live"
style={{ display: 'inline-block', width: 6, height: 6, borderRadius: '50%', background: 'var(--accent-cyan)' }}
/>
<span>
{t('settings.unsavedChanges')}{' '}
<span style={{ color: 'var(--accent-amber)' }}>{dirtyLabel}</span>
</span>
</>
) : null}
</div>
{saveError && (
<div role="alert" className="micro" style={{ color: 'var(--accent-red)', textTransform: 'none', letterSpacing: 0 }}>
{saveError}
</div>
)}
<div className="ml-auto flex items-center gap-3">
<button
type="button"
className="btn btn-ghost"
onClick={cancel}
disabled={saving || !anyDirty}
>
{t('settings.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void save()}
disabled={saving || !anyDirty}
>
<CheckIcon />
{t('settings.saveChanges')}
</button>
</div>
</div>
</div>
<Modal
open={aircraftModalOpen}
title={t('admin.aircrafts.addTitle')}
onClose={closeAircraftModal}
closeLabel={t('admin.classes.cancel')}
footer={
<>
<button
type="button"
className="btn btn-ghost"
onClick={closeAircraftModal}
disabled={aircraftSaving}
>
{t('admin.classes.cancel')}
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => void saveAircraft()}
disabled={aircraftSaving}
>
{t('admin.aircrafts.addTitle')}
</button>
</>
}
>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldModel')}</label>
<input
autoFocus
className="inp inp-mono"
value={aircraftDraft.model}
onChange={e => setAircraftDraft(p => ({ ...p, model: e.target.value }))}
placeholder="DJI Mavic 3"
aria-label={t('admin.aircrafts.fieldModel')}
/>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldType')}</label>
<div className="seg" role="group" aria-label={t('admin.aircrafts.fieldType')}>
{AIRCRAFT_TYPES.map(typ => (
<button
key={typ}
type="button"
onClick={() => setAircraftDraft(p => ({ ...p, type: typ }))}
className={`seg-btn${aircraftDraft.type === typ ? ' active' : ''}`}
aria-pressed={aircraftDraft.type === typ}
>
{t(`admin.aircrafts.${TYPE_LEGEND_KEY[typ]}`)}
</button>
))}
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldResolution')}</label>
<select
className="inp inp-mono"
value={aircraftDraft.resolution}
onChange={e => setAircraftDraft(p => ({ ...p, resolution: e.target.value }))}
aria-label={t('admin.aircrafts.fieldResolution')}
>
{RESOLUTIONS.map(r => <option key={r} value={r}>{r}</option>)}
</select>
</div>
<div>
<label className="micro block mb-1">{t('admin.aircrafts.fieldMaxMinutes')}</label>
<input
type="number"
className="inp inp-mono"
value={aircraftDraft.maxMinutes}
onChange={e => setAircraftDraft(p => ({ ...p, maxMinutes: Number(e.target.value) }))}
style={{ textAlign: 'right' }}
aria-label={t('admin.aircrafts.fieldMaxMinutes')}
/>
</div>
</div>
<label className="checkbox-row">
<input
type="checkbox"
className="checkbox"
checked={aircraftDraft.isDefault}
onChange={e => setAircraftDraft(p => ({ ...p, isDefault: e.target.checked }))}
/>
<span>{t('admin.aircrafts.fieldDefault')}</span>
</label>
{aircraftError && (
<div role="alert" style={{ color: 'var(--accent-red)', fontSize: 11 }}>
{t(`admin.aircrafts.${aircraftError}`)}
</div>
)}
</Modal>
</main>
)
}
// ===== Sub-components =====
function BracketPanel({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={className ? `bracket panel ${className}` : 'bracket panel'}>
<span className="br" />
{children}
</div>
)
}
function FieldLabel({ label, hint, hintColor }: { label: string; hint?: string; hintColor?: string }) {
return (
<div className="flex items-center justify-between mb-1.5">
<label className="micro">{label}</label>
{hint && (
<span className="mono" style={{ fontSize: 9, color: hintColor ?? 'var(--text-muted)' }}>{hint}</span>
)}
</div>
)
}
function FieldText({
label, hint, value, onChange,
}: {
label: string
hint?: string
value: string
onChange: (v: string) => void
}) {
return (
<div>
<label className="text-az-muted text-xs block mb-0.5">{label}</label>
<FieldLabel label={label} hint={hint} />
<input
type={type}
value={value ?? ''}
className="inp"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
className="w-full bg-az-bg border border-az-border rounded px-2 py-1 text-xs text-az-text outline-none focus:border-az-orange"
aria-label={label}
/>
</div>
)
}
function FieldNumber({
label, hint, suffix, value, onChange, step,
}: {
label: string
hint?: string
suffix: string
value: number
onChange: (v: number) => void
step?: string
}) {
return (
<div className="flex h-full overflow-y-auto p-4 gap-6">
{/* Tenant config */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.tenant')}</h2>
{system && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Military Unit', system.militaryUnit, v => setSystem(p => p ? { ...p, militaryUnit: v } : p))}
{field('Name', system.name, v => setSystem(p => p ? { ...p, name: v } : p))}
{field('Default Camera Width', system.defaultCameraWidth, v => setSystem(p => p ? { ...p, defaultCameraWidth: parseInt(v) || 0 } : p), 'number')}
{field('Default Camera FoV', system.defaultCameraFoV, v => setSystem(p => p ? { ...p, defaultCameraFoV: parseFloat(v) || 0 } : p), 'number')}
<button onClick={saveSystem} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Directories */}
<div className="w-[300px] shrink-0">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.directories')}</h2>
{dirs && (
<div className="bg-az-panel border border-az-border rounded p-3 space-y-2">
{field('Videos Dir', dirs.videosDir, v => setDirs(p => p ? { ...p, videosDir: v } : p))}
{field('Images Dir', dirs.imagesDir, v => setDirs(p => p ? { ...p, imagesDir: v } : p))}
{field('Labels Dir', dirs.labelsDir, v => setDirs(p => p ? { ...p, labelsDir: v } : p))}
{field('Results Dir', dirs.resultsDir, v => setDirs(p => p ? { ...p, resultsDir: v } : p))}
{field('Thumbnails Dir', dirs.thumbnailsDir, v => setDirs(p => p ? { ...p, thumbnailsDir: v } : p))}
{field('GPS Sat Dir', dirs.gpsSatDir, v => setDirs(p => p ? { ...p, gpsSatDir: v } : p))}
{field('GPS Route Dir', dirs.gpsRouteDir, v => setDirs(p => p ? { ...p, gpsRouteDir: v } : p))}
<button onClick={saveDirs} disabled={saving} className="bg-az-orange text-white text-xs px-3 py-1 rounded disabled:opacity-50">
{t('settings.save')}
</button>
</div>
)}
</div>
{/* Aircrafts */}
<div className="flex-1 max-w-sm">
<h2 className="text-sm font-semibold text-white mb-2">{t('settings.aircrafts')}</h2>
<div className="bg-az-panel border border-az-border rounded p-2 space-y-1">
{aircrafts.map(a => (
<div key={a.id} className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-az-bg text-xs text-az-text">
<span className="flex-1">{a.model}</span>
<span className={`px-1 rounded text-[10px] ${a.type === 'Plane' ? 'bg-az-blue/20 text-az-blue' : 'bg-az-green/20 text-az-green'}`}>
{a.type}
</span>
<button onClick={() => handleToggleDefault(a)} className={`text-sm ${a.isDefault ? 'text-az-orange' : 'text-az-muted hover:text-az-orange'}`}>
</button>
</div>
))}
</div>
<div>
<FieldLabel label={label} hint={hint} />
<div className="relative">
<input
className="inp inp-mono"
type="number"
step={step}
value={value}
onChange={e => onChange(step ? parseFloat(e.target.value) || 0 : parseInt(e.target.value) || 0)}
aria-label={label}
style={{ paddingRight: 36 }}
/>
<span
className="mono"
style={{
position: 'absolute', right: 10, top: '50%', transform: 'translateY(-50%)',
fontSize: 11, color: 'var(--text-muted)', pointerEvents: 'none',
}}
>
{suffix}
</span>
</div>
</div>
)
}
function PathField({
label, statusLabel, statusColor, browseLabel, value, onChange,
}: {
label: string
statusLabel: string
statusColor: string
browseLabel: string
value: string
onChange: (v: string) => void
}) {
return (
<div>
<FieldLabel label={label} hint={statusLabel} hintColor={statusColor} />
<div className="path-wrap">
<span className="path-icon"><FolderIcon /></span>
<input
className="inp inp-mono"
type="text"
value={value}
onChange={e => onChange(e.target.value)}
aria-label={label}
/>
<button type="button" aria-label={browseLabel} className="browse">
{browseLabel}
</button>
</div>
</div>
)
}
function AircraftTypeChip({ type, label }: { type: Aircraft['type']; label: string }) {
const color = TYPE_CHIP_COLOR[type]
return (
<span
className="inline-flex items-center gap-1.5 mono uppercase"
style={{
fontSize: 10, letterSpacing: '0.12em',
padding: '2px 8px', borderRadius: 2,
border: `1px solid ${TYPE_CHIP_BORDER[type]}`,
color, background: 'transparent',
}}
>
<span style={{ width: 6, height: 6, borderRadius: '50%', background: color, display: 'inline-block' }} />
{label}
</span>
)
}
function StarButton({
active, onClick, ...rest
}: {
active: boolean
onClick: () => void
} & React.ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button
type="button"
onClick={onClick}
className={active ? 'star active' : 'star'}
aria-pressed={active}
{...rest}
>
{active ? '★' : '☆'}
</button>
)
}
+65 -4
View File
@@ -85,18 +85,47 @@
},
"annotations": {
"title": "Annotations",
"mediaList": "Media",
"mediaList": "Media Files",
"filterByName": "filter by name…",
"upload": "Upload Files",
"deleteMedia": "Delete media?",
"detect": "AI Detect",
"detectInProgress": "AI DETECTION IN PROGRESS",
"save": "Save",
"delete": "Delete",
"deleteAll": "Delete All",
"deleteAllTitle": "Delete all on frame",
"classes": "Detection Classes",
"photoMode": "Photo Mode",
"photoMode": "PhotoMode",
"regular": "Regular",
"winter": "Winter",
"night": "Night"
"night": "Night",
"colName": "NAME",
"colKey": "KEY",
"colNum": "#",
"colTime": "TIME",
"colClass": "CLASS",
"colConf": "CONF",
"canvas": "Canvas",
"zoom": "ZOOM",
"cursor": "CURSOR",
"frameStep": "FRAME STEP",
"frame": "FRAME",
"summary": "SUMMARY",
"emptyFrame": "empty frame",
"filter": "Filter",
"sort": "Sort",
"play": "Play",
"pause": "Pause",
"previousMedia": "Previous media",
"nextMedia": "Next media",
"back5s": "Back 5s",
"forward5s": "Forward 5s",
"mute": "Mute",
"selectMedia": "Select a media file to start",
"annCount_one": "{{count}} ann",
"annCount_other": "{{count}} ann",
"emptyCount": "{{count}} empty"
},
"dataset": {
"title": "Dataset Explorer",
@@ -189,7 +218,39 @@
"tenant": "Tenant Configuration",
"directories": "Directories",
"aircrafts": "Aircrafts",
"save": "Save"
"save": "Save",
"militaryUnit": "Military Unit",
"unitName": "Name",
"camWidth": "Cam Width",
"camFoV": "Cam FoV",
"required": "REQ",
"imagesDir": "Images Dir",
"labelsDir": "Labels Dir",
"thumbnailsDir": "Thumbnails Dir",
"mounted": "MOUNTED",
"cache": "CACHE",
"browse": "Browse",
"storageFree": "Storage Free",
"aircraftsRegistered": "REGISTERED",
"addAircraft": "Add Aircraft",
"colModel": "Model",
"colType": "Type",
"colDefault": "Default",
"language": "Language",
"languageHint": "Affects all UI text",
"languageNote": "Detection class names also use the localized field from seed data.",
"languageBundle": "i18n BUNDLE",
"locale": "Locale",
"session": "Session",
"sessionActive": "ACTIVE",
"lastLogin": "Last Login",
"signOutEverywhere": "Sign out everywhere",
"cancel": "Cancel",
"saveChanges": "Save Changes",
"saveError": "Save failed. Please try again.",
"unsavedChanges": "Unsaved changes detected in",
"unitTenant": "TENANT",
"unitDirectories": "DIRECTORIES"
},
"common": {
"confirm": "Confirm",
+13 -1
View File
@@ -3,9 +3,21 @@ import { initReactI18next } from 'react-i18next'
import en from './en.json'
import ua from './ua.json'
export const LANG_STORAGE_KEY = 'azaion.lang'
function readPersistedLanguage(): 'en' | 'ua' {
// Safari private mode throws on localStorage access — fall back to 'en'.
try {
const persisted = localStorage.getItem(LANG_STORAGE_KEY)
return persisted === 'ua' || persisted === 'en' ? persisted : 'en'
} catch {
return 'en'
}
}
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, ua: { translation: ua } },
lng: 'en',
lng: readPersistedLanguage(),
fallbackLng: 'en',
interpolation: { escapeValue: false },
})
+1 -1
View File
@@ -1 +1 @@
export { default } from './i18n'
export { default, LANG_STORAGE_KEY } from './i18n'
+66 -3
View File
@@ -85,18 +85,49 @@
},
"annotations": {
"title": "Анотації",
"mediaList": "Медіа",
"mediaList": "Медіа файли",
"filterByName": "фільтр за назвою…",
"upload": "Завантажити файли",
"deleteMedia": "Видалити медіа?",
"detect": "AI Розпізнавання",
"detectInProgress": "AI РОЗПІЗНАВАННЯ ТРИВАЄ",
"save": "Зберегти",
"delete": "Видалити",
"deleteAll": "Видалити все",
"deleteAllTitle": "Видалити все на кадрі",
"classes": "Класи детекцій",
"photoMode": "Режим фото",
"regular": "Звичайний",
"winter": "Зимовий",
"night": "Нічний"
"night": "Нічний",
"colName": "НАЗВА",
"colKey": "КЛВ",
"colNum": "№",
"colTime": "ЧАС",
"colClass": "КЛАС",
"colConf": "ВПЕВ",
"canvas": "Канва",
"zoom": "ЗУМ",
"cursor": "КУРСОР",
"frameStep": "КРОК КАДРУ",
"frame": "КАДР",
"summary": "ПІДСУМОК",
"emptyFrame": "порожній кадр",
"filter": "Фільтр",
"sort": "Сортувати",
"play": "Програти",
"pause": "Пауза",
"previousMedia": "Попереднє медіа",
"nextMedia": "Наступне медіа",
"back5s": "Назад 5с",
"forward5s": "Вперед 5с",
"mute": "Без звуку",
"selectMedia": "Оберіть файл медіа щоб почати",
"annCount_one": "{{count}} анот.",
"annCount_few": "{{count}} анот.",
"annCount_many": "{{count}} анот.",
"annCount_other": "{{count}} анот.",
"emptyCount": "{{count}} порожн."
},
"dataset": {
"title": "Датасет",
@@ -189,7 +220,39 @@
"tenant": "Конфігурація",
"directories": "Директорії",
"aircrafts": "Літальні апарати",
"save": "Зберегти"
"save": "Зберегти",
"militaryUnit": "Військова частина",
"unitName": "Назва",
"camWidth": "Ширина кадру",
"camFoV": "Кут огляду",
"required": "ОБ.",
"imagesDir": "Директорія зображень",
"labelsDir": "Директорія міток",
"thumbnailsDir": "Директорія мініатюр",
"mounted": "ПІД'ЄДНАНО",
"cache": "КЕШ",
"browse": "Огляд",
"storageFree": "Вільно",
"aircraftsRegistered": "ЗАРЕЄСТРОВАНО",
"addAircraft": "Додати апарат",
"colModel": "Модель",
"colType": "Тип",
"colDefault": "За замовч.",
"language": "Мова",
"languageHint": "Впливає на весь UI",
"languageNote": "Назви класів детекцій теж беруться з локалізованого поля seed-даних.",
"languageBundle": "i18n БАНДЛ",
"locale": "Локаль",
"session": "Сесія",
"sessionActive": "АКТИВНА",
"lastLogin": "Останній вхід",
"signOutEverywhere": "Вийти всюди",
"cancel": "Скасувати",
"saveChanges": "Зберегти зміни",
"saveError": "Не вдалося зберегти. Спробуйте ще раз.",
"unsavedChanges": "Незбережені зміни в",
"unitTenant": "КОНФІГУРАЦІЇ",
"unitDirectories": "ДИРЕКТОРІЯХ"
},
"common": {
"confirm": "Підтвердити",
+258 -1
View File
@@ -209,6 +209,15 @@ body {
border-color: var(--border-hair);
}
.btn-ghost:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-raised); }
.btn-danger-ghost {
background: transparent;
color: var(--accent-red);
border-color: rgba(255,71,86,0.5);
}
.btn-danger-ghost:hover:not(:disabled) {
background: rgba(255,71,86,0.08);
border-color: var(--accent-red);
}
.btn-danger {
background: var(--accent-red);
color: #0A0D10;
@@ -336,10 +345,79 @@ header .ibtn.danger:hover { color: var(--accent-red); border-color: var(--accent
border-radius: 2px;
}
/* Settings v2 — settings.html mock specs larger buttons than admin.html mock.
Scope to the Settings page only so Admin keeps its tighter spec.
line-height: 1.5 matches the mock's body inheritance (its .btn doesn't use
the font shorthand, so it inherits body's line-height instead of "normal"). */
.settings-page .btn {
height: auto;
padding: 7px 14px;
font-weight: 400;
line-height: 1.5;
letter-spacing: 0.10em;
gap: 8px;
}
.settings-page .seg-btn {
height: auto;
padding: 7px 18px;
font-size: 11px;
line-height: 1.5;
letter-spacing: 0.14em;
font-weight: 400;
}
.settings-page .seg-btn.active { font-weight: 600; border-right: 0; }
.settings-page .seg-btn + .seg-btn { border-left: 1px solid var(--border-hair); }
.settings-page .btn-primary:hover:not(:disabled) { filter: brightness(1.05); }
/* Star button */
.star { color: var(--accent-amber); }
.star {
background: transparent;
border: 0;
cursor: pointer;
color: var(--text-muted);
font-size: 18px;
line-height: 1;
padding: 4px;
transition: color .12s, transform .12s;
}
.star:hover { color: var(--accent-amber); }
.star.active { color: var(--accent-amber); }
.star-off { color: var(--text-muted); }
/* Path input with Browse button (Settings v2 directories panel). */
.path-wrap { position: relative; display: flex; align-items: center; }
.path-wrap .path-icon {
position: absolute;
left: 10px;
color: var(--text-muted);
display: flex;
align-items: center;
pointer-events: none;
}
.path-wrap .browse {
position: absolute;
right: 4px;
top: 4px;
height: 24px;
padding: 0 10px;
font-family: 'JetBrains Mono', monospace;
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
background: transparent;
border: 1px solid var(--border-hair);
border-radius: 2px;
cursor: pointer;
transition: color .12s, border-color .12s, background .12s;
}
.path-wrap .browse:hover {
color: var(--accent-amber);
border-color: var(--accent-amber);
background: rgba(255,157,61,0.06);
}
.path-wrap > input.inp { padding-left: 30px; padding-right: 70px; }
/* Pulse for live dot */
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.35; } }
.live { animation: pulse 1.6s ease-in-out infinite; }
@@ -366,3 +444,182 @@ select.inp {
::-webkit-scrollbar-track { background: var(--surface-0); }
::-webkit-scrollbar-thumb { background: #1f2630; border-radius: 2px; }
::-webkit-scrollbar-thumb:hover { background: #2a323e; }
/* =========================================================================
ANNOTATIONS PAGE — v2 surfaces
========================================================================= */
/* Splitter affordance between resizable panes */
.split { width: 4px; cursor: col-resize; background: transparent; position: relative; }
.split::after {
content: ''; position: absolute; left: 1px; top: 0; bottom: 0; width: 1px;
background: var(--border-hair);
}
.split:hover::after { background: var(--accent-amber); }
/* Media list row (264px left aside) */
.media-row {
position: relative;
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
align-items: center;
height: 32px; padding: 0 12px 0 14px;
border-bottom: 1px solid var(--border-hair);
cursor: pointer; user-select: none;
}
.media-row:hover { background: var(--surface-2); }
.media-row.active { background: var(--surface-2); }
.media-row.active::before {
content: ''; position: absolute; left: 0; top: 0; bottom: 0;
width: 2px; background: var(--accent-amber);
}
/* Type chips inside media rows */
.chip-photo {
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 16px; border-radius: 2px;
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
color: var(--accent-cyan); border: 1px solid rgba(54,214,197,0.45);
background: rgba(54,214,197,0.06);
}
.chip-video {
display: inline-flex; align-items: center; justify-content: center;
width: 40px; height: 16px; border-radius: 2px;
font: 600 9px/1 'JetBrains Mono', monospace; letter-spacing: 0.1em;
color: var(--accent-amber); border: 1px solid rgba(255,157,61,0.45);
background: rgba(255,157,61,0.06);
}
/* Detection class row */
.class-row {
display: grid; grid-template-columns: 16px 1fr auto; gap: 10px;
align-items: center; height: 28px; padding: 0 12px;
border-bottom: 1px solid var(--border-hair);
cursor: pointer;
}
.class-row:hover { background: var(--surface-2); }
.class-row.active { background: var(--surface-2); }
.class-row.active .kbd { color: var(--accent-amber); border-color: var(--accent-amber); }
/* Keycap chip */
.kbd {
display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 16px; padding: 0;
font: 600 10px/1 'JetBrains Mono', monospace;
color: var(--text-muted); border: 1px solid var(--border-hair); border-radius: 2px;
background: var(--surface-0);
}
/* Annotation row in right sidebar (gradient stripe via --row-grad) */
.ann-row {
position: relative;
display: grid; grid-template-columns: 44px 1fr auto; gap: 8px;
align-items: center;
height: 36px; padding: 0 12px;
border-bottom: 1px solid var(--border-hair);
cursor: pointer;
background-color: var(--surface-1);
}
.ann-row::after {
content: ''; position: absolute; left: 0; right: 0; top: 0; bottom: 0;
background-image: var(--row-grad, none);
pointer-events: none;
}
.ann-row > * { position: relative; z-index: 1; }
.ann-row:hover { background-color: var(--surface-2); }
.ann-row.active { background-color: var(--surface-2); }
/* Faux terrain wash behind canvas */
.terrain {
background-color: #11181B;
background-image:
radial-gradient(900px 500px at 30% 40%, rgba(48,72,60,0.45), transparent 60%),
radial-gradient(700px 400px at 75% 65%, rgba(40,52,68,0.35), transparent 65%),
radial-gradient(400px 300px at 60% 30%, rgba(82,64,40,0.18), transparent 70%),
linear-gradient(rgba(255,255,255,0.025) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.025) 1px, transparent 1px);
background-size: auto, auto, auto, 48px 48px, 48px 48px;
}
/* Floating AI Detection banner over canvas */
.ai-banner {
backdrop-filter: blur(6px);
background: rgba(10,13,16,0.78);
border: 1px solid rgba(54,214,197,0.4);
border-radius: 2px;
}
/* Bounding-box label chip (DOM overlay on canvas) */
.bbox-label {
display: inline-flex; align-items: center; gap: 6px;
height: 22px; padding: 0 8px;
font: 600 10px/1 'JetBrains Mono', monospace; letter-spacing: 0.08em;
text-transform: uppercase;
border-radius: 2px;
background: rgba(10,13,16,0.92);
color: var(--text-primary);
border: 1px solid var(--border-hair);
white-space: nowrap;
}
.bbox-label .conf { color: var(--text-secondary); font-weight: 500; }
/* Selection handles on bounding boxes */
.handle {
position: absolute; width: 6px; height: 6px;
background: var(--accent-amber); border: 1px solid #0A0D10;
pointer-events: none;
}
/* Scrubber (timeline with annotation marks) */
.scrub {
height: 4px; background: var(--surface-2); border: 1px solid var(--border-hair);
border-radius: 2px; position: relative; cursor: pointer;
}
.scrub .fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--accent-amber); pointer-events: none; }
.scrub .head {
position: absolute; top: 50%; width: 2px; height: 10px; background: var(--accent-amber);
transform: translate(-50%, -50%); pointer-events: none;
}
.scrub .head-knob {
position: absolute; top: 50%; width: 12px; height: 12px;
background: var(--accent-amber);
border: 2px solid var(--surface-1);
border-radius: 999px;
transform: translate(-50%, -50%);
box-shadow: 0 0 0 1px var(--accent-amber), 0 0 8px rgba(255,157,61,0.45);
z-index: 2;
cursor: grab;
}
.scrub .head-knob:active { cursor: grabbing; }
.scrub .tick {
position: absolute; top: 50%; width: 1px; height: 6px; background: var(--text-muted);
transform: translateY(-50%); pointer-events: none;
}
.scrub .mark {
position: absolute; top: -3px; width: 2px; height: 10px; pointer-events: none;
}
/* Volume slider (range input next to mute) */
.vol {
appearance: none; -webkit-appearance: none;
height: 2px; width: 72px; background: var(--border-hair); outline: none; border-radius: 2px;
}
.vol::-webkit-slider-thumb {
-webkit-appearance: none; appearance: none;
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer;
}
.vol::-moz-range-thumb {
width: 10px; height: 10px; background: var(--accent-amber); border-radius: 0; cursor: pointer; border: 0;
}
/* Live pulse dot (cyan glow) — annotations LIVE indicators */
.live-dot {
width: 6px; height: 6px; border-radius: 999px;
background: var(--accent-cyan);
box-shadow: 0 0 0 0 rgba(54,214,197,0.5);
animation: live-pulse 1.6s ease-in-out infinite;
display: inline-block; flex: none;
}
@keyframes live-pulse {
0%,100% { box-shadow: 0 0 0 0 rgba(54,214,197,0.5); }
50% { box-shadow: 0 0 0 6px rgba(54,214,197,0); }
}
+2
View File
@@ -8,6 +8,8 @@ interface ImportMetaEnv {
readonly VITE_OWM_API_KEY?: string
readonly VITE_OWM_BASE_URL?: string
readonly VITE_SATELLITE_TILE_URL?: string
/** Dev-only: when 'true', skip backend auth and inject a fake admin user. */
readonly VITE_DEV_AUTH_BYPASS?: string
}
interface ImportMeta {
+62 -139
View File
@@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { http, HttpResponse } from 'msw'
import { server } from './msw/server'
import { jsonResponse } from './msw/helpers'
import { renderWithProviders, screen, waitFor, userEvent, within } from './helpers/render'
import { renderWithProviders, screen, waitFor, userEvent } from './helpers/render'
import { seedBearer, clearBearer } from './helpers/auth'
import { SettingsPage } from '../src/features/settings'
import { seedAircraft } from './fixtures/seed_aircraft'
@@ -18,16 +18,9 @@ import type { SystemSettings, DirectorySettings } from '../src/types'
// AC-3 (NFT-PERF-09) — Deadline: wall-clock from PUT response/error
// to error visibility ≤ 2 s.
//
// Production today (`SettingsPage.saveSystem` / `saveDirs`) does
// setSaving(true); await api.put(...); setSaving(false)
// with no try/finally and no error region in the JSX. Both AC-1 and AC-2 are
// drift today: the button stays disabled forever and no alert appears. The
// AC-3 deadline assertion is also vacuously failing (no DOM element to find).
// We mark the contract assertions `it.fails()` and pin the current drift with
// control tests, so:
// - The drift is documented in the test suite.
// - The contract tests will start passing the moment SettingsPage wires
// try/finally + an error region — no edits to this file required.
// v2 SettingsPage wraps `save()` in try/catch/finally and renders an inline
// role="alert" in the sticky footer when the PUT rejects. The three contract
// tests below assert that wiring directly.
const SYSTEM_SEED: SystemSettings = {
id: 'sys-1',
@@ -84,163 +77,93 @@ function rigSettingsEnv(failure: SettingsFailure): SettingsRig {
}
/**
* SettingsPage renders two "Save" buttons (one per panel) once both GETs
* resolve. We always exercise the *system* panel its handler (`saveSystem`)
* has the same try-finally drift as `saveDirs`, and scoping the query to
* "Tenant Configuration" makes the selector unambiguous regardless of which
* GET resolves first.
* SettingsPage (v2) renders a single sticky-footer "Save Changes" button that
* persists whichever panels are dirty in parallel. The footer button is the
* only Save affordance; per-panel Save buttons no longer exist. We must mark
* the Tenant panel as dirty by editing a field before the footer button
* becomes enabled selecting the Military Unit input by accessible name and
* typing a single character is enough to flip the dirty flag.
*/
async function findSystemSaveButton(): Promise<HTMLElement> {
const systemHeading = await screen.findByRole('heading', { name: /Tenant Configuration/i })
const panel = systemHeading.parentElement as HTMLElement
return within(panel).getByRole('button', { name: /^Save$/i })
// Wait until the data has loaded (heading is present immediately, but the
// input is rendered only after the GET resolves).
await screen.findByRole('heading', { name: /Tenant Configuration/i })
return screen.getByRole('button', { name: /^Save Changes$/i })
}
async function makeTenantDirty(): Promise<void> {
const militaryUnit = await screen.findByLabelText(/Military Unit/i)
await userEvent.type(militaryUnit, '!')
}
async function renderAndClickSave(): Promise<void> {
renderWithProviders(<SettingsPage />)
await makeTenantDirty()
const saveButton = await findSystemSaveButton()
await userEvent.click(saveButton)
}
describe('AZ-477 — Settings save resilience + 2 s error budget', () => {
// Production today has no try/catch around the settings-save api.put().
// When MSW returns 500 (or HttpResponse.error()), the rejected promise
// becomes an unhandled rejection at the process level and Vitest fails
// the run with exit code 1 — even though every test assertion passes.
// This handler swallows the *expected* rejection pattern only, so any
// unexpected unhandled rejection still surfaces as a hard failure.
// The drift itself is asserted by the it.fails() contract tests above
// ("Save button stays disabled" / "no DOM error region").
let suppressedRejections: unknown[] = []
const onUnhandled = (reason: unknown): void => {
const msg =
reason instanceof Error
? reason.message
: typeof reason === 'string'
? reason
: ''
if (
msg.startsWith('500: upstream failure') ||
msg.startsWith('Failed to fetch') ||
msg === 'Network error' ||
msg.includes('network error')
) {
suppressedRejections.push(reason)
return
}
// Re-throw — surface unexpected rejections to the test runner.
throw reason
}
beforeEach(() => {
seedBearer()
suppressedRejections = []
process.on('unhandledRejection', onUnhandled)
})
afterEach(() => {
clearBearer()
process.off('unhandledRejection', onUnhandled)
// Sanity: every test in this file expects exactly one swallowed
// rejection (the settings PUT). If a test triggers more — or zero — the
// drift assumption changed and the harness should flag it.
if (suppressedRejections.length > 1) {
throw new Error(
`AZ-477 harness: expected at most 1 suppressed rejection, got ${suppressedRejections.length}`,
)
}
})
describe('AC-1 (FT-N-13 / NFT-RES-05) — 500 recovery', () => {
it.fails(
'PUT 500 → Save button is no longer disabled within 2 s',
async () => {
// Drift: saveSystem awaits api.put() outside a try/finally; on a
// rejected promise the trailing `setSaving(false)` is never reached
// and the button stays disabled forever.
rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
const saveButton = await findSystemSaveButton()
await waitFor(
() => expect(saveButton).not.toBeDisabled(),
{ timeout: 2000 },
)
},
)
it.fails(
'PUT 500 → an in-DOM error region (role="alert") appears within 2 s',
async () => {
// Drift: SettingsPage renders no error region. Will pass once a
// toast / inline alert is wired into the save handler.
rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
// Message shape: production task picks the i18n key; the test only
// asserts that *some* user-visible error text is present.
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
},
)
it('control: today the Save button stays disabled after a 500 (current drift)', async () => {
// Pins the silent-failure drift: button remains in `disabled` state
// because setSaving(false) is unreachable.
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
it('PUT 500 → Save button is no longer disabled within 2 s', async () => {
rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
await waitFor(() => expect(rig.systemPuts).toBe(1))
// Wait briefly past the response; the button must stay disabled
// (drift: setSaving(false) is unreachable past the rejected await).
await new Promise((r) => setTimeout(r, 100))
const saveButton = await findSystemSaveButton()
expect(saveButton).toBeDisabled()
await waitFor(
() => expect(saveButton).not.toBeDisabled(),
{ timeout: 2000 },
)
})
it('PUT 500 → an in-DOM error region (role="alert") appears within 2 s', async () => {
rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
// Message shape: production task picks the i18n key; the test only
// asserts that *some* user-visible error text is present.
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
})
})
describe('AC-2 (FT-N-14 / NFT-RES-06) — network drop', () => {
it.fails(
'network error → Save button is no longer disabled within 2 s',
async () => {
rigSettingsEnv({ kind: 'network' })
await renderAndClickSave()
const saveButton = await findSystemSaveButton()
await waitFor(
() => expect(saveButton).not.toBeDisabled(),
{ timeout: 2000 },
)
},
)
it('network error → Save button is no longer disabled within 2 s', async () => {
rigSettingsEnv({ kind: 'network' })
await renderAndClickSave()
const saveButton = await findSystemSaveButton()
await waitFor(
() => expect(saveButton).not.toBeDisabled(),
{ timeout: 2000 },
)
})
it.fails(
'network error → an in-DOM error region (role="alert") appears within 2 s',
async () => {
rigSettingsEnv({ kind: 'network' })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
},
)
it('network error → an in-DOM error region (role="alert") appears within 2 s', async () => {
rigSettingsEnv({ kind: 'network' })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2000 })
expect((alertEl.textContent ?? '').trim().length).toBeGreaterThan(0)
})
})
describe('AC-3 (NFT-PERF-09) — deadline ≤ 2 s', () => {
it.fails(
'500 → DOM error region visible within 2000 ms of the response',
async () => {
// The deadline is measured from the moment the 500 response is
// returned by MSW (rig.responseAt.value) to the moment role="alert"
// is found. Today the alert never appears; the assertion is set so
// it will pass the moment the alert is wired AND comes up under the
// 2-second budget.
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
const alertVisibleAt = performance.now()
expect(rig.responseAt.value).not.toBeNull()
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
expect(elapsed).toBeGreaterThanOrEqual(0)
expect(elapsed).toBeLessThanOrEqual(2000)
expect(alertEl).toBeInTheDocument()
},
)
it('500 → DOM error region visible within 2000 ms of the response', async () => {
const rig = rigSettingsEnv({ kind: 'http', status: 500 })
await renderAndClickSave()
const alertEl = await screen.findByRole('alert', {}, { timeout: 2500 })
const alertVisibleAt = performance.now()
expect(rig.responseAt.value).not.toBeNull()
const elapsed = alertVisibleAt - (rig.responseAt.value as number)
// Elapsed must be ≥ 0 (response landed first) AND ≤ 2000 ms.
expect(elapsed).toBeGreaterThanOrEqual(0)
expect(elapsed).toBeLessThanOrEqual(2000)
expect(alertEl).toBeInTheDocument()
})
})
})