diff --git a/.claude/commands/create-defect.md b/.claude/commands/create-defect.md
index 82e19e9..935c7a5 100644
--- a/.claude/commands/create-defect.md
+++ b/.claude/commands/create-defect.md
@@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the bug involves component logic, a service, or routing:
+
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
- Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting.
@@ -56,6 +57,7 @@ Environment / Notes
```
Rules:
+
- Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant.
@@ -64,6 +66,7 @@ Rules:
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
+
- Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link?
@@ -73,6 +76,7 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
+>
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -86,6 +90,7 @@ If ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
+
- `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown)
diff --git a/.claude/commands/create-story.md b/.claude/commands/create-story.md
index 25edb03..8ede476 100644
--- a/.claude/commands/create-story.md
+++ b/.claude/commands/create-story.md
@@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints:
+
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting.
@@ -53,6 +54,7 @@ Implementation Notes
```
Rules:
+
- User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
@@ -61,6 +63,7 @@ Rules:
Show the draft to the user.
**Use `AskUserQuestion` tool to ask:**
+
- Are the acceptance criteria complete and correct?
- Any implementation constraints to add?
- Priority (if known)?
@@ -70,6 +73,7 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project
> **Project routing rules (always apply these):**
+>
> - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -83,6 +87,7 @@ If still ambiguous, ask the user.
## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with:
+
- `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown)
@@ -96,14 +101,14 @@ After creation, ask the user (use `AskUserQuestion` if interactive, otherwise in
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
-| Situation | Relation to use |
-|-----------|----------------|
-| This story must be done before another | `blocks` |
-| Another story must be done before this | `is blocked by` |
-| Stories share domain or are related | `relates to` |
-| This is a child of an epic/story | `subtask of` |
-| This is a parent grouping subtasks | `parent for` |
-| This depends on another ticket's output | `depends on` |
+| Situation | Relation to use |
+| --------------------------------------- | --------------- |
+| This story must be done before another | `blocks` |
+| Another story must be done before this | `is blocked by` |
+| Stories share domain or are related | `relates to` |
+| This is a child of an epic/story | `subtask of` |
+| This is a parent grouping subtasks | `parent for` |
+| This depends on another ticket's output | `depends on` |
If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link.
diff --git a/.claude/commands/estimate-issues.md b/.claude/commands/estimate-issues.md
index cc8ed37..153f696 100644
--- a/.claude/commands/estimate-issues.md
+++ b/.claude/commands/estimate-issues.md
@@ -7,10 +7,12 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set):
+
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty):
+
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
- Collect all returned issues.
@@ -18,6 +20,7 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 2 — Build Issue Tree
For each top-level issue from Step 1:
+
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
3. Recursively fetch subtasks until all leaves are known.
@@ -29,17 +32,18 @@ For each top-level issue from Step 1:
## Step 3 — Estimate Leaf Nodes
For each leaf node:
+
1. Read: summary, description, acceptance criteria, implementation notes.
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
3. Assign estimate using this scale:
-| Size | Criteria | Estimate |
-|------|----------|----------|
-| Trivial | Style tweak, copy change, 1-file tweak | 30m |
-| Small | 1–3 files, single component/service, no unknowns | 1h–2h |
-| Medium | 3–6 files, new component + service wiring, some design | 3h–5h |
-| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d |
-| XL | New feature area, major refactor, research spike | 3d–5d |
+| Size | Criteria | Estimate |
+| ------- | ------------------------------------------------------ | -------- |
+| Trivial | Style tweak, copy change, 1-file tweak | 30m |
+| Small | 1–3 files, single component/service, no unknowns | 1h–2h |
+| Medium | 3–6 files, new component + service wiring, some design | 3h–5h |
+| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d |
+| XL | New feature area, major refactor, research spike | 3d–5d |
4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
@@ -49,6 +53,7 @@ For each leaf node:
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals:
+
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up).
@@ -68,11 +73,13 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom:
+
```
Sprint total: Xd Yh Zm (N issues, M leaves to update)
```
**Use `AskUserQuestion` tool:**
+
- Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack?
@@ -81,6 +88,7 @@ Incorporate all feedback before proceeding.
## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order):
+
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated.
diff --git a/.claude/commands/fix-defect.md b/.claude/commands/fix-defect.md
index e46441c..71aee61 100644
--- a/.claude/commands/fix-defect.md
+++ b/.claude/commands/fix-defect.md
@@ -3,6 +3,7 @@
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
+
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -15,6 +16,7 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst
## Step 2 — Create Worktree
Derive branch name from ticket:
+
- `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -42,6 +44,7 @@ After root cause confirmed, assess scope:
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks:
+
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
2. For each subtask call `mcp__youtrack__create_issue` with:
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
@@ -66,7 +69,7 @@ Then proceed to Step 4, implementing subtasks in order.
5. Run `npx prettier --check .` — **blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to Step 5 until the build, tests and format check all pass.**
-If any step fails, iterate until all pass.
+ If any step fails, iterate until all pass.
## Step 5 — Review
@@ -76,6 +79,7 @@ Display findings grouped by severity.
## Step 5b — Apply Review Findings
If the review produced any findings (any severity):
+
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -125,12 +129,12 @@ Files changed:
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
-| Situation | Relation |
-|-----------|---------|
-| This fix blocks another open ticket | `blocks` |
-| Another ticket must ship first | `is blocked by` |
-| Related defect or story | `relates to` |
-| Duplicate of another defect | `duplicates` |
+| Situation | Relation |
+| ----------------------------------- | --------------- |
+| This fix blocks another open ticket | `blocks` |
+| Another ticket must ship first | `is blocked by` |
+| Related defect or story | `relates to` |
+| Duplicate of another defect | `duplicates` |
Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md
index fa2edb1..fb02ce1 100644
--- a/.claude/commands/implement-feature.md
+++ b/.claude/commands/implement-feature.md
@@ -4,6 +4,7 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates:
+
- Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -40,6 +41,7 @@ are collected and reported at the end with a ready-to-run prompt.
## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`:
+
- `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -83,6 +85,7 @@ Display findings grouped by severity.
## Step 6b — Apply Review Findings
If the review produced any findings (any severity):
+
1. Implement all agreed fixes.
2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -139,10 +142,12 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
Final report to the user, in two sections:
### Blocked in-project tasks
+
List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
that stopped them. (These can be re-run with this command once blockers clear.)
### Cross-project tasks (NCS / NCI / other)
+
For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry:
diff --git a/.claude/commands/split-story.md b/.claude/commands/split-story.md
index 40bcf1e..1ebe9b1 100644
--- a/.claude/commands/split-story.md
+++ b/.claude/commands/split-story.md
@@ -12,6 +12,7 @@ Extract and display: summary, description, acceptance criteria, implementation n
## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints:
+
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits.
@@ -19,6 +20,7 @@ If the story involves unfamiliar UI flows or technical constraints:
## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules:
+
- Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
@@ -26,6 +28,7 @@ Analyse the story and propose a set of subtasks. Rules:
Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:**
+
- Does the split look right?
- Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person?
@@ -57,6 +60,7 @@ What must be true for this subtask to be considered complete:
```
Rules:
+
- Steps/Tasks: concrete, ordered where order matters.
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
- Keep description short — one paragraph max.
@@ -74,6 +78,7 @@ If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with:
+
- `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown)
@@ -84,6 +89,7 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN
## Step 6b — Inter-Subtask Links
If subtasks must be done in sequence (one depends on output of another), add ordering links:
+
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
Ask the user to confirm sequencing before adding these links:
@@ -94,12 +100,12 @@ Ask the user to confirm sequencing before adding these links:
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
-| Situation | Relation |
-|-----------|---------|
-| Parent story blocks another epic/story | `blocks` |
+| Situation | Relation |
+| ---------------------------------------- | --------------- |
+| Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` |
-| Related story in same domain | `relates to` |
-| This story duplicates or supersedes | `duplicates` |
+| Related story in same domain | `relates to` |
+| This story duplicates or supersedes | `duplicates` |
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 643029b..ca63010 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,58 +1,77 @@
+## [0.3.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.3.0) (2026-06-10)
+
+### Features
+
+- NCWF-5 Scaffold post-game analysis page at /analysis with board viewer and route
+- NCWF-6 FEN / PGN / Game-ID input form for custom analysis
+- NCWF-7 Integrate backend analysis API (POST /api/analysis/position) via GameApiService
+- NCWF-8 Per-move evaluation timeline SVG chart
+- NCWF-9 Annotated move list with quality labels (!! ! ?! ? ??)
+
## 0.0.0 (2026-05-12)
### Features
-* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
-* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
-* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
-* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
+- added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
+- added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
+- NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
+- NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
### Bug Fixes
-* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
-* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
-* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
-* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
-* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
-* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
-* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
-* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
-* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
+- build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
+- cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
+- gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
+- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
+- npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
+- removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
+- removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
+- size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
+- structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
### Features
-* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
+- NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes
-* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
+- NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes
-* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
+- NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes
-* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
+- added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
### Bug Fixes
-* build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
-* NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
+- build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
+- NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
### Bug Fixes
-* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
+- NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
+
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### Bug Fixes
-* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
+- route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
diff --git a/angular.json b/angular.json
index 53f40a6..d6c8d66 100644
--- a/angular.json
+++ b/angular.json
@@ -18,9 +18,7 @@
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
- "polyfills": [
- "zone.js"
- ],
+ "polyfills": ["zone.js"],
"tsConfig": "tsconfig.app.json",
"assets": [
{
@@ -38,9 +36,7 @@
"output": "/assets/ChessAssets"
}
],
- "styles": [
- "src/styles.css"
- ]
+ "styles": ["src/styles.css"]
},
"configurations": {
"production": {
@@ -106,10 +102,7 @@
"test": {
"builder": "@angular/build:karma",
"options": {
- "polyfills": [
- "zone.js",
- "zone.js/testing"
- ],
+ "polyfills": ["zone.js", "zone.js/testing"],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
@@ -127,9 +120,7 @@
"output": "/assets/ChessAssets"
}
],
- "styles": [
- "src/styles.css"
- ]
+ "styles": ["src/styles.css"]
}
}
}
diff --git a/docs/board-api-spec.yaml b/docs/board-api-spec.yaml
index 61bf241..b6947ef 100644
--- a/docs/board-api-spec.yaml
+++ b/docs/board-api-spec.yaml
@@ -55,7 +55,6 @@ tags:
description: Export a game as FEN or PGN
paths:
-
# ---------------------------------------------------------------------------
# Game lifecycle
# ---------------------------------------------------------------------------
@@ -467,7 +466,6 @@ paths:
# =============================================================================
components:
-
securitySchemes:
bearerAuth:
type: http
@@ -517,7 +515,6 @@ components:
$ref: '#/components/schemas/ApiError'
schemas:
-
# -------------------------------------------------------------------------
# Requests
# -------------------------------------------------------------------------
@@ -551,7 +548,7 @@ components:
pgn:
type: string
description: PGN text (headers and move list)
- example: "1. e4 e5 2. Nf3 Nc6 *"
+ example: '1. e4 e5 2. Nf3 Nc6 *'
# -------------------------------------------------------------------------
# Game state
@@ -587,7 +584,7 @@ components:
pgn:
type: string
description: PGN move text for the full game so far
- example: "1. e4"
+ example: '1. e4'
turn:
type: string
enum: [white, black]
diff --git a/proxy.conf.json b/proxy.conf.json
index f243619..e65b255 100644
--- a/proxy.conf.json
+++ b/proxy.conf.json
@@ -1,4 +1,9 @@
{
+ "/api/analysis": {
+ "target": "http://localhost:8087",
+ "secure": false,
+ "changeOrigin": true
+ },
"/api/tournament": {
"target": "http://localhost:8089",
"secure": false,
diff --git a/public/env.template.js b/public/env.template.js
index 72477ac..165a040 100644
--- a/public/env.template.js
+++ b/public/env.template.js
@@ -1,4 +1,4 @@
window.__RUNTIME_CONFIG__ = {
- API_URL: "${API_URL}",
- WEBSOCKET_URL: "${WEBSOCKET_URL}"
+ API_URL: '${API_URL}',
+ WEBSOCKET_URL: '${WEBSOCKET_URL}',
};
diff --git a/src/app/app.config.ts b/src/app/app.config.ts
index 77e96f7..7d5cc1f 100644
--- a/src/app/app.config.ts
+++ b/src/app/app.config.ts
@@ -1,4 +1,8 @@
-import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
+import {
+ ApplicationConfig,
+ provideBrowserGlobalErrorListeners,
+ provideZoneChangeDetection,
+} from '@angular/core';
import { provideHttpClient, withInterceptors } from '@angular/common/http';
import { provideRouter } from '@angular/router';
@@ -10,6 +14,6 @@ export const appConfig: ApplicationConfig = {
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideHttpClient(withInterceptors([authInterceptor])),
- provideRouter(routes)
- ]
+ provideRouter(routes),
+ ],
};
diff --git a/src/app/app.html b/src/app/app.html
index 515b2b7..48f375d 100644
--- a/src/app/app.html
+++ b/src/app/app.html
@@ -1,2 +1 @@
-
-
\ No newline at end of file
+
diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts
index 54f7663..63b01eb 100644
--- a/src/app/app.routes.ts
+++ b/src/app/app.routes.ts
@@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component';
+import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [
{ path: '', component: WelcomeComponent },
@@ -14,6 +15,7 @@ export const routes: Routes = [
{ path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent },
+ { path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent },
- { path: '**', redirectTo: '' }
+ { path: '**', redirectTo: '' },
];
diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts
index 2f546f6..91e7669 100644
--- a/src/app/app.spec.ts
+++ b/src/app/app.spec.ts
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
+import { provideHttpClient } from '@angular/common/http';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
- providers: [provideRouter([])]
+ providers: [provideRouter([]), provideHttpClient()],
}).compileComponents();
});
diff --git a/src/app/app.ts b/src/app/app.ts
index 4206e4c..57c6cff 100644
--- a/src/app/app.ts
+++ b/src/app/app.ts
@@ -7,10 +7,10 @@ import { ThemeService } from './services/theme.service';
selector: 'app-root',
imports: [RouterOutlet, ToolbarComponent],
templateUrl: './app.html',
- styleUrl: './app.css'
+ styleUrl: './app.css',
})
export class App implements OnInit {
- constructor(private readonly themeService: ThemeService) { }
+ constructor(private readonly themeService: ThemeService) {}
ngOnInit(): void {
this.themeService.initTheme();
diff --git a/src/app/button-template.css b/src/app/button-template.css
index 5b8888a..58ca321 100644
--- a/src/app/button-template.css
+++ b/src/app/button-template.css
@@ -12,7 +12,11 @@
letter-spacing: 1px;
cursor: pointer;
box-shadow: var(--btn-glow);
- transition: transform 0.2s ease, filter 0.2s ease, background 1.6s ease, box-shadow 1.6s ease;
+ transition:
+ transform 0.2s ease,
+ filter 0.2s ease,
+ background 1.6s ease,
+ box-shadow 1.6s ease;
display: inline-flex;
align-items: center;
justify-content: center;
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.css b/src/app/components/annotated-move-list/annotated-move-list.component.css
new file mode 100644
index 0000000..6062a45
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.css
@@ -0,0 +1,108 @@
+:host {
+ display: block;
+}
+
+.empty {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+ padding: 12px 16px;
+ font-family: var(--nc-mono, monospace);
+ letter-spacing: 0.06em;
+}
+
+.move-grid {
+ display: grid;
+ grid-template-columns: 28px 1fr 1fr;
+ gap: 2px 4px;
+ padding: 8px 12px;
+ font-family: var(--nc-mono, monospace);
+ font-size: 12px;
+}
+
+.mv-num {
+ color: rgba(255, 255, 255, 0.3);
+ font-size: 10px;
+ display: flex;
+ align-items: center;
+ letter-spacing: 0.04em;
+}
+
+.mv {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 8px;
+ cursor: pointer;
+ border-radius: 2px;
+ color: rgba(255, 255, 255, 0.8);
+ transition:
+ background 0.12s,
+ color 0.12s;
+}
+
+.mv:hover {
+ background: rgba(255, 255, 255, 0.06);
+ color: #fff;
+}
+
+.mv.active {
+ background: rgba(255, 69, 200, 0.18);
+ color: #fff;
+}
+
+.mv-empty {
+ cursor: default;
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.mv-empty:hover {
+ background: transparent;
+ color: rgba(255, 255, 255, 0.25);
+}
+
+.mv-san {
+ flex: 1;
+}
+
+.mv-placeholder {
+ opacity: 0.4;
+}
+
+/* Quality badges */
+.mv-badge {
+ font-size: 10px;
+ font-weight: 700;
+ padding: 1px 4px;
+ border-radius: 2px;
+ flex-shrink: 0;
+}
+
+.q-brilliant .mv-badge,
+.mv-badge.q-brilliant {
+ color: #5ee5a1;
+ background: rgba(94, 229, 161, 0.15);
+}
+
+.q-best .mv-badge,
+.mv-badge.q-best {
+ color: #5ee5a1;
+ background: rgba(94, 229, 161, 0.1);
+}
+
+.q-inaccuracy .mv-badge,
+.mv-badge.q-inaccuracy {
+ color: #ffb13a;
+ background: rgba(255, 177, 58, 0.15);
+}
+
+.q-mistake .mv-badge,
+.mv-badge.q-mistake {
+ color: #ff7a7a;
+ background: rgba(255, 122, 122, 0.15);
+}
+
+.q-blunder .mv-badge,
+.mv-badge.q-blunder {
+ color: #ff4444;
+ background: rgba(255, 68, 68, 0.18);
+}
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.html b/src/app/components/annotated-move-list/annotated-move-list.component.html
new file mode 100644
index 0000000..111aa14
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.html
@@ -0,0 +1,48 @@
+@if (moves.length === 0) {
+
No annotated moves yet.
+} @else {
+
+ @for (pair of pairs; track $index) {
+
{{ $index + 1 }}
+
+
+ {{ pair.white?.san ?? '' }}
+ @if (qualityLabel(pair.white)) {
+ {{
+ qualityLabel(pair.white)
+ }}
+ }
+
+
+
+ @if (pair.black) {
+ {{ pair.black.san }}
+ @if (qualityLabel(pair.black)) {
+ {{
+ qualityLabel(pair.black)
+ }}
+ }
+ } @else {
+ …
+ }
+
+ }
+
+}
diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.ts b/src/app/components/annotated-move-list/annotated-move-list.component.ts
new file mode 100644
index 0000000..b7d7a66
--- /dev/null
+++ b/src/app/components/annotated-move-list/annotated-move-list.component.ts
@@ -0,0 +1,83 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { AnnotatedMove, MoveQuality } from '../../models/analysis.models';
+
+interface AnnotatedPair {
+ white: AnnotatedMove | null;
+ black: AnnotatedMove | null;
+}
+
+const QUALITY_LABELS: Record = {
+ brilliant: '!!',
+ best: '!',
+ good: '',
+ inaccuracy: '?!',
+ mistake: '?',
+ blunder: '??',
+};
+
+const QUALITY_CLASSES: Record = {
+ brilliant: 'q-brilliant',
+ best: 'q-best',
+ good: 'q-good',
+ inaccuracy: 'q-inaccuracy',
+ mistake: 'q-mistake',
+ blunder: 'q-blunder',
+};
+
+@Component({
+ selector: 'app-annotated-move-list',
+ standalone: true,
+ imports: [],
+ templateUrl: './annotated-move-list.component.html',
+ styleUrl: './annotated-move-list.component.css',
+})
+export class AnnotatedMoveListComponent {
+ @Input({ required: true }) moves: AnnotatedMove[] = [];
+ @Input() activePly: number | null = null;
+ @Output() plySelected = new EventEmitter();
+
+ get pairs(): AnnotatedPair[] {
+ const result: AnnotatedPair[] = [];
+ for (let i = 0; i < this.moves.length; i += 2) {
+ result.push({
+ white: this.moves[i] ?? null,
+ black: this.moves[i + 1] ?? null,
+ });
+ }
+ return result;
+ }
+
+ qualityLabel(move: AnnotatedMove | null): string {
+ if (!move?.quality) return '';
+ return QUALITY_LABELS[move.quality] ?? '';
+ }
+
+ qualityClass(move: AnnotatedMove | null): string {
+ if (!move?.quality) return '';
+ return QUALITY_CLASSES[move.quality] ?? '';
+ }
+
+ isWhiteActive(pairIndex: number): boolean {
+ return this.activePly === pairIndex * 2;
+ }
+
+ isBlackActive(pairIndex: number): boolean {
+ return this.activePly === pairIndex * 2 + 1;
+ }
+
+ selectWhite(pairIndex: number): void {
+ this.plySelected.emit(pairIndex * 2);
+ }
+
+ selectBlack(pairIndex: number, black: AnnotatedMove | null): void {
+ if (!black) return;
+ this.plySelected.emit(pairIndex * 2 + 1);
+ }
+
+ formatEval(move: AnnotatedMove | null): string {
+ if (!move || move.evalAfter === null) return '';
+ const v = move.evalAfter;
+ const sign = v > 0 ? '+' : '';
+ return `${sign}${v.toFixed(2)}`;
+ }
+}
diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.css b/src/app/components/board-actions-bar/board-actions-bar.component.css
index fb76f03..cba36af 100644
--- a/src/app/components/board-actions-bar/board-actions-bar.component.css
+++ b/src/app/components/board-actions-bar/board-actions-bar.component.css
@@ -27,7 +27,10 @@
align-items: center;
justify-content: center;
gap: 6px;
- transition: background 0.15s, border-color 0.15s, color 0.15s;
+ transition:
+ background 0.15s,
+ border-color 0.15s,
+ color 0.15s;
}
.btn:hover:not(:disabled) {
diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.html b/src/app/components/board-actions-bar/board-actions-bar.component.html
index 92c94fa..401b392 100644
--- a/src/app/components/board-actions-bar/board-actions-bar.component.html
+++ b/src/app/components/board-actions-bar/board-actions-bar.component.html
@@ -1,26 +1,53 @@
diff --git a/src/app/components/board-actions-bar/board-actions-bar.component.ts b/src/app/components/board-actions-bar/board-actions-bar.component.ts
index d34e859..629dc16 100644
--- a/src/app/components/board-actions-bar/board-actions-bar.component.ts
+++ b/src/app/components/board-actions-bar/board-actions-bar.component.ts
@@ -5,7 +5,7 @@ import { Component, EventEmitter, Input, Output } from '@angular/core';
standalone: true,
imports: [],
templateUrl: './board-actions-bar.component.html',
- styleUrl: './board-actions-bar.component.css'
+ styleUrl: './board-actions-bar.component.css',
})
export class BoardActionsBarComponent {
@Input() undoAvailable = false;
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css
index e099c25..2e214d8 100644
--- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.css
@@ -23,7 +23,9 @@
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
- box-shadow: 0 0 20px rgba(0, 213, 255, 0.3), inset 0 0 10px rgba(0, 213, 255, 0.05);
+ box-shadow:
+ 0 0 20px rgba(0, 213, 255, 0.3),
+ inset 0 0 10px rgba(0, 213, 255, 0.05);
width: 90%;
max-width: 500px;
max-height: 90vh;
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
index 9536f0d..3944d08 100644
--- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.html
@@ -1,91 +1,117 @@
-
-
-
-
+
+
-
\ No newline at end of file
+
+
+
+
diff --git a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts
index 2dc9a1f..50f04b9 100644
--- a/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts
+++ b/src/app/components/challenge-create-dialog/challenge-create-dialog.component.ts
@@ -1,6 +1,20 @@
-import { Component, inject, OnInit, OnDestroy, DestroyRef, Output, EventEmitter } from '@angular/core';
+import {
+ Component,
+ inject,
+ OnInit,
+ OnDestroy,
+ DestroyRef,
+ Output,
+ EventEmitter,
+} from '@angular/core';
import { CommonModule } from '@angular/common';
-import { FormsModule, ReactiveFormsModule, FormBuilder, FormGroup, Validators } from '@angular/forms';
+import {
+ FormsModule,
+ ReactiveFormsModule,
+ FormBuilder,
+ FormGroup,
+ Validators,
+} from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { finalize } from 'rxjs';
import { ChallengeService } from '../../services/challenge.service';
@@ -11,149 +25,155 @@ import { PlayerColor } from '../../models/challenge.models';
type TimeMode = 'blitz' | 'rapid' | 'classical' | 'unlimited';
interface TimePreset {
- label: string;
- limitSeconds: number;
- incrementSeconds: number;
+ label: string;
+ limitSeconds: number;
+ incrementSeconds: number;
}
@Component({
- selector: 'app-challenge-create-dialog',
- standalone: true,
- imports: [CommonModule, FormsModule, ReactiveFormsModule],
- templateUrl: './challenge-create-dialog.component.html',
- styleUrls: ['./challenge-create-dialog.component.css']
+ selector: 'app-challenge-create-dialog',
+ standalone: true,
+ imports: [CommonModule, FormsModule, ReactiveFormsModule],
+ templateUrl: './challenge-create-dialog.component.html',
+ styleUrls: ['./challenge-create-dialog.component.css'],
})
export class ChallengeCreateDialogComponent implements OnInit, OnDestroy {
- private readonly challengeService = inject(ChallengeService);
- private readonly router = inject(Router);
- private readonly fb = inject(FormBuilder);
- private readonly destroyRef = inject(DestroyRef);
+ private readonly challengeService = inject(ChallengeService);
+ private readonly router = inject(Router);
+ private readonly fb = inject(FormBuilder);
+ private readonly destroyRef = inject(DestroyRef);
- @Output() closeChallengeDialog = new EventEmitter
();
+ @Output() closeChallengeDialog = new EventEmitter();
- form!: FormGroup;
- loading = false;
- errorMessage = '';
- selectedTimeMode: TimeMode = 'rapid';
+ form!: FormGroup;
+ loading = false;
+ errorMessage = '';
+ selectedTimeMode: TimeMode = 'rapid';
- timePresets: Record = {
- blitz: [
- { label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
- { label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
- { label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
- { label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
- { label: '5+0', limitSeconds: 300, incrementSeconds: 0 }
- ],
- rapid: [
- { label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
- { label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
- { label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
- { label: '25+10', limitSeconds: 1500, incrementSeconds: 10 }
- ],
- classical: [
- { label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
- { label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
- { label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
- { label: '90+30', limitSeconds: 5400, incrementSeconds: 30 }
- ],
- unlimited: []
- };
+ timePresets: Record = {
+ blitz: [
+ { label: '1+0', limitSeconds: 60, incrementSeconds: 0 },
+ { label: '2+1', limitSeconds: 120, incrementSeconds: 1 },
+ { label: '3+0', limitSeconds: 180, incrementSeconds: 0 },
+ { label: '3+2', limitSeconds: 180, incrementSeconds: 2 },
+ { label: '5+0', limitSeconds: 300, incrementSeconds: 0 },
+ ],
+ rapid: [
+ { label: '10+0', limitSeconds: 600, incrementSeconds: 0 },
+ { label: '10+5', limitSeconds: 600, incrementSeconds: 5 },
+ { label: '15+10', limitSeconds: 900, incrementSeconds: 10 },
+ { label: '25+10', limitSeconds: 1500, incrementSeconds: 10 },
+ ],
+ classical: [
+ { label: '30+0', limitSeconds: 1800, incrementSeconds: 0 },
+ { label: '30+20', limitSeconds: 1800, incrementSeconds: 20 },
+ { label: '60+30', limitSeconds: 3600, incrementSeconds: 30 },
+ { label: '90+30', limitSeconds: 5400, incrementSeconds: 30 },
+ ],
+ unlimited: [],
+ };
- ttlOptions = [
- { label: '5 minutes', seconds: 300 },
- { label: '1 hour', seconds: 3600 },
- { label: '1 day', seconds: 86400 },
- { label: 'No expiry', seconds: 0 }
- ];
+ ttlOptions = [
+ { label: '5 minutes', seconds: 300 },
+ { label: '1 hour', seconds: 3600 },
+ { label: '1 day', seconds: 86400 },
+ { label: 'No expiry', seconds: 0 },
+ ];
- ngOnInit(): void {
- this.initializeForm();
- }
+ ngOnInit(): void {
+ this.initializeForm();
+ }
- ngOnDestroy(): void {
- }
+ ngOnDestroy(): void {}
- private initializeForm(): void {
- this.form = this.fb.group({
- targetUsername: ['', [Validators.required, Validators.minLength(1)]],
- color: ['random', Validators.required],
- timeMode: ['rapid'],
- limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
- incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
- ttlSeconds: [3600, Validators.required]
- });
+ private initializeForm(): void {
+ this.form = this.fb.group({
+ targetUsername: ['', [Validators.required, Validators.minLength(1)]],
+ color: ['random', Validators.required],
+ timeMode: ['rapid'],
+ limitMinutes: [10, [Validators.required, Validators.min(1), Validators.max(1000)]],
+ incrementSeconds: [5, [Validators.required, Validators.min(0), Validators.max(300)]],
+ ttlSeconds: [3600, Validators.required],
+ });
- this.form.get('timeMode')?.valueChanges
- .pipe(takeUntilDestroyed(this.destroyRef))
- .subscribe((mode: unknown) => {
- const timeMode = mode as TimeMode;
- this.selectedTimeMode = timeMode;
- if (timeMode !== 'unlimited') {
- const firstPreset = this.timePresets[timeMode][0];
- if (firstPreset) {
- this.form.patchValue({
- limitMinutes: firstPreset.limitSeconds / 60,
- incrementSeconds: firstPreset.incrementSeconds
- });
- }
- }
+ this.form
+ .get('timeMode')
+ ?.valueChanges.pipe(takeUntilDestroyed(this.destroyRef))
+ .subscribe((mode: unknown) => {
+ const timeMode = mode as TimeMode;
+ this.selectedTimeMode = timeMode;
+ if (timeMode !== 'unlimited') {
+ const firstPreset = this.timePresets[timeMode][0];
+ if (firstPreset) {
+ this.form.patchValue({
+ limitMinutes: firstPreset.limitSeconds / 60,
+ incrementSeconds: firstPreset.incrementSeconds,
});
- }
-
- selectPreset(preset: TimePreset): void {
- this.form.patchValue({
- limitMinutes: preset.limitSeconds / 60,
- incrementSeconds: preset.incrementSeconds
- });
- }
-
- getAvailablePresets(): TimePreset[] {
- return this.timePresets[this.selectedTimeMode] || [];
- }
-
- submit(): void {
- if (this.form.invalid || this.loading) {
- return;
+ }
}
+ });
+ }
- const targetUsername = this.form.get('targetUsername')?.value?.trim();
- if (!targetUsername) {
- this.errorMessage = 'Please enter a valid username';
- return;
- }
+ selectPreset(preset: TimePreset): void {
+ this.form.patchValue({
+ limitMinutes: preset.limitSeconds / 60,
+ incrementSeconds: preset.incrementSeconds,
+ });
+ }
- this.errorMessage = '';
- this.loading = true;
- this.form.disable();
+ getAvailablePresets(): TimePreset[] {
+ return this.timePresets[this.selectedTimeMode] || [];
+ }
- const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
- const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
- const color = (rawColor || 'random') as PlayerColor;
-
- this.challengeService.sendChallenge(targetUsername, {
- timeControl: {
- limitSeconds,
- incrementSeconds
- },
- color,
- ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined
- })
- .pipe(finalize(() => { this.loading = false; this.form.enable(); }))
- .subscribe({
- next: (challenge) => {
- // Challenge sent successfully - navigate to challenges page to view status
- this.form.reset();
- this.closeChallengeDialog.emit();
- void this.router.navigate(['/challenges']);
- },
- error: (error) => {
- this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.');
- }
- });
+ submit(): void {
+ if (this.form.invalid || this.loading) {
+ return;
}
- cancel(): void {
- this.form.reset();
- this.closeChallengeDialog.emit();
+ const targetUsername = this.form.get('targetUsername')?.value?.trim();
+ if (!targetUsername) {
+ this.errorMessage = 'Please enter a valid username';
+ return;
}
+
+ this.errorMessage = '';
+ this.loading = true;
+ this.form.disable();
+
+ const limitSeconds = Math.round((this.form.getRawValue().limitMinutes || 0) * 60);
+ const { incrementSeconds, ttlSeconds, color: rawColor } = this.form.getRawValue();
+ const color = (rawColor || 'random') as PlayerColor;
+
+ this.challengeService
+ .sendChallenge(targetUsername, {
+ timeControl: {
+ limitSeconds,
+ incrementSeconds,
+ },
+ color,
+ ttlSeconds: ttlSeconds > 0 ? ttlSeconds : undefined,
+ })
+ .pipe(
+ finalize(() => {
+ this.loading = false;
+ this.form.enable();
+ }),
+ )
+ .subscribe({
+ next: (challenge) => {
+ // Challenge sent successfully - navigate to challenges page to view status
+ this.form.reset();
+ this.closeChallengeDialog.emit();
+ void this.router.navigate(['/challenges']);
+ },
+ error: (error) => {
+ this.errorMessage = getErrorMessage(error, 'Failed to send challenge. Please try again.');
+ },
+ });
+ }
+
+ cancel(): void {
+ this.form.reset();
+ this.closeChallengeDialog.emit();
+ }
}
diff --git a/src/app/components/challenge-notification/challenge-notification.component.css b/src/app/components/challenge-notification/challenge-notification.component.css
index 79268f7..767e449 100644
--- a/src/app/components/challenge-notification/challenge-notification.component.css
+++ b/src/app/components/challenge-notification/challenge-notification.component.css
@@ -8,7 +8,9 @@
background: linear-gradient(135deg, #0a0e27 0%, #1a1a3e 100%);
border: 2px solid #00d5ff;
border-radius: 8px;
- box-shadow: 0 0 20px rgba(0, 213, 255, 0.4), inset 0 0 10px rgba(0, 213, 255, 0.05);
+ box-shadow:
+ 0 0 20px rgba(0, 213, 255, 0.4),
+ inset 0 0 10px rgba(0, 213, 255, 0.05);
padding: 16px;
color: #e0e0e0;
z-index: 2000;
@@ -16,7 +18,9 @@
&.error {
border-color: #ff6b6b;
- box-shadow: 0 0 20px rgba(255, 107, 107, 0.4), inset 0 0 10px rgba(255, 107, 107, 0.05);
+ box-shadow:
+ 0 0 20px rgba(255, 107, 107, 0.4),
+ inset 0 0 10px rgba(255, 107, 107, 0.05);
}
@media (max-width: 768px) {
diff --git a/src/app/components/challenge-notification/challenge-notification.component.html b/src/app/components/challenge-notification/challenge-notification.component.html
index 46a3907..c099802 100644
--- a/src/app/components/challenge-notification/challenge-notification.component.html
+++ b/src/app/components/challenge-notification/challenge-notification.component.html
@@ -1,38 +1,50 @@
-
diff --git a/src/app/components/challenge-notification/challenge-notification.component.ts b/src/app/components/challenge-notification/challenge-notification.component.ts
index 87d760f..b5ff029 100644
--- a/src/app/components/challenge-notification/challenge-notification.component.ts
+++ b/src/app/components/challenge-notification/challenge-notification.component.ts
@@ -7,102 +7,104 @@ import { finalize } from 'rxjs';
import { getErrorMessage } from '../../core/http/error-message.util';
@Component({
- selector: 'app-challenge-notification',
- standalone: true,
- imports: [CommonModule],
- templateUrl: './challenge-notification.component.html',
- styleUrls: ['./challenge-notification.component.css']
+ selector: 'app-challenge-notification',
+ standalone: true,
+ imports: [CommonModule],
+ templateUrl: './challenge-notification.component.html',
+ styleUrls: ['./challenge-notification.component.css'],
})
export class ChallengeNotificationComponent {
- @Input() challenge!: Challenge;
- @Output() accept = new EventEmitter
();
- @Output() decline = new EventEmitter();
- @Output() close = new EventEmitter();
+ @Input() challenge!: Challenge;
+ @Output() accept = new EventEmitter();
+ @Output() decline = new EventEmitter();
+ @Output() close = new EventEmitter();
- private readonly challengeService = inject(ChallengeService);
- private readonly router = inject(Router);
+ private readonly challengeService = inject(ChallengeService);
+ private readonly router = inject(Router);
- acceptingChallenge = false;
- decliningChallenge = false;
- errorMessage = '';
+ acceptingChallenge = false;
+ decliningChallenge = false;
+ errorMessage = '';
- onAccept(): void {
- if (this.acceptingChallenge || this.decliningChallenge) {
- return;
- }
-
- this.acceptingChallenge = true;
- this.errorMessage = '';
-
- this.challengeService.acceptChallenge(this.challenge.id)
- .pipe(finalize(() => (this.acceptingChallenge = false)))
- .subscribe({
- next: (acceptedChallenge) => {
- this.accept.emit(acceptedChallenge);
- if (acceptedChallenge.gameId) {
- void this.router.navigate(['/game', acceptedChallenge.gameId]);
- } else {
- this.errorMessage = 'Challenge accepted, but no game was created.';
- }
- },
- error: (error) => {
- this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
- }
- });
+ onAccept(): void {
+ if (this.acceptingChallenge || this.decliningChallenge) {
+ return;
}
- onDecline(): void {
- if (this.acceptingChallenge || this.decliningChallenge) {
- return;
- }
+ this.acceptingChallenge = true;
+ this.errorMessage = '';
- this.decliningChallenge = true;
- this.errorMessage = '';
+ this.challengeService
+ .acceptChallenge(this.challenge.id)
+ .pipe(finalize(() => (this.acceptingChallenge = false)))
+ .subscribe({
+ next: (acceptedChallenge) => {
+ this.accept.emit(acceptedChallenge);
+ if (acceptedChallenge.gameId) {
+ void this.router.navigate(['/game', acceptedChallenge.gameId]);
+ } else {
+ this.errorMessage = 'Challenge accepted, but no game was created.';
+ }
+ },
+ error: (error) => {
+ this.errorMessage = getErrorMessage(error, 'Failed to accept challenge');
+ },
+ });
+ }
- this.challengeService.declineChallenge(this.challenge.id, { reason: 'generic' })
- .pipe(finalize(() => (this.decliningChallenge = false)))
- .subscribe({
- next: () => {
- this.decline.emit(this.challenge);
- },
- error: (error) => {
- this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
- }
- });
+ onDecline(): void {
+ if (this.acceptingChallenge || this.decliningChallenge) {
+ return;
}
- onClose(): void {
- this.close.emit();
+ this.decliningChallenge = true;
+ this.errorMessage = '';
+
+ this.challengeService
+ .declineChallenge(this.challenge.id, { reason: 'generic' })
+ .pipe(finalize(() => (this.decliningChallenge = false)))
+ .subscribe({
+ next: () => {
+ this.decline.emit(this.challenge);
+ },
+ error: (error) => {
+ this.errorMessage = getErrorMessage(error, 'Failed to decline challenge');
+ },
+ });
+ }
+
+ onClose(): void {
+ this.close.emit();
+ }
+
+ getTimeControlDisplay(): string {
+ const { limit, increment } = this.challenge.timeControl;
+ if (!limit || !increment) {
+ return 'Unlimited';
+ }
+ const minutes = Math.floor(limit / 60);
+ return `${minutes}+${increment}`;
+ }
+
+ getCreatedByDisplay(): string {
+ return this.challenge.challenger.name;
+ }
+
+ getExpirationInfo(): string {
+ const expiresAt = new Date(this.challenge.expiresAt);
+ const now = new Date();
+ const diffMs = expiresAt.getTime() - now.getTime();
+
+ if (diffMs <= 0) {
+ return 'Expired';
}
- getTimeControlDisplay(): string {
- const { limit, increment } = this.challenge.timeControl;
- if (!limit || !increment) {
- return 'Unlimited';
- }
- const minutes = Math.floor(limit / 60);
- return `${minutes}+${increment}`;
+ const minutes = Math.floor(diffMs / 60000);
+ if (minutes > 60) {
+ const hours = Math.floor(minutes / 60);
+ return `Expires in ${hours}h`;
}
- getCreatedByDisplay(): string {
- return this.challenge.challenger.name;
- }
-
- getExpirationInfo(): string {
- const expiresAt = new Date(this.challenge.expiresAt);
- const now = new Date();
- const diffMs = expiresAt.getTime() - now.getTime();
-
- if (diffMs <= 0) {
- return 'Expired';
- }
-
- const minutes = Math.floor(diffMs / 60000);
- if (minutes > 60) {
- const hours = Math.floor(minutes / 60);
- return `Expires in ${hours}h`;
- }
-
- return `Expires in ${minutes}m`;
- }
+ return `Expires in ${minutes}m`;
+ }
}
diff --git a/src/app/components/chess-board/chess-board.component.html b/src/app/components/chess-board/chess-board.component.html
index 0704d39..eaeec68 100644
--- a/src/app/components/chess-board/chess-board.component.html
+++ b/src/app/components/chess-board/chess-board.component.html
@@ -1,5 +1,9 @@
-
+
@for (square of squares; track trackByCoordinate($index, square)) {
diff --git a/src/app/components/chess-board/chess-board.component.ts b/src/app/components/chess-board/chess-board.component.ts
index 0a1a1a8..3abe46d 100644
--- a/src/app/components/chess-board/chess-board.component.ts
+++ b/src/app/components/chess-board/chess-board.component.ts
@@ -14,7 +14,7 @@ type BoardTheme = 'arabian' | 'classic';
standalone: true,
imports: [ChessPieceComponent],
templateUrl: './chess-board.component.html',
- styleUrl: './chess-board.component.css'
+ styleUrl: './chess-board.component.css',
})
export class ChessBoardComponent implements OnChanges {
@Input({ required: true }) fen = '';
@@ -150,7 +150,7 @@ export class ChessBoardComponent implements OnChanges {
return {
coordinate: `${file}${rank}`,
isLight: (rowIndex + column) % 2 === 0,
- pieceCode
+ pieceCode,
};
}
diff --git a/src/app/components/chess-piece/chess-piece.component.ts b/src/app/components/chess-piece/chess-piece.component.ts
index 6b3e125..70974aa 100644
--- a/src/app/components/chess-piece/chess-piece.component.ts
+++ b/src/app/components/chess-piece/chess-piece.component.ts
@@ -6,7 +6,7 @@ type BoardTheme = 'arabian' | 'classic';
selector: 'app-chess-piece',
standalone: true,
templateUrl: './chess-piece.component.html',
- styleUrl: './chess-piece.component.css'
+ styleUrl: './chess-piece.component.css',
})
export class ChessPieceComponent {
@Input({ required: true }) pieceCode: string | null = null;
diff --git a/src/app/components/eval-timeline/eval-timeline.component.css b/src/app/components/eval-timeline/eval-timeline.component.css
new file mode 100644
index 0000000..fc29110
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.css
@@ -0,0 +1,53 @@
+:host {
+ display: block;
+ width: 100%;
+}
+
+.timeline-wrap {
+ width: 100%;
+ overflow: hidden;
+}
+
+.timeline-svg {
+ display: block;
+ width: 100%;
+ height: 80px;
+}
+
+.midline {
+ stroke: rgba(255, 255, 255, 0.12);
+ stroke-width: 1;
+ stroke-dasharray: 4 4;
+}
+
+.area-white {
+ fill: rgba(255, 255, 255, 0.18);
+}
+
+.area-black {
+ fill: rgba(20, 20, 30, 0.55);
+}
+
+.eval-line {
+ fill: none;
+ stroke: var(--nc-neon, #ff45c8);
+ stroke-width: 1.5;
+ stroke-linejoin: round;
+ stroke-linecap: round;
+}
+
+.active-marker {
+ stroke: var(--nc-warning, #ffb13a);
+ stroke-width: 1.5;
+ stroke-dasharray: 3 3;
+ opacity: 0.8;
+}
+
+.empty {
+ font-size: 11px;
+ color: rgba(255, 255, 255, 0.4);
+ padding: 12px 0;
+ font-family: var(--nc-mono, monospace);
+ letter-spacing: 0.06em;
+ text-align: center;
+}
diff --git a/src/app/components/eval-timeline/eval-timeline.component.html b/src/app/components/eval-timeline/eval-timeline.component.html
new file mode 100644
index 0000000..1589d21
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.html
@@ -0,0 +1,53 @@
+@if (points.length === 0) {
+
No evaluation data yet.
+} @else {
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (activeX() !== null) {
+
+ }
+
+
+}
diff --git a/src/app/components/eval-timeline/eval-timeline.component.ts b/src/app/components/eval-timeline/eval-timeline.component.ts
new file mode 100644
index 0000000..9099dd9
--- /dev/null
+++ b/src/app/components/eval-timeline/eval-timeline.component.ts
@@ -0,0 +1,73 @@
+import { Component, Input, OnChanges } from '@angular/core';
+import { AnnotatedMove } from '../../models/analysis.models';
+
+interface TimelinePoint {
+ x: number;
+ y: number;
+ eval: number;
+ san: string;
+ plyIndex: number;
+}
+
+const CLAMP = 5; // clamp eval to ±5 pawns for display
+const HEIGHT = 80;
+const WIDTH = 600;
+
+@Component({
+ selector: 'app-eval-timeline',
+ standalone: true,
+ imports: [],
+ templateUrl: './eval-timeline.component.html',
+ styleUrl: './eval-timeline.component.css',
+})
+export class EvalTimelineComponent implements OnChanges {
+ @Input({ required: true }) moves: AnnotatedMove[] = [];
+ @Input() activePly: number | null = null;
+
+ points: TimelinePoint[] = [];
+ evalPolyline = '';
+ polylineWhite = '';
+ polylineBlack = '';
+ svgWidth = WIDTH;
+ svgHeight = HEIGHT;
+
+ ngOnChanges(): void {
+ this.buildChart();
+ }
+
+ activeX(): number | null {
+ if (this.activePly === null) return null;
+ const pt = this.points[this.activePly];
+ return pt ? pt.x : null;
+ }
+
+ private buildChart(): void {
+ if (this.moves.length === 0) {
+ this.points = [];
+ this.evalPolyline = '';
+ this.polylineWhite = '';
+ this.polylineBlack = '';
+ return;
+ }
+
+ const total = this.moves.length;
+ this.points = this.moves.map((m, i) => {
+ const evalValue = m.evalAfter ?? 0;
+ const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue));
+ const x = (i / Math.max(total - 1, 1)) * WIDTH;
+ // y=0 => white winning (top), y=HEIGHT => black winning (bottom)
+ const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT;
+ return { x, y, eval: evalValue, san: m.san, plyIndex: i };
+ });
+
+ const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
+ this.evalPolyline = coordStr;
+
+ const mid = HEIGHT / 2;
+ const first = this.points[0];
+ const last = this.points[this.points.length - 1];
+
+ this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`;
+ this.polylineBlack = this.polylineWhite;
+ }
+}
diff --git a/src/app/components/export-panel/export-panel.component.css b/src/app/components/export-panel/export-panel.component.css
index 26128d6..7591ce9 100644
--- a/src/app/components/export-panel/export-panel.component.css
+++ b/src/app/components/export-panel/export-panel.component.css
@@ -66,7 +66,9 @@
font-family: var(--nc-sans);
letter-spacing: 0.08em;
cursor: pointer;
- transition: background 0.15s, color 0.15s;
+ transition:
+ background 0.15s,
+ color 0.15s;
}
.seg-btn.active {
@@ -115,7 +117,9 @@
align-items: center;
justify-content: center;
gap: 6px;
- transition: background 0.15s, border-color 0.15s;
+ transition:
+ background 0.15s,
+ border-color 0.15s;
}
.btn:hover {
diff --git a/src/app/components/export-panel/export-panel.component.html b/src/app/components/export-panel/export-panel.component.html
index 35e8bfc..de8d2e6 100644
--- a/src/app/components/export-panel/export-panel.component.html
+++ b/src/app/components/export-panel/export-panel.component.html
@@ -2,8 +2,17 @@
Export Position
-
-
+
+
@@ -15,30 +24,60 @@
[class.active]="exportKind === 'fen'"
role="tab"
[attr.aria-selected]="exportKind === 'fen'"
- (click)="setKind('fen')">FEN
+ (click)="setKind('fen')"
+ >
+ FEN
+
+ (click)="setKind('pgn')"
+ >
+ PGN
+
-
+
diff --git a/src/app/components/export-panel/export-panel.component.ts b/src/app/components/export-panel/export-panel.component.ts
index d53be84..7b9d7e7 100644
--- a/src/app/components/export-panel/export-panel.component.ts
+++ b/src/app/components/export-panel/export-panel.component.ts
@@ -8,7 +8,7 @@ type ExportKind = 'fen' | 'pgn';
standalone: true,
imports: [FormsModule],
templateUrl: './export-panel.component.html',
- styleUrl: './export-panel.component.css'
+ styleUrl: './export-panel.component.css',
})
export class ExportPanelComponent implements OnChanges {
@Input() fen = '';
diff --git a/src/app/components/input-card/input-card.component.css b/src/app/components/input-card/input-card.component.css
index 65aa86b..99b01e0 100644
--- a/src/app/components/input-card/input-card.component.css
+++ b/src/app/components/input-card/input-card.component.css
@@ -29,17 +29,16 @@
.input-card textarea {
resize: vertical;
height: 40px;
- background-color:lightcyan;
+ background-color: lightcyan;
border: 3px solid var(--color-border);
- border-radius: var(--border-radius-sm);
+ border-radius: var(--border-radius-sm);
}
.input-card input {
min-width: unset;
- background-color:lightcyan;
- border: 3px solid var(--color-border);
- border-radius: var(--border-radius-sm);
-
+ background-color: lightcyan;
+ border: 3px solid var(--color-border);
+ border-radius: var(--border-radius-sm);
}
.hint-text {
diff --git a/src/app/components/input-card/input-card.component.html b/src/app/components/input-card/input-card.component.html
index bdc6202..0239946 100644
--- a/src/app/components/input-card/input-card.component.html
+++ b/src/app/components/input-card/input-card.component.html
@@ -2,11 +2,23 @@
@if (inputType === 'textarea') {
-
+
} @else {
-
+
}
@if (hintText) {
-
{{ hintText }}
+
{{ hintText }}
}
-
\ No newline at end of file
+
diff --git a/src/app/components/login-dialog/login-dialog.component.css b/src/app/components/login-dialog/login-dialog.component.css
index 813b554..d7b5c3f 100644
--- a/src/app/components/login-dialog/login-dialog.component.css
+++ b/src/app/components/login-dialog/login-dialog.component.css
@@ -22,7 +22,7 @@
position: relative;
width: min(420px, 100%);
background:
- radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.10), transparent 60%),
+ radial-gradient(120% 80% at 50% 0%, rgba(255, 69, 200, 0.1), transparent 60%),
linear-gradient(180deg, #0a0612 0%, #06060d 100%);
border: 1px solid var(--auth-neon-soft);
border-radius: 14px;
@@ -60,14 +60,13 @@
position: absolute;
inset: 0;
pointer-events: none;
- background-image:
- repeating-linear-gradient(
- 0deg,
- rgba(255, 255, 255, 0.012) 0px,
- rgba(255, 255, 255, 0.012) 1px,
- transparent 1px,
- transparent 3px
- );
+ background-image: repeating-linear-gradient(
+ 0deg,
+ rgba(255, 255, 255, 0.012) 0px,
+ rgba(255, 255, 255, 0.012) 1px,
+ transparent 1px,
+ transparent 3px
+ );
border-radius: inherit;
}
@@ -174,7 +173,10 @@ form {
font-family: var(--auth-mono);
font-size: 13px;
letter-spacing: 0.3px;
- transition: border-color 0.18s ease, box-shadow 0.18s ease, background 0.18s ease;
+ transition:
+ border-color 0.18s ease,
+ box-shadow 0.18s ease,
+ background 0.18s ease;
}
.dialog-input::placeholder {
@@ -185,7 +187,9 @@ form {
outline: none;
border-color: var(--auth-neon);
background: rgba(20, 6, 26, 0.7);
- box-shadow: 0 0 0 3px rgba(255, 69, 200, 0.15), 0 0 18px rgba(255, 69, 200, 0.18);
+ box-shadow:
+ 0 0 0 3px rgba(255, 69, 200, 0.15),
+ 0 0 18px rgba(255, 69, 200, 0.18);
}
.text-danger {
@@ -303,26 +307,56 @@ form {
}
@keyframes scanline {
- 0% { transform: translateY(-100%); }
- 100% { transform: translateY(300%); }
+ 0% {
+ transform: translateY(-100%);
+ }
+ 100% {
+ transform: translateY(300%);
+ }
}
@keyframes pulse-glow {
- 0%, 100% { opacity: 0.85; }
- 50% { opacity: 1; }
+ 0%,
+ 100% {
+ opacity: 0.85;
+ }
+ 50% {
+ opacity: 1;
+ }
}
@keyframes dialog-in {
- from { opacity: 0; transform: translateY(8px) scale(0.96); }
- to { opacity: 1; transform: translateY(0) scale(1); }
+ from {
+ opacity: 0;
+ transform: translateY(8px) scale(0.96);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0) scale(1);
+ }
}
@keyframes backdrop-in {
- from { opacity: 0; }
- to { opacity: 1; }
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
}
@keyframes shake {
- 0%, 100% { transform: translateX(0); }
- 20%, 60% { transform: translateX(-4px); }
- 40%, 80% { transform: translateX(4px); }
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+ 20%,
+ 60% {
+ transform: translateX(-4px);
+ }
+ 40%,
+ 80% {
+ transform: translateX(4px);
+ }
}
@keyframes spin {
- to { transform: rotate(360deg); }
+ to {
+ transform: rotate(360deg);
+ }
}
diff --git a/src/app/components/login-dialog/login-dialog.component.html b/src/app/components/login-dialog/login-dialog.component.html
index e6ff05d..3b853a2 100644
--- a/src/app/components/login-dialog/login-dialog.component.html
+++ b/src/app/components/login-dialog/login-dialog.component.html
@@ -11,8 +11,14 @@
diff --git a/src/app/components/login-dialog/login-dialog.component.ts b/src/app/components/login-dialog/login-dialog.component.ts
index dc025dd..0cc60de 100644
--- a/src/app/components/login-dialog/login-dialog.component.ts
+++ b/src/app/components/login-dialog/login-dialog.component.ts
@@ -9,7 +9,7 @@ import { AuthDialogService } from '../../services/auth-dialog.service';
standalone: true,
imports: [CommonModule, ReactiveFormsModule],
templateUrl: './login-dialog.component.html',
- styleUrl: './login-dialog.component.css'
+ styleUrl: './login-dialog.component.css',
})
export class LoginDialogComponent {
@Output() onClose = new EventEmitter
();
@@ -26,7 +26,7 @@ export class LoginDialogComponent {
constructor() {
this.loginForm = this.formBuilder.group({
username: ['', [Validators.required, Validators.minLength(3)]],
- password: ['', [Validators.required, Validators.minLength(6)]]
+ password: ['', [Validators.required, Validators.minLength(6)]],
});
}
@@ -51,7 +51,7 @@ export class LoginDialogComponent {
this.isLoading = false;
this.loginForm.enable();
this.errorMessage = err.error?.message || 'Login failed. Please try again.';
- }
+ },
});
}
diff --git a/src/app/components/move-history/move-history.component.css b/src/app/components/move-history/move-history.component.css
index 1727b12..06988f6 100644
--- a/src/app/components/move-history/move-history.component.css
+++ b/src/app/components/move-history/move-history.component.css
@@ -26,7 +26,9 @@
padding: 6px 10px;
color: var(--nc-text);
cursor: pointer;
- transition: background 0.1s, color 0.1s;
+ transition:
+ background 0.1s,
+ color 0.1s;
}
.mv:hover {
@@ -35,7 +37,7 @@
}
.mv.current {
- background: rgba(255, 69, 200, 0.10);
+ background: rgba(255, 69, 200, 0.1);
color: var(--nc-neon);
}
@@ -72,7 +74,9 @@
align-items: center;
justify-content: center;
cursor: pointer;
- transition: color 0.15s, border-color 0.15s;
+ transition:
+ color 0.15s,
+ border-color 0.15s;
}
.icon-btn:hover {
@@ -93,7 +97,16 @@
opacity: 1;
}
-.moves::-webkit-scrollbar { width: 6px; }
-.moves::-webkit-scrollbar-track { background: transparent; }
-.moves::-webkit-scrollbar-thumb { background: var(--nc-border-strong); border-radius: 3px; }
-.moves::-webkit-scrollbar-thumb:hover { background: var(--nc-neon-soft); }
+.moves::-webkit-scrollbar {
+ width: 6px;
+}
+.moves::-webkit-scrollbar-track {
+ background: transparent;
+}
+.moves::-webkit-scrollbar-thumb {
+ background: var(--nc-border-strong);
+ border-radius: 3px;
+}
+.moves::-webkit-scrollbar-thumb:hover {
+ background: var(--nc-neon-soft);
+}
diff --git a/src/app/components/move-history/move-history.component.html b/src/app/components/move-history/move-history.component.html
index 681bc79..439339f 100644
--- a/src/app/components/move-history/move-history.component.html
+++ b/src/app/components/move-history/move-history.component.html
@@ -4,13 +4,25 @@
} @else {
@for (pair of movePairs; track $index) {
{{ $index + 1 }}
-
+
{{ pair.white }}
-
+
{{ pair.black ?? '…' }}
}
@@ -20,23 +32,61 @@