Initialize League GUI prototype
This commit is contained in:
79
.codex/project.md
Normal file
79
.codex/project.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Codex Project Notes
|
||||
|
||||
## Project
|
||||
|
||||
`League of Legends GUI Overhaul` is a React/Vite prototype for a modern, dark, MOBA-/fantasy-inspired client interface.
|
||||
|
||||
Repository:
|
||||
|
||||
```text
|
||||
Toxic/league-of-legends-gui-overhaul
|
||||
```
|
||||
|
||||
Remote:
|
||||
|
||||
```text
|
||||
https://git.wilkensxl.de/Toxic/league-of-legends-gui-overhaul.git
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
Use these commands as the source of truth:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
There is no separate lint script yet. `npm run build` runs `tsc --noEmit` before the Vite build. There is no audit or release-check script yet.
|
||||
|
||||
## Stack
|
||||
|
||||
```text
|
||||
React, Vite, TypeScript, React Router, Vitest, Testing Library, CSS custom properties.
|
||||
```
|
||||
|
||||
Package manager or build tool:
|
||||
|
||||
```text
|
||||
npm
|
||||
```
|
||||
|
||||
## Build Artifacts
|
||||
|
||||
Release artifacts are produced in:
|
||||
|
||||
Expected files:
|
||||
|
||||
```text
|
||||
dist/
|
||||
```
|
||||
|
||||
## Security Rules
|
||||
|
||||
- Do not commit secrets, tokens, `.env` files, certificates, or private keys.
|
||||
- Treat generated credentials as sensitive.
|
||||
- Prefer local generation and local processing for user data.
|
||||
- Keep dependency audit results visible in CI once dependencies exist.
|
||||
- Do not add external network calls unless the feature explicitly requires them.
|
||||
- Document any external assets, API calls, telemetry, or package publishing behavior when implementation begins.
|
||||
|
||||
## Release Rules
|
||||
|
||||
No published release process exists yet.
|
||||
|
||||
Before a release:
|
||||
|
||||
1. run `npm run build`,
|
||||
2. run `npm test`,
|
||||
3. run the release checklist,
|
||||
4. verify CI is green,
|
||||
5. verify download links when publishing artifacts,
|
||||
6. update README and changelog,
|
||||
7. create a tag,
|
||||
8. create the release.
|
||||
|
||||
Do not create releases unless the user explicitly asks for a release.
|
||||
114
.gitea/workflows/dependency-check.yml
Normal file
114
.gitea/workflows/dependency-check.yml
Normal file
@@ -0,0 +1,114 @@
|
||||
name: Scheduled Dependency Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "29 3 * * 2"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dependency-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect project stack
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
stacks=""
|
||||
|
||||
[ -f package.json ] && stacks="${stacks} node"
|
||||
{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && stacks="${stacks} python"
|
||||
[ -f Cargo.toml ] && stacks="${stacks} rust"
|
||||
[ -f go.mod ] && stacks="${stacks} go"
|
||||
{ [ -f Dockerfile ] || [ -f compose.yml ] || [ -f docker-compose.yml ]; } && stacks="${stacks} docker"
|
||||
|
||||
echo "stacks=${stacks:-generic}" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected stacks:${stacks:- generic}"
|
||||
|
||||
- name: Node dependency report
|
||||
if: contains(steps.detect.outputs.stacks, 'node')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install --package-lock-only --ignore-scripts
|
||||
fi
|
||||
|
||||
echo "Security audit:"
|
||||
npm audit --omit=dev --audit-level=high
|
||||
|
||||
echo
|
||||
echo "Outdated dependencies:"
|
||||
npm outdated || true
|
||||
|
||||
- name: Python dependency report
|
||||
if: contains(steps.detect.outputs.stacks, 'python')
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip pip-audit
|
||||
|
||||
echo "Security audit:"
|
||||
if [ -f requirements.txt ]; then
|
||||
pip-audit -r requirements.txt
|
||||
else
|
||||
pip-audit
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Outdated packages:"
|
||||
python -m pip list --outdated || true
|
||||
|
||||
- name: Rust dependency report
|
||||
if: contains(steps.detect.outputs.stacks, 'rust')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo install cargo-audit cargo-outdated --locked
|
||||
|
||||
echo "Security audit:"
|
||||
cargo audit
|
||||
|
||||
echo
|
||||
echo "Outdated crates:"
|
||||
cargo outdated || true
|
||||
|
||||
- name: Go dependency report
|
||||
if: contains(steps.detect.outputs.stacks, 'go')
|
||||
shell: bash
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
|
||||
echo "Security audit:"
|
||||
govulncheck ./...
|
||||
|
||||
echo
|
||||
echo "Available dependency updates:"
|
||||
go list -u -m all || true
|
||||
|
||||
- name: Docker base image report
|
||||
if: contains(steps.detect.outputs.stacks, 'docker')
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Docker image references:"
|
||||
grep -RInE --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=dist --exclude-dir=build '^\s*FROM\s+' Dockerfile* . 2>/dev/null || true
|
||||
|
||||
echo
|
||||
echo "Review Docker base images manually for pinned versions, official sources, and current security status."
|
||||
|
||||
- name: Dependency guidance
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
Dependency check completed.
|
||||
|
||||
This workflow reports vulnerabilities and available updates. It does
|
||||
not modify dependency files, create pull requests, or publish packages.
|
||||
|
||||
Recommended manual follow-up:
|
||||
- update dependencies in a focused branch,
|
||||
- run the project test/build commands,
|
||||
- review lockfile diffs carefully,
|
||||
- document intentionally held versions.
|
||||
EOF
|
||||
133
.gitea/workflows/release-dry-run.yml
Normal file
133
.gitea/workflows/release-dry-run.yml
Normal file
@@ -0,0 +1,133 @@
|
||||
name: Release Dry Run
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
release-dry-run:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Inspect release metadata
|
||||
shell: bash
|
||||
run: |
|
||||
missing=0
|
||||
|
||||
required_docs=(
|
||||
"README.md"
|
||||
"CHANGELOG.md"
|
||||
"SECURITY.md"
|
||||
"docs/release-checklist.md"
|
||||
)
|
||||
|
||||
for file in "${required_docs[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Missing release document: $file"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
placeholder_paths=(README.md AGENTS.md .codex docs)
|
||||
placeholder_pattern='PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY|DOWNLOAD_URL|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND'
|
||||
|
||||
for path in "${placeholder_paths[@]}"; do
|
||||
[ -e "$path" ] || continue
|
||||
if grep -RInE --exclude-dir=.git "$placeholder_pattern" "$path"; then
|
||||
echo "Unresolved template placeholders found."
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Detect project stack
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
stacks=""
|
||||
|
||||
[ -f package.json ] && stacks="${stacks} node"
|
||||
{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && stacks="${stacks} python"
|
||||
[ -f Cargo.toml ] && stacks="${stacks} rust"
|
||||
[ -f go.mod ] && stacks="${stacks} go"
|
||||
|
||||
echo "stacks=${stacks:-generic}" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected stacks:${stacks:- generic}"
|
||||
|
||||
- name: Node release checks
|
||||
if: contains(steps.detect.outputs.stacks, 'node')
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then
|
||||
npm ci
|
||||
else
|
||||
npm install
|
||||
fi
|
||||
|
||||
node -e "const p=require('./package.json'); if(!p.name||!p.version){throw new Error('package.json needs name and version')}; console.log(p.name+'@'+p.version)"
|
||||
|
||||
npm run lint --if-present
|
||||
npm test --if-present
|
||||
npm run build --if-present
|
||||
npm run release:check --if-present
|
||||
|
||||
- name: Python release checks
|
||||
if: contains(steps.detect.outputs.stacks, 'python')
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
|
||||
if [ -f requirements.txt ]; then
|
||||
python -m pip install -r requirements.txt
|
||||
fi
|
||||
|
||||
if [ -f pyproject.toml ]; then
|
||||
python -m pip install build
|
||||
python -m build
|
||||
else
|
||||
echo "No pyproject.toml found; skipped Python package build."
|
||||
fi
|
||||
|
||||
- name: Rust release checks
|
||||
if: contains(steps.detect.outputs.stacks, 'rust')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo test
|
||||
cargo build --release
|
||||
|
||||
- name: Go release checks
|
||||
if: contains(steps.detect.outputs.stacks, 'go')
|
||||
shell: bash
|
||||
run: |
|
||||
go test ./...
|
||||
go build ./...
|
||||
|
||||
- name: Artifact report
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Potential release artifacts:"
|
||||
find . \
|
||||
-path ./.git -prune -o \
|
||||
-path ./node_modules -prune -o \
|
||||
-path './dist/*' -type f -print -o \
|
||||
-path './build/*' -type f -print -o \
|
||||
-path './release/*' -type f -print -o \
|
||||
-path './target/release/*' -type f -print \
|
||||
| sed 's#^\./##' \
|
||||
| head -200
|
||||
|
||||
cat <<'EOF'
|
||||
|
||||
Release dry run completed.
|
||||
|
||||
This workflow verifies release readiness. It does not create tags,
|
||||
releases, packages, or upload artifacts.
|
||||
EOF
|
||||
139
.gitea/workflows/repo-cleanup.yml
Normal file
139
.gitea/workflows/repo-cleanup.yml
Normal file
@@ -0,0 +1,139 @@
|
||||
name: Scheduled Repository Cleanup Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "43 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cleanup-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check ignored and untracked generated files
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Ignored files that would be skipped by git:"
|
||||
git status --ignored --short || true
|
||||
|
||||
echo
|
||||
echo "Tracked generated files check:"
|
||||
generated_patterns=(
|
||||
'(^|/)node_modules/'
|
||||
'(^|/)dist/'
|
||||
'(^|/)build/'
|
||||
'(^|/)out/'
|
||||
'(^|/)release/'
|
||||
'(^|/)target/'
|
||||
'(^|/)coverage/'
|
||||
'\.log$'
|
||||
'\.tmp$'
|
||||
'\.temp$'
|
||||
)
|
||||
|
||||
found=0
|
||||
tracked_files="$(git ls-files)"
|
||||
for pattern in "${generated_patterns[@]}"; do
|
||||
if echo "$tracked_files" | grep -Ei "$pattern"; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Generated files appear to be tracked. Review .gitignore and remove generated outputs from version control if appropriate."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check large tracked files
|
||||
shell: bash
|
||||
run: |
|
||||
limit_bytes="${LARGE_FILE_LIMIT_BYTES:-5242880}"
|
||||
found=0
|
||||
|
||||
while IFS= read -r file; do
|
||||
[ -f "$file" ] || continue
|
||||
size="$(wc -c < "$file")"
|
||||
if [ "$size" -gt "$limit_bytes" ]; then
|
||||
echo "${file} is ${size} bytes, above limit ${limit_bytes}."
|
||||
found=1
|
||||
fi
|
||||
done < <(git ls-files)
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Large tracked files found. Move release artifacts to packages/releases or document why they belong in git."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check local config and secret-prone files
|
||||
shell: bash
|
||||
run: |
|
||||
found=0
|
||||
|
||||
risky_patterns=(
|
||||
'^\.env$'
|
||||
'^\.env\.'
|
||||
'\.pfx$'
|
||||
'\.p12$'
|
||||
'\.pem$'
|
||||
'\.key$'
|
||||
'\.token$'
|
||||
'(^|/)secrets/'
|
||||
)
|
||||
|
||||
tracked_files="$(git ls-files)"
|
||||
for pattern in "${risky_patterns[@]}"; do
|
||||
if echo "$tracked_files" | grep -Ei "$pattern" | grep -vE '^\.env\.example$'; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Secret-prone local config files are tracked. Review immediately."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check stale branches
|
||||
shell: bash
|
||||
run: |
|
||||
git fetch --all --prune
|
||||
|
||||
protected='^(main|master|develop|dev|release|staging|production)$'
|
||||
cutoff="$(date -u -d '90 days ago' +%s)"
|
||||
found=0
|
||||
|
||||
while IFS='|' read -r branch timestamp; do
|
||||
branch="${branch#origin/}"
|
||||
[ "$branch" = "HEAD" ] && continue
|
||||
echo "$branch" | grep -Eq "$protected" && continue
|
||||
|
||||
if [ "$timestamp" -lt "$cutoff" ]; then
|
||||
echo "Stale remote branch candidate: ${branch}"
|
||||
found=1
|
||||
fi
|
||||
done < <(git for-each-ref refs/remotes/origin --format='%(refname:short)|%(committerdate:unix)')
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Stale branch candidates found. Review manually before deleting anything."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Cleanup guidance
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
Repository cleanup check completed.
|
||||
|
||||
This workflow reports cleanup candidates. It does not delete branches,
|
||||
packages, releases, or files automatically.
|
||||
|
||||
Recommended manual follow-up:
|
||||
- remove generated files from git,
|
||||
- update .gitignore,
|
||||
- move large artifacts to releases or package registry,
|
||||
- review stale branches,
|
||||
- document intentional exceptions.
|
||||
EOF
|
||||
173
.gitea/workflows/security-scan.yml
Normal file
173
.gitea/workflows/security-scan.yml
Normal file
@@ -0,0 +1,173 @@
|
||||
name: Scheduled Security Scan
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Detect project stack
|
||||
id: detect
|
||||
shell: bash
|
||||
run: |
|
||||
stacks=""
|
||||
|
||||
[ -f package.json ] && stacks="${stacks} node"
|
||||
{ [ -f pyproject.toml ] || [ -f requirements.txt ]; } && stacks="${stacks} python"
|
||||
[ -f Cargo.toml ] && stacks="${stacks} rust"
|
||||
[ -f go.mod ] && stacks="${stacks} go"
|
||||
{ [ -f Dockerfile ] || [ -f compose.yml ] || [ -f docker-compose.yml ]; } && stacks="${stacks} docker"
|
||||
|
||||
echo "stacks=${stacks:-generic}" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected stacks:${stacks:- generic}"
|
||||
|
||||
- name: Node production dependency audit
|
||||
if: contains(steps.detect.outputs.stacks, 'node')
|
||||
run: npm audit --omit=dev --audit-level=high
|
||||
|
||||
- name: Python dependency audit
|
||||
if: contains(steps.detect.outputs.stacks, 'python')
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install --upgrade pip pip-audit
|
||||
if [ -f requirements.txt ]; then
|
||||
pip-audit -r requirements.txt
|
||||
else
|
||||
pip-audit
|
||||
fi
|
||||
|
||||
- name: Rust dependency audit
|
||||
if: contains(steps.detect.outputs.stacks, 'rust')
|
||||
shell: bash
|
||||
run: |
|
||||
cargo install cargo-audit --locked
|
||||
cargo audit
|
||||
|
||||
- name: Go vulnerability scan
|
||||
if: contains(steps.detect.outputs.stacks, 'go')
|
||||
shell: bash
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
|
||||
- name: Suspicious code pattern scan
|
||||
shell: bash
|
||||
run: |
|
||||
grep_excludes=(
|
||||
--exclude-dir=.git
|
||||
--exclude-dir=node_modules
|
||||
--exclude-dir=dist
|
||||
--exclude-dir=build
|
||||
--exclude-dir=release
|
||||
--exclude=security-scan.yml
|
||||
)
|
||||
|
||||
patterns=(
|
||||
'eval\s*\('
|
||||
'new Function\s*\('
|
||||
'dangerouslySetInnerHTML'
|
||||
'innerHTML\s*='
|
||||
'child_process'
|
||||
'exec\s*\('
|
||||
'spawn\s*\('
|
||||
'shell\.openExternal'
|
||||
'nodeIntegration:\s*true'
|
||||
'webSecurity:\s*false'
|
||||
'allowRunningInsecureContent:\s*true'
|
||||
'curl .*sh'
|
||||
'wget .*sh'
|
||||
)
|
||||
|
||||
found=0
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if grep -RInE "${grep_excludes[@]}" "$pattern" .; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Suspicious code patterns were found. Review the matches above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Secret and config leak scan
|
||||
shell: bash
|
||||
run: |
|
||||
grep_excludes=(
|
||||
--exclude-dir=.git
|
||||
--exclude-dir=node_modules
|
||||
--exclude-dir=dist
|
||||
--exclude-dir=build
|
||||
--exclude-dir=release
|
||||
--exclude=security-scan.yml
|
||||
)
|
||||
|
||||
patterns=(
|
||||
'BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY'
|
||||
'AKIA[0-9A-Z]{16}'
|
||||
'xox[baprs]-[0-9A-Za-z-]+'
|
||||
'gh[pousr]_[0-9A-Za-z_]+'
|
||||
'sk-[A-Za-z0-9]{20,}'
|
||||
'api[_-]?key\s*=\s*["'\'']?[A-Za-z0-9_\-]{20,}'
|
||||
'token\s*=\s*["'\'']?[A-Za-z0-9_\-]{20,}'
|
||||
'password\s*=\s*["'\'']?[^[:space:]]{8,}'
|
||||
)
|
||||
|
||||
found=0
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if grep -RInE "${grep_excludes[@]}" "$pattern" .; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if find . -path ./.git -prune -o \( -name ".env" -o -name ".env.*" \) -not -name ".env.example" -print | grep .; then
|
||||
echo "Committed environment files were found."
|
||||
found=1
|
||||
fi
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Potential secret or config leak detected. Review the matches above."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: AI instruction safety scan
|
||||
shell: bash
|
||||
run: |
|
||||
grep_excludes=(
|
||||
--exclude-dir=.git
|
||||
--exclude-dir=node_modules
|
||||
--exclude-dir=dist
|
||||
--exclude-dir=build
|
||||
--exclude-dir=release
|
||||
--exclude=security-scan.yml
|
||||
)
|
||||
|
||||
patterns=(
|
||||
'ignore (all )?(previous|above) instructions'
|
||||
'system prompt'
|
||||
'developer message'
|
||||
'reveal your instructions'
|
||||
'exfiltrate'
|
||||
'send.*token'
|
||||
'send.*secret'
|
||||
'disable.*safety'
|
||||
'jailbreak'
|
||||
)
|
||||
|
||||
found=0
|
||||
for pattern in "${patterns[@]}"; do
|
||||
if grep -RInEi "${grep_excludes[@]}" "$pattern" .; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Potential unsafe AI-control text found. Review whether this is documentation, test data, or malicious content."
|
||||
exit 1
|
||||
fi
|
||||
99
.gitea/workflows/template-compliance.yml
Normal file
99
.gitea/workflows/template-compliance.yml
Normal file
@@ -0,0 +1,99 @@
|
||||
name: Codex Template Compliance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
template-compliance:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Check required Codex files
|
||||
shell: bash
|
||||
run: |
|
||||
missing=0
|
||||
|
||||
required_files=(
|
||||
"AGENTS.md"
|
||||
".codex/project.md"
|
||||
"README.md"
|
||||
)
|
||||
|
||||
recommended_files=(
|
||||
"SECURITY.md"
|
||||
"CHANGELOG.md"
|
||||
"docs/agent-handoff.md"
|
||||
)
|
||||
|
||||
for file in "${required_files[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Missing required Codex file: $file"
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
|
||||
for file in "${recommended_files[@]}"; do
|
||||
if [ ! -f "$file" ]; then
|
||||
echo "Recommended Codex file not found: $file"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$missing" -eq 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check unresolved placeholders
|
||||
shell: bash
|
||||
run: |
|
||||
found=0
|
||||
paths=(AGENTS.md README.md SECURITY.md CHANGELOG.md .codex docs)
|
||||
pattern='PROJECT_NAME|PROJECT_DESCRIPTION|REPOSITORY_OWNER|REPOSITORY_NAME|PACKAGE_NAME|ARTIFACT_NAME|ARTIFACT_OUTPUT_DIRECTORY|AUTHOR_NAME|PROJECT_STACK|DOWNLOAD_URL|BUILD_COMMAND|TEST_COMMAND|LINT_COMMAND|AUDIT_COMMAND|README_COMMAND|INSTALL_COMMAND|DEV_COMMAND|PACKAGE_MANAGER|PROJECT_VERSION'
|
||||
|
||||
for path in "${paths[@]}"; do
|
||||
[ -e "$path" ] || continue
|
||||
if grep -RInE --exclude-dir=.git "$pattern" "$path"; then
|
||||
found=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$found" -eq 1 ]; then
|
||||
echo "Unresolved template placeholders found. Replace real values or mark genuinely unknown values as PENDING."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check workflow baseline
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Detected Gitea workflows:"
|
||||
find .gitea/workflows -maxdepth 1 -type f -name '*.yml' -print 2>/dev/null || true
|
||||
|
||||
if [ ! -f ".gitea/workflows/security-scan.yml" ]; then
|
||||
echo "Recommended workflow missing: .gitea/workflows/security-scan.yml"
|
||||
fi
|
||||
|
||||
if [ ! -f ".gitea/workflows/repo-cleanup.yml" ]; then
|
||||
echo "Recommended workflow missing: .gitea/workflows/repo-cleanup.yml"
|
||||
fi
|
||||
|
||||
- name: Compliance guidance
|
||||
shell: bash
|
||||
run: |
|
||||
cat <<'EOF'
|
||||
Codex template compliance check completed.
|
||||
|
||||
This workflow verifies agent context and template hygiene. It does
|
||||
not change files automatically.
|
||||
|
||||
Recommended manual follow-up:
|
||||
- add missing required Codex context files,
|
||||
- replace unresolved placeholders,
|
||||
- keep README and project context aligned,
|
||||
- document intentional exceptions in .codex/project.md.
|
||||
EOF
|
||||
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
.vite/
|
||||
.codex-agent-repository-kit/
|
||||
66
AGENTS.md
Normal file
66
AGENTS.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Project
|
||||
|
||||
League of Legends GUI Overhaul: React/Vite prototype for a modern, dark, MOBA-/fantasy-inspired client interface.
|
||||
|
||||
## Repository Rules
|
||||
|
||||
- Prefer existing project patterns over new abstractions.
|
||||
- Keep changes scoped to the user's request.
|
||||
- Do not commit secrets, `.env` files, private keys, certificates, or tokens.
|
||||
- Do not rewrite history or run destructive git commands unless explicitly requested.
|
||||
- Do not create a release unless explicitly requested.
|
||||
- Check repository state before editing and before finishing. Preserve unrelated user changes.
|
||||
- Replace applicable placeholders in copied templates. Remove non-applicable placeholder sections instead of leaving fake values.
|
||||
- The current stack is Node, React, Vite, TypeScript, React Router, Vitest, Testing Library, and CSS custom properties.
|
||||
- If `GITEA_TOKEN` is available locally, use it only for read-only Gitea API checks such as private repository metadata, package-read visibility, and Actions run status. Never print, commit, or store the token.
|
||||
- After pushing commits that trigger a Gitea workflow, poll the workflow run until it succeeds or a concrete blocker is known.
|
||||
- Repository cleanup automation must be non-destructive. Do not delete branches, packages, releases, or tracked files without explicit user approval.
|
||||
- Dependency, compliance, and release dry-run automation must report findings only. Do not auto-update dependencies, auto-open PRs, create tags, publish packages, or create releases without explicit user approval.
|
||||
|
||||
## Commands
|
||||
|
||||
Use these commands:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
npm run build
|
||||
npm test
|
||||
git diff --check
|
||||
```
|
||||
|
||||
There is no separate lint script yet. `npm run build` runs `tsc --noEmit` before the Vite build.
|
||||
|
||||
Keep `.codex/project.md` and this `AGENTS.md` aligned when commands, artifact paths, or release rules change.
|
||||
|
||||
## Artifacts
|
||||
|
||||
Build output is produced in:
|
||||
|
||||
```text
|
||||
dist/
|
||||
```
|
||||
|
||||
No release package naming or download verification process exists yet.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Review `docs/security-review.md` before release work.
|
||||
- Fill `docs/security-review.md` with actual checked commands and results when performing release-readiness work.
|
||||
- Review scheduled security workflow failures before changing code. Treat matches as leads: they may be true positives, documentation examples, or test fixtures.
|
||||
- Review repository cleanup workflow failures as maintenance leads. Document intentional exceptions instead of blindly deleting files.
|
||||
- Review dependency and template compliance workflow failures as maintenance leads. Preserve project-specific conventions when they are documented.
|
||||
- Treat generated credentials and config files as sensitive.
|
||||
- Keep external network calls documented.
|
||||
- Prefer local processing for user data.
|
||||
- Keep CI publishing secrets in repository or organization secrets, not in tracked files.
|
||||
|
||||
## Finish Checklist
|
||||
|
||||
- `git diff --check` passes.
|
||||
- The cheapest reliable verification command has been run, or the reason it could not run is documented.
|
||||
- README, changelog, security review, and release checklist are updated when the change touches release behavior.
|
||||
- `docs/agent-handoff.md` is updated when work is interrupted, risky, or spans multiple sessions.
|
||||
- Any pushed Gitea workflow has been polled to success or a concrete blocker has been reported.
|
||||
8
CHANGELOG.md
Normal file
8
CHANGELOG.md
Normal file
@@ -0,0 +1,8 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes to this project are documented here.
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Added the Codex Agent Repository Kit baseline.
|
||||
- Documented that the project currently has no selected implementation stack or runnable build commands.
|
||||
41
CONTRIBUTING.md
Normal file
41
CONTRIBUTING.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# Contributing
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Keep changes scoped to the issue or user request.
|
||||
- Prefer existing project patterns.
|
||||
- Do not commit secrets, generated credentials, local `.env` files, or private keys.
|
||||
- Do not create releases unless explicitly requested.
|
||||
- Preserve unrelated user changes.
|
||||
|
||||
## Before Committing
|
||||
|
||||
Run the cheapest reliable verification commands for this project.
|
||||
|
||||
Current available check:
|
||||
|
||||
```bash
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Once a real stack exists, add and document lint, test, build, and audit commands.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
Pull requests should include:
|
||||
|
||||
- summary of changes,
|
||||
- verification performed,
|
||||
- known risks or skipped checks,
|
||||
- artifact or download notes when relevant.
|
||||
|
||||
## Releases
|
||||
|
||||
Before release work, update:
|
||||
|
||||
```text
|
||||
CHANGELOG.md
|
||||
docs/release-checklist.md
|
||||
docs/security-review.md
|
||||
README.md
|
||||
```
|
||||
119
INSTALL.md
Normal file
119
INSTALL.md
Normal file
@@ -0,0 +1,119 @@
|
||||
# Installation
|
||||
|
||||
This guide explains how to install and run the League of Legends GUI Overhaul prototype locally.
|
||||
|
||||
The project is a React/Vite/TypeScript app. It does not install a game client, connect to Riot services, or include official Riot assets.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Node.js with npm available in your terminal.
|
||||
- Recommended npm version: 10 or newer.
|
||||
- A local clone of this repository.
|
||||
|
||||
Check your tools:
|
||||
|
||||
```powershell
|
||||
node --version
|
||||
npm --version
|
||||
```
|
||||
|
||||
## Install Dependencies
|
||||
|
||||
Open a terminal in this project folder:
|
||||
|
||||
```powershell
|
||||
cd D:\Codex\Main\CodexTest\projects\league-of-legends-gui-overhaul
|
||||
```
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
This creates `node_modules/` locally. The folder is ignored by Git.
|
||||
|
||||
## Start The GUI
|
||||
|
||||
Start the local development server:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite prints a local URL, usually:
|
||||
|
||||
```text
|
||||
http://localhost:5173
|
||||
```
|
||||
|
||||
Open that URL in your browser.
|
||||
|
||||
## Verify The Installation
|
||||
|
||||
Run the production build:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run the test suite:
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
```
|
||||
|
||||
Optional dependency audit:
|
||||
|
||||
```powershell
|
||||
npm audit --audit-level=moderate
|
||||
```
|
||||
|
||||
## Preview A Production Build
|
||||
|
||||
After `npm run build`, preview the built app:
|
||||
|
||||
```powershell
|
||||
npm run preview
|
||||
```
|
||||
|
||||
## Common Problems
|
||||
|
||||
### `npm` is not recognized
|
||||
|
||||
Install Node.js from the official Node.js distribution or add an existing Node/npm installation to your `PATH`.
|
||||
|
||||
After updating `PATH`, open a new terminal and check again:
|
||||
|
||||
```powershell
|
||||
npm --version
|
||||
```
|
||||
|
||||
### Port 5173 is already in use
|
||||
|
||||
Start Vite on another port:
|
||||
|
||||
```powershell
|
||||
npm run dev -- --port 5174
|
||||
```
|
||||
|
||||
### Dependencies changed
|
||||
|
||||
If `package-lock.json` changed or dependencies were updated, reinstall:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
## What This Installs
|
||||
|
||||
The install step only installs frontend development dependencies for this prototype:
|
||||
|
||||
- React
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React Router
|
||||
- Vitest
|
||||
- Testing Library
|
||||
|
||||
It does not install any Riot software, game files, launchers, or protected assets.
|
||||
125
README.md
Normal file
125
README.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# League of Legends GUI Overhaul
|
||||
|
||||
A React/Vite prototype for a modern, dark, MOBA-/fantasy-inspired client interface. The goal of iteration 1 is a stable base GUI: routing, layout, reusable components, mock data, centralized theme tokens, tests, and documentation.
|
||||
|
||||
This project does not use Riot assets and is not a 1:1 clone of the official League of Legends client.
|
||||
|
||||
Repository:
|
||||
|
||||
```text
|
||||
https://git.wilkensxl.de/Toxic/league-of-legends-gui-overhaul.git
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- React
|
||||
- Vite
|
||||
- TypeScript
|
||||
- React Router
|
||||
- Vitest
|
||||
- Testing Library
|
||||
- CSS Custom Properties
|
||||
|
||||
## Setup
|
||||
|
||||
For detailed installation steps, see [INSTALL.md](INSTALL.md).
|
||||
|
||||
Install dependencies:
|
||||
|
||||
```powershell
|
||||
npm install
|
||||
```
|
||||
|
||||
Start the dev server:
|
||||
|
||||
```powershell
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Build:
|
||||
|
||||
```powershell
|
||||
npm run build
|
||||
```
|
||||
|
||||
Run tests:
|
||||
|
||||
```powershell
|
||||
npm test
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```text
|
||||
src/
|
||||
|-- app/
|
||||
|-- components/
|
||||
| `-- ui/
|
||||
|-- config/
|
||||
|-- data/
|
||||
|-- features/
|
||||
|-- layouts/
|
||||
|-- pages/
|
||||
|-- styles/
|
||||
|-- test/
|
||||
`-- types/
|
||||
```
|
||||
|
||||
## Extending The App
|
||||
|
||||
Add a page:
|
||||
|
||||
1. Create a component in `src/pages/`.
|
||||
2. Register it in `src/config/routes.tsx`.
|
||||
3. Add a navigation item in `src/config/navigation.ts` only if it should appear in the sidebar.
|
||||
|
||||
Add a navigation item:
|
||||
|
||||
1. Add an entry with `id`, `label`, `path`, and optional `iconName`, `section`, `enabled`.
|
||||
2. Keep sidebar rendering generic; do not hardcode menu entries in `AppShell`.
|
||||
|
||||
Adjust theme tokens:
|
||||
|
||||
1. Edit `src/styles/theme.css`.
|
||||
2. Add preset overrides with `[data-theme="preset-name"]`.
|
||||
3. Prefer tokens over page-level hardcoded colors, spacing, shadows, or radii.
|
||||
|
||||
Extend mock data:
|
||||
|
||||
1. Add or edit records in `src/data/`.
|
||||
2. Keep display logic in pages or feature modules, not inside mock files.
|
||||
|
||||
## Iteration 1 Includes
|
||||
|
||||
- App Shell with header, sidebar, content area, and status footer
|
||||
- Routes for Home, Play, Champions, Profile, and Settings
|
||||
- A mock champion detail route at `/champions/:championId`
|
||||
- A right-side Social Sidebar powered by friend mock data
|
||||
- Session-only theme presets from Settings
|
||||
- Prototype Toast flows from header, settings, and social actions
|
||||
- Base UI components: Button, Panel, SearchInput, Dropdown, Modal, Tabs, Tooltip, Toast
|
||||
- Mock data for champions, friends, news, and match history
|
||||
- Central CSS theme tokens
|
||||
- Smoke tests for rendering, navigation, routing, and core UI components
|
||||
|
||||
## Iteration 1 Does Not Include
|
||||
|
||||
- Login
|
||||
- Riot API integration
|
||||
- Matchmaking
|
||||
- Persistence
|
||||
- Full champion detail content beyond the first placeholder route
|
||||
- Plugin engine
|
||||
- Final visual polish
|
||||
- Official Riot or League assets
|
||||
|
||||
## Next Iterations
|
||||
|
||||
- Polish the App Shell visuals
|
||||
- Add ability, skin, and progression modules to champion detail pages
|
||||
- Expand the social sidebar with parties, invites, and richer presence
|
||||
- Add persisted theme preferences
|
||||
- Use Modal flows in more page-level actions
|
||||
- Improve responsive behavior
|
||||
- Add subtle transitions and animation states
|
||||
- Plan a lightweight plugin/module extension layer
|
||||
22
SECURITY.md
Normal file
22
SECURITY.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported Versions
|
||||
|
||||
| Version | Supported |
|
||||
| --- | --- |
|
||||
| Latest project state | Yes |
|
||||
|
||||
## Reporting A Vulnerability
|
||||
|
||||
Report security issues privately to the project owner.
|
||||
|
||||
Do not include secrets, production data, private credentials, or unreleased exploit details in public issues.
|
||||
|
||||
## Project Security Principles
|
||||
|
||||
- Keep secrets out of the repository.
|
||||
- Do not commit `.env` files, tokens, certificates, private keys, or generated credentials.
|
||||
- Prefer local processing for user data.
|
||||
- Document external network calls when implementation begins.
|
||||
- Keep release artifacts reproducible through CI once release artifacts exist.
|
||||
- Run dependency audits before releases once dependencies exist.
|
||||
38
docs/agent-handoff.md
Normal file
38
docs/agent-handoff.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Agent Handoff
|
||||
|
||||
Use this file when a task spans multiple sessions, has unresolved follow-up work, or changes release behavior.
|
||||
|
||||
## Current State
|
||||
|
||||
```text
|
||||
Codex Agent Repository Kit baseline applied. The project is a React/Vite/TypeScript prototype with npm scripts for development, build, and tests.
|
||||
```
|
||||
|
||||
## Changes Made
|
||||
|
||||
- Added agent instructions and project notes.
|
||||
- Added security, contribution, changelog, release checklist, security review, and release notes documents.
|
||||
- Added non-destructive Gitea maintenance workflows.
|
||||
- Project remote set to `https://git.wilkensxl.de/Toxic/league-of-legends-gui-overhaul.git`.
|
||||
- Documented current npm build and test commands.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `git diff --check` | PENDING |
|
||||
|
||||
## Open Questions
|
||||
|
||||
- Separate lint, audit, and release-check scripts.
|
||||
- Release packaging and download target.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add separate lint, audit, and release-check scripts if needed.
|
||||
- Add or update build workflow around `npm run build` and `npm test`.
|
||||
- Update README, `AGENTS.md`, `.codex/project.md`, and release docs when implementation begins.
|
||||
|
||||
## Risks
|
||||
|
||||
- Release packaging is intentionally limited until download targets are defined.
|
||||
38
docs/release-checklist.md
Normal file
38
docs/release-checklist.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# Release Checklist
|
||||
|
||||
No release process exists yet. Complete this checklist only after the project has real build, test, audit, and artifact commands.
|
||||
|
||||
## Version
|
||||
|
||||
- [ ] Version number updated.
|
||||
- [ ] Changelog updated.
|
||||
- [ ] README updated.
|
||||
|
||||
## Quality
|
||||
|
||||
- [ ] Working tree is clean.
|
||||
- [ ] Lint or type checks pass.
|
||||
- [ ] Tests pass or missing tests are documented.
|
||||
- [ ] Build succeeds in CI.
|
||||
|
||||
## Security
|
||||
|
||||
- [ ] Security review is current.
|
||||
- [ ] Dependency audit is clean or documented.
|
||||
- [ ] No secrets are committed.
|
||||
- [ ] Release artifacts do not contain local config files.
|
||||
|
||||
## Artifacts
|
||||
|
||||
- [ ] Artifacts are produced by documented commands.
|
||||
- [ ] Artifacts are uploaded.
|
||||
- [ ] Download links work.
|
||||
- [ ] Package registry links work if used.
|
||||
- [ ] Installer, portable, or archive naming is clear.
|
||||
|
||||
## Release
|
||||
|
||||
- [ ] Git tag created.
|
||||
- [ ] Release notes written.
|
||||
- [ ] Release published.
|
||||
- [ ] Post-release download smoke test completed.
|
||||
25
docs/release-notes.md
Normal file
25
docs/release-notes.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# League of Legends GUI Overhaul PENDING
|
||||
|
||||
## Downloads
|
||||
|
||||
No release artifacts exist yet.
|
||||
|
||||
## Highlights
|
||||
|
||||
- Codex Agent Repository Kit baseline applied.
|
||||
|
||||
## Security
|
||||
|
||||
- Dependency audit: pending until dependencies exist.
|
||||
- Secret handling: secrets must not be committed.
|
||||
- External network calls: pending until implementation exists.
|
||||
|
||||
## Verification
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `git diff --check` | PENDING |
|
||||
|
||||
## Notes
|
||||
|
||||
Release notes are a placeholder until the project has a releasable artifact.
|
||||
54
docs/security-review.md
Normal file
54
docs/security-review.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# Security Review
|
||||
|
||||
## Scope
|
||||
|
||||
Project:
|
||||
|
||||
```text
|
||||
League of Legends GUI Overhaul
|
||||
```
|
||||
|
||||
Reviewed version or commit:
|
||||
|
||||
```text
|
||||
PENDING
|
||||
```
|
||||
|
||||
## Code Patterns Checked
|
||||
|
||||
- [ ] No `eval`.
|
||||
- [ ] No dynamic `Function` constructor.
|
||||
- [ ] No unsafe HTML injection.
|
||||
- [ ] No unexpected shell execution.
|
||||
- [ ] No unexpected external network calls.
|
||||
- [ ] No secrets committed.
|
||||
- [ ] No unsafe file writes outside expected user-selected paths.
|
||||
|
||||
## Dependency Review
|
||||
|
||||
Command:
|
||||
|
||||
```text
|
||||
PENDING
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
No dependency audit exists yet because no implementation stack or dependency manifest exists.
|
||||
```
|
||||
|
||||
## Runtime Review
|
||||
|
||||
- [ ] Least-privilege runtime configuration.
|
||||
- [ ] External URLs documented.
|
||||
- [ ] Local data storage documented.
|
||||
- [ ] Sensitive data is not persisted unless explicitly required.
|
||||
|
||||
## Release Notes
|
||||
|
||||
Known residual risks:
|
||||
|
||||
```text
|
||||
The implementation stack, runtime behavior, and artifact process are not defined yet.
|
||||
```
|
||||
12
index.html
Normal file
12
index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>League GUI Overhaul</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2534
package-lock.json
generated
Normal file
2534
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
29
package.json
Normal file
29
package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "league-of-legends-gui-overhaul",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.30.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@testing-library/user-event": "^14.5.2",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"jsdom": "^25.0.1",
|
||||
"typescript": "^5.6.3",
|
||||
"vite": "^8.0.11",
|
||||
"vitest": "^4.1.5"
|
||||
}
|
||||
}
|
||||
78
src/app/App.test.tsx
Normal file
78
src/app/App.test.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderAppAt } from "../test/renderWithRouter";
|
||||
|
||||
describe("App shell", () => {
|
||||
it("renders the main layout and navigation", () => {
|
||||
renderAppAt("/");
|
||||
|
||||
const navigation = screen.getByRole("navigation", { name: /primary navigation/i });
|
||||
|
||||
expect(screen.getByText("Nexus Overhaul")).toBeInTheDocument();
|
||||
expect(navigation).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("link", { name: /home/i })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("link", { name: /play/i })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("link", { name: /champions/i })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("link", { name: /profile/i })).toBeInTheDocument();
|
||||
expect(within(navigation).getByRole("link", { name: /settings/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("complementary", { name: /friends and social status/i })).toBeInTheDocument();
|
||||
expect(screen.getByText(/Status: local prototype/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Theme: dusk-gold/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/", "Dashboard"],
|
||||
["/play", "Queue prototype"],
|
||||
["/champions", "Collection prototype"],
|
||||
["/champions/nyra", "Combat Profile"],
|
||||
["/profile", "Player profile"],
|
||||
["/settings", "Configuration prototype"]
|
||||
])("renders route %s", (path, expectedText) => {
|
||||
renderAppAt(path);
|
||||
|
||||
expect(screen.getByText(expectedText)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("switches views through sidebar navigation", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAppAt("/");
|
||||
const navigation = screen.getByRole("navigation", { name: /primary navigation/i });
|
||||
|
||||
await user.click(within(navigation).getByRole("link", { name: /champions/i }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Champions" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a champion detail page from the champion grid", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAppAt("/champions");
|
||||
|
||||
await user.click(screen.getByRole("link", { name: /nyra/i }));
|
||||
|
||||
expect(screen.getByRole("heading", { name: "Nyra" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Combat Profile")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies a theme preset from settings", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { container } = renderAppAt("/settings");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText(/theme preset/i), "arcane-teal");
|
||||
|
||||
expect(container.querySelector(".app-shell")).toHaveAttribute("data-theme", "arcane-teal");
|
||||
expect(screen.getByText(/Theme: arcane-teal/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("Theme preview applied")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows a toast when sending a social invite", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderAppAt("/");
|
||||
const socialSidebar = screen.getByRole("complementary", { name: /friends and social status/i });
|
||||
|
||||
await user.click(within(socialSidebar).getAllByRole("button", { name: /invite/i })[0]);
|
||||
|
||||
expect(screen.getByText("Invite sent")).toBeInTheDocument();
|
||||
expect(screen.getByText(/received a prototype party invite/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
16
src/app/App.tsx
Normal file
16
src/app/App.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import { AppProvider } from "./AppContext";
|
||||
import { AppRoutes } from "./router";
|
||||
import { AppShell } from "../layouts/AppShell";
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<AppProvider>
|
||||
<BrowserRouter future={{ v7_relativeSplatPath: true, v7_startTransition: true }}>
|
||||
<AppShell>
|
||||
<AppRoutes />
|
||||
</AppShell>
|
||||
</BrowserRouter>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
64
src/app/AppContext.tsx
Normal file
64
src/app/AppContext.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { createContext, useContext, useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import type { ToastVariant } from "../components/ui";
|
||||
|
||||
export type ThemePreset = "dusk-gold" | "arcane-teal" | "obsidian";
|
||||
|
||||
export type AppNotification = {
|
||||
id: string;
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: ToastVariant;
|
||||
};
|
||||
|
||||
type AppContextValue = {
|
||||
theme: ThemePreset;
|
||||
setTheme: (theme: ThemePreset) => void;
|
||||
notifications: AppNotification[];
|
||||
notify: (notification: Omit<AppNotification, "id">) => void;
|
||||
dismissNotification: (id: string) => void;
|
||||
};
|
||||
|
||||
const AppContext = createContext<AppContextValue | undefined>(undefined);
|
||||
|
||||
type AppProviderProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppProvider({ children }: AppProviderProps) {
|
||||
const [theme, setTheme] = useState<ThemePreset>("dusk-gold");
|
||||
const [notifications, setNotifications] = useState<AppNotification[]>([]);
|
||||
|
||||
const value = useMemo<AppContextValue>(
|
||||
() => ({
|
||||
theme,
|
||||
setTheme,
|
||||
notifications,
|
||||
notify: (notification) => {
|
||||
setNotifications((current) => [
|
||||
...current.slice(-2),
|
||||
{
|
||||
...notification,
|
||||
id: crypto.randomUUID()
|
||||
}
|
||||
]);
|
||||
},
|
||||
dismissNotification: (id) => {
|
||||
setNotifications((current) => current.filter((notification) => notification.id !== id));
|
||||
}
|
||||
}),
|
||||
[notifications, theme]
|
||||
);
|
||||
|
||||
return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAppContext() {
|
||||
const context = useContext(AppContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useAppContext must be used inside AppProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
13
src/app/router.tsx
Normal file
13
src/app/router.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { appRoutes } from "../config/routes";
|
||||
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Routes>
|
||||
{appRoutes.map((route) => (
|
||||
<Route key={route.id} path={route.path} element={<route.component />} />
|
||||
))}
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
30
src/components/ui/Button.tsx
Normal file
30
src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type ButtonVariant = "primary" | "secondary" | "ghost";
|
||||
type ButtonSize = "sm" | "md" | "lg";
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
children: ReactNode;
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
active?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = "primary",
|
||||
size = "md",
|
||||
active = false,
|
||||
className = "",
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const classes = ["ui-button", `ui-button-${variant}`, `ui-button-${size}`, active ? "is-active" : "", className]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
|
||||
return (
|
||||
<button className={classes} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
26
src/components/ui/Dropdown.tsx
Normal file
26
src/components/ui/Dropdown.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { SelectHTMLAttributes } from "react";
|
||||
|
||||
export type DropdownOption = {
|
||||
label: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type DropdownProps = SelectHTMLAttributes<HTMLSelectElement> & {
|
||||
label: string;
|
||||
options: DropdownOption[];
|
||||
};
|
||||
|
||||
export function Dropdown({ label, options, className = "", ...props }: DropdownProps) {
|
||||
return (
|
||||
<label className={["ui-field", className].filter(Boolean).join(" ")}>
|
||||
<span>{label}</span>
|
||||
<select className="ui-select" {...props}>
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
29
src/components/ui/Modal.tsx
Normal file
29
src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
export function Modal({ open, title, children, onClose }: ModalProps) {
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ui-modal-backdrop" role="presentation">
|
||||
<section className="ui-modal" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||
<header className="ui-modal-header">
|
||||
<h2 id="modal-title">{title}</h2>
|
||||
<Button variant="ghost" size="sm" onClick={onClose} aria-label="Close modal">
|
||||
Close
|
||||
</Button>
|
||||
</header>
|
||||
<div>{children}</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
21
src/components/ui/Panel.tsx
Normal file
21
src/components/ui/Panel.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
type PanelProps = HTMLAttributes<HTMLElement> & {
|
||||
children: ReactNode;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
};
|
||||
|
||||
export function Panel({ children, title, subtitle, className = "", ...props }: PanelProps) {
|
||||
return (
|
||||
<section className={["ui-panel", className].filter(Boolean).join(" ")} {...props}>
|
||||
{(title || subtitle) && (
|
||||
<header className="ui-panel-header">
|
||||
{title && <h2>{title}</h2>}
|
||||
{subtitle && <p>{subtitle}</p>}
|
||||
</header>
|
||||
)}
|
||||
{children}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/SearchInput.tsx
Normal file
14
src/components/ui/SearchInput.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { InputHTMLAttributes } from "react";
|
||||
|
||||
type SearchInputProps = InputHTMLAttributes<HTMLInputElement> & {
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export function SearchInput({ label = "Search", className = "", ...props }: SearchInputProps) {
|
||||
return (
|
||||
<label className={["ui-field", className].filter(Boolean).join(" ")}>
|
||||
<span>{label}</span>
|
||||
<input className="ui-input" type="search" {...props} />
|
||||
</label>
|
||||
);
|
||||
}
|
||||
42
src/components/ui/Tabs.tsx
Normal file
42
src/components/ui/Tabs.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Button } from "./Button";
|
||||
|
||||
export type TabItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
content: ReactNode;
|
||||
};
|
||||
|
||||
type TabsProps = {
|
||||
tabs: TabItem[];
|
||||
defaultTabId?: string;
|
||||
};
|
||||
|
||||
export function Tabs({ tabs, defaultTabId }: TabsProps) {
|
||||
const [activeId, setActiveId] = useState(defaultTabId ?? tabs[0]?.id);
|
||||
const activeTab = tabs.find((tab) => tab.id === activeId) ?? tabs[0];
|
||||
|
||||
return (
|
||||
<div className="ui-tabs">
|
||||
<div className="ui-tab-list" role="tablist">
|
||||
{tabs.map((tab) => (
|
||||
<Button
|
||||
key={tab.id}
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
role="tab"
|
||||
active={tab.id === activeTab.id}
|
||||
aria-selected={tab.id === activeTab.id}
|
||||
onClick={() => setActiveId(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="ui-tab-panel" role="tabpanel">
|
||||
{activeTab.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
src/components/ui/Toast.tsx
Normal file
24
src/components/ui/Toast.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
export type ToastVariant = "info" | "success" | "warning";
|
||||
|
||||
type ToastProps = {
|
||||
title: string;
|
||||
message: string;
|
||||
variant?: ToastVariant;
|
||||
onDismiss?: () => void;
|
||||
};
|
||||
|
||||
export function Toast({ title, message, variant = "info", onDismiss }: ToastProps) {
|
||||
return (
|
||||
<aside className={`ui-toast ui-toast-${variant}`} role="status">
|
||||
<div>
|
||||
<strong>{title}</strong>
|
||||
<span>{message}</span>
|
||||
</div>
|
||||
{onDismiss && (
|
||||
<button className="toast-dismiss" type="button" onClick={onDismiss} aria-label={`Dismiss ${title}`}>
|
||||
x
|
||||
</button>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
14
src/components/ui/Tooltip.tsx
Normal file
14
src/components/ui/Tooltip.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
type TooltipProps = {
|
||||
content: string;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function Tooltip({ content, children }: TooltipProps) {
|
||||
return (
|
||||
<span className="ui-tooltip" data-tooltip={content}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
11
src/components/ui/index.ts
Normal file
11
src/components/ui/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export { Button } from "./Button";
|
||||
export { Dropdown } from "./Dropdown";
|
||||
export type { DropdownOption } from "./Dropdown";
|
||||
export { Modal } from "./Modal";
|
||||
export { Panel } from "./Panel";
|
||||
export { SearchInput } from "./SearchInput";
|
||||
export { Tabs } from "./Tabs";
|
||||
export type { TabItem } from "./Tabs";
|
||||
export { Toast } from "./Toast";
|
||||
export type { ToastVariant } from "./Toast";
|
||||
export { Tooltip } from "./Tooltip";
|
||||
44
src/components/ui/ui.test.tsx
Normal file
44
src/components/ui/ui.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Button, Dropdown, Panel, SearchInput } from ".";
|
||||
import { renderWithRouter } from "../../test/renderWithRouter";
|
||||
|
||||
describe("base UI components", () => {
|
||||
it("renders a button", () => {
|
||||
renderWithRouter(<Button>Confirm</Button>);
|
||||
|
||||
expect(screen.getByRole("button", { name: /confirm/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a panel with title and content", () => {
|
||||
renderWithRouter(
|
||||
<Panel title="Panel title">
|
||||
<p>Panel body</p>
|
||||
</Panel>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { name: /panel title/i })).toBeInTheDocument();
|
||||
expect(screen.getByText("Panel body")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a search input", () => {
|
||||
renderWithRouter(<SearchInput label="Champion search" placeholder="Search" />);
|
||||
|
||||
expect(screen.getByLabelText(/champion search/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a dropdown", () => {
|
||||
renderWithRouter(
|
||||
<Dropdown
|
||||
label="Role"
|
||||
options={[
|
||||
{ label: "All roles", value: "all" },
|
||||
{ label: "Duelist", value: "duelist" }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByLabelText(/role/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /duelist/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
44
src/config/navigation.ts
Normal file
44
src/config/navigation.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { NavigationItem } from "../types/navigation";
|
||||
|
||||
export const navigationItems: NavigationItem[] = [
|
||||
{
|
||||
id: "home",
|
||||
label: "Home",
|
||||
path: "/",
|
||||
iconName: "H",
|
||||
section: "primary",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "play",
|
||||
label: "Play",
|
||||
path: "/play",
|
||||
iconName: "P",
|
||||
section: "primary",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "champions",
|
||||
label: "Champions",
|
||||
path: "/champions",
|
||||
iconName: "C",
|
||||
section: "collection",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
label: "Profile",
|
||||
path: "/profile",
|
||||
iconName: "R",
|
||||
section: "account",
|
||||
enabled: true
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
label: "Settings",
|
||||
path: "/settings",
|
||||
iconName: "S",
|
||||
section: "system",
|
||||
enabled: true
|
||||
}
|
||||
];
|
||||
49
src/config/routes.tsx
Normal file
49
src/config/routes.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { ChampionDetailPage } from "../pages/ChampionDetailPage";
|
||||
import { ChampionsPage } from "../pages/ChampionsPage";
|
||||
import { HomePage } from "../pages/HomePage";
|
||||
import { PlayPage } from "../pages/PlayPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
import { SettingsPage } from "../pages/SettingsPage";
|
||||
import type { AppRoute } from "../types/navigation";
|
||||
|
||||
type RouteComponent = ComponentType;
|
||||
|
||||
export const appRoutes: Array<AppRoute & { component: RouteComponent }> = [
|
||||
{
|
||||
id: "home",
|
||||
title: "Home",
|
||||
path: "/",
|
||||
component: HomePage
|
||||
},
|
||||
{
|
||||
id: "play",
|
||||
title: "Play",
|
||||
path: "/play",
|
||||
component: PlayPage
|
||||
},
|
||||
{
|
||||
id: "champions",
|
||||
title: "Champions",
|
||||
path: "/champions",
|
||||
component: ChampionsPage
|
||||
},
|
||||
{
|
||||
id: "champion-detail",
|
||||
title: "Champion Detail",
|
||||
path: "/champions/:championId",
|
||||
component: ChampionDetailPage
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
title: "Profile",
|
||||
path: "/profile",
|
||||
component: ProfilePage
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
path: "/settings",
|
||||
component: SettingsPage
|
||||
}
|
||||
];
|
||||
40
src/data/champions.mock.ts
Normal file
40
src/data/champions.mock.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { Champion } from "../types/domain";
|
||||
|
||||
export const champions: Champion[] = [
|
||||
{
|
||||
id: "nyra",
|
||||
name: "Nyra",
|
||||
role: "Invoker",
|
||||
difficulty: "Medium",
|
||||
tags: ["arcane", "control", "mid"],
|
||||
shortDescription: "A stormbound tactician who shapes fights with precise zones.",
|
||||
accentColor: "#36d7d0"
|
||||
},
|
||||
{
|
||||
id: "kael",
|
||||
name: "Kael",
|
||||
role: "Duelist",
|
||||
difficulty: "High",
|
||||
tags: ["melee", "burst", "solo"],
|
||||
shortDescription: "A blade-focused skirmisher built around timing and pressure.",
|
||||
accentColor: "#c89b3c"
|
||||
},
|
||||
{
|
||||
id: "maera",
|
||||
name: "Maera",
|
||||
role: "Guardian",
|
||||
difficulty: "Low",
|
||||
tags: ["support", "shield", "teamfight"],
|
||||
shortDescription: "A steady frontliner who protects allies and anchors engages.",
|
||||
accentColor: "#5b8cff"
|
||||
},
|
||||
{
|
||||
id: "oren",
|
||||
name: "Oren",
|
||||
role: "Striker",
|
||||
difficulty: "Medium",
|
||||
tags: ["ranged", "tempo", "objective"],
|
||||
shortDescription: "A marksman prototype focused on objective windows and spacing.",
|
||||
accentColor: "#e56b6f"
|
||||
}
|
||||
];
|
||||
25
src/data/friends.mock.ts
Normal file
25
src/data/friends.mock.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import type { Friend } from "../types/domain";
|
||||
|
||||
export const friends: Friend[] = [
|
||||
{
|
||||
id: "friend-1",
|
||||
displayName: "Astra",
|
||||
status: "online",
|
||||
activity: "Browsing champions",
|
||||
level: 42
|
||||
},
|
||||
{
|
||||
id: "friend-2",
|
||||
displayName: "Vey",
|
||||
status: "in-game",
|
||||
activity: "Draft lobby",
|
||||
level: 57
|
||||
},
|
||||
{
|
||||
id: "friend-3",
|
||||
displayName: "Solin",
|
||||
status: "away",
|
||||
activity: "Idle",
|
||||
level: 31
|
||||
}
|
||||
];
|
||||
22
src/data/matchHistory.mock.ts
Normal file
22
src/data/matchHistory.mock.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { MatchHistoryEntry } from "../types/domain";
|
||||
|
||||
export const matchHistory: MatchHistoryEntry[] = [
|
||||
{
|
||||
id: "match-1",
|
||||
championName: "Nyra",
|
||||
mode: "Summoner Rift",
|
||||
result: "Victory",
|
||||
kda: "8 / 2 / 11",
|
||||
duration: "31m 12s",
|
||||
date: "Today"
|
||||
},
|
||||
{
|
||||
id: "match-2",
|
||||
championName: "Maera",
|
||||
mode: "ARAM",
|
||||
result: "Defeat",
|
||||
kda: "2 / 6 / 19",
|
||||
duration: "18m 44s",
|
||||
date: "Yesterday"
|
||||
}
|
||||
];
|
||||
18
src/data/news.mock.ts
Normal file
18
src/data/news.mock.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import type { NewsItem } from "../types/domain";
|
||||
|
||||
export const newsItems: NewsItem[] = [
|
||||
{
|
||||
id: "news-1",
|
||||
title: "Base Shell Prototype Ready",
|
||||
category: "Development",
|
||||
date: "2026-05-10",
|
||||
summary: "The first interface foundation focuses on layout, routing, reusable UI, and mock data."
|
||||
},
|
||||
{
|
||||
id: "news-2",
|
||||
title: "Champion Grid Planning",
|
||||
category: "Collection",
|
||||
date: "2026-05-10",
|
||||
summary: "The champion overview starts with searchable mock cards and can later gain filters."
|
||||
}
|
||||
];
|
||||
9
src/features/champions/championLookup.ts
Normal file
9
src/features/champions/championLookup.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { champions } from "../../data/champions.mock";
|
||||
|
||||
export function getChampionById(championId: string | undefined) {
|
||||
if (!championId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return champions.find((champion) => champion.id === championId);
|
||||
}
|
||||
15
src/features/champions/filterChampions.ts
Normal file
15
src/features/champions/filterChampions.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Champion } from "../../types/domain";
|
||||
|
||||
export function filterChampions(champions: Champion[], query: string, role: string): Champion[] {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
|
||||
return champions.filter((champion) => {
|
||||
const matchesRole = role === "all" || champion.role === role;
|
||||
const matchesQuery =
|
||||
normalizedQuery.length === 0 ||
|
||||
champion.name.toLowerCase().includes(normalizedQuery) ||
|
||||
champion.tags.some((tag) => tag.includes(normalizedQuery));
|
||||
|
||||
return matchesRole && matchesQuery;
|
||||
});
|
||||
}
|
||||
6
src/features/profile/profileSummary.ts
Normal file
6
src/features/profile/profileSummary.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const profileSummary = {
|
||||
displayName: "Toxic",
|
||||
level: 42,
|
||||
rank: "Gold prototype rank",
|
||||
note: "No persistence in iteration 1."
|
||||
};
|
||||
44
src/features/social/SocialSidebar.tsx
Normal file
44
src/features/social/SocialSidebar.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useAppContext } from "../../app/AppContext";
|
||||
import { Button } from "../../components/ui";
|
||||
import { friends } from "../../data/friends.mock";
|
||||
import { getFriendStatusLabel } from "./friendStatus";
|
||||
|
||||
export function SocialSidebar() {
|
||||
const { notify } = useAppContext();
|
||||
|
||||
return (
|
||||
<aside className="social-sidebar" aria-label="Friends and social status">
|
||||
<header>
|
||||
<span className="eyebrow">Social</span>
|
||||
<h2>Friends</h2>
|
||||
</header>
|
||||
|
||||
<div className="social-list">
|
||||
{friends.map((friend) => (
|
||||
<article key={friend.id} className="social-card">
|
||||
<span className={`status-dot status-${friend.status}`} />
|
||||
<div>
|
||||
<h3>{friend.displayName}</h3>
|
||||
<p>{getFriendStatusLabel(friend.status)}</p>
|
||||
<span>{friend.activity}</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={friend.status === "offline"}
|
||||
onClick={() =>
|
||||
notify({
|
||||
title: "Invite sent",
|
||||
message: `${friend.displayName} received a prototype party invite.`,
|
||||
variant: "success"
|
||||
})
|
||||
}
|
||||
>
|
||||
Invite
|
||||
</Button>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
12
src/features/social/friendStatus.ts
Normal file
12
src/features/social/friendStatus.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { FriendStatus } from "../../types/domain";
|
||||
|
||||
export function getFriendStatusLabel(status: FriendStatus): string {
|
||||
const labels: Record<FriendStatus, string> = {
|
||||
online: "Online",
|
||||
away: "Away",
|
||||
"in-game": "In game",
|
||||
offline: "Offline"
|
||||
};
|
||||
|
||||
return labels[status];
|
||||
}
|
||||
106
src/layouts/AppShell.tsx
Normal file
106
src/layouts/AppShell.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { useAppContext } from "../app/AppContext";
|
||||
import { navigationItems } from "../config/navigation";
|
||||
import { Button } from "../components/ui/Button";
|
||||
import { Toast } from "../components/ui/Toast";
|
||||
import { SocialSidebar } from "../features/social/SocialSidebar";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const enabledItems = navigationItems.filter((item) => item.enabled !== false);
|
||||
const { dismissNotification, notifications, notify, theme } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="app-shell" data-theme={theme}>
|
||||
<aside className="sidebar" aria-label="Primary navigation">
|
||||
<div className="brand">
|
||||
<span className="brand-mark">N</span>
|
||||
<div>
|
||||
<strong>Nexus Overhaul</strong>
|
||||
<span>Base GUI</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="nav-list" aria-label="Primary navigation">
|
||||
{enabledItems.map((item) => (
|
||||
<NavLink
|
||||
key={item.id}
|
||||
to={item.path}
|
||||
end={item.path === "/"}
|
||||
className={({ isActive }) => `nav-item${isActive ? " nav-item-active" : ""}`}
|
||||
>
|
||||
<span className="nav-icon" aria-hidden="true">
|
||||
{item.iconName ?? item.label.charAt(0)}
|
||||
</span>
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="app-frame">
|
||||
<header className="topbar">
|
||||
<div>
|
||||
<span className="eyebrow">Iteration 1</span>
|
||||
<h1>MOBA Interface Foundation</h1>
|
||||
</div>
|
||||
<div className="topbar-actions">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
notify({
|
||||
title: "Patch notes",
|
||||
message: "News and patch notes are still powered by mock data.",
|
||||
variant: "info"
|
||||
})
|
||||
}
|
||||
>
|
||||
Patch Notes
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
notify({
|
||||
title: "Session queued",
|
||||
message: "This is a prototype action. No matchmaking is started.",
|
||||
variant: "success"
|
||||
})
|
||||
}
|
||||
>
|
||||
Start Session
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main className="content-area">{children}</main>
|
||||
|
||||
<footer className="status-bar">
|
||||
<span>Status: local prototype</span>
|
||||
<span>Theme: {theme}</span>
|
||||
<span>Modules: base shell</span>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<SocialSidebar />
|
||||
|
||||
{notifications.length > 0 && (
|
||||
<div className="toast-stack" aria-label="Notifications">
|
||||
{notifications.map((notification) => (
|
||||
<Toast
|
||||
key={notification.id}
|
||||
title={notification.title}
|
||||
message={notification.message}
|
||||
variant={notification.variant}
|
||||
onDismiss={() => dismissNotification(notification.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/main.tsx
Normal file
11
src/main.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { App } from "./app/App";
|
||||
import "./styles/theme.css";
|
||||
import "./styles/global.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
67
src/pages/ChampionDetailPage.tsx
Normal file
67
src/pages/ChampionDetailPage.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Panel } from "../components/ui";
|
||||
import { getChampionById } from "../features/champions/championLookup";
|
||||
|
||||
export function ChampionDetailPage() {
|
||||
const { championId } = useParams();
|
||||
const champion = getChampionById(championId);
|
||||
|
||||
if (!champion) {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Panel title="Champion not found" subtitle="The requested mock champion does not exist.">
|
||||
<Link className="text-link" to="/champions">
|
||||
Return to champions
|
||||
</Link>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="champion-detail-hero">
|
||||
<div className="champion-portrait" style={{ "--champion-accent": champion.accentColor } as CSSProperties}>
|
||||
{champion.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">{champion.role}</span>
|
||||
<h2>{champion.name}</h2>
|
||||
<p>{champion.shortDescription}</p>
|
||||
<div className="tag-row">
|
||||
{champion.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Panel title="Combat Profile" subtitle="Placeholder values for future tuning">
|
||||
<dl className="stat-list">
|
||||
<div>
|
||||
<dt>Difficulty</dt>
|
||||
<dd>{champion.difficulty}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Primary Role</dt>
|
||||
<dd>{champion.role}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Theme Hint</dt>
|
||||
<dd>{champion.accentColor ?? "Default accent"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Future Detail Modules" subtitle="Prepared but not implemented">
|
||||
<p>Abilities, skins, progression, lore, and build recommendations can be added as independent modules.</p>
|
||||
<Link className="action-link action-link-inline" to="/champions">
|
||||
Back to overview
|
||||
</Link>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
72
src/pages/ChampionsPage.tsx
Normal file
72
src/pages/ChampionsPage.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { CSSProperties } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Dropdown, Panel, SearchInput } from "../components/ui";
|
||||
import { champions } from "../data/champions.mock";
|
||||
import { filterChampions } from "../features/champions/filterChampions";
|
||||
|
||||
const roleOptions = [
|
||||
{ label: "All roles", value: "all" },
|
||||
{ label: "Controller", value: "Controller" },
|
||||
{ label: "Duelist", value: "Duelist" },
|
||||
{ label: "Guardian", value: "Guardian" },
|
||||
{ label: "Invoker", value: "Invoker" },
|
||||
{ label: "Striker", value: "Striker" }
|
||||
];
|
||||
|
||||
export function ChampionsPage() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [role, setRole] = useState("all");
|
||||
|
||||
const filteredChampions = useMemo(() => {
|
||||
return filterChampions(champions, query, role);
|
||||
}, [query, role]);
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="page-heading">
|
||||
<span className="eyebrow">Collection prototype</span>
|
||||
<h2>Champions</h2>
|
||||
<p>Searchable mock champion cards with filtering hooks for later collection features.</p>
|
||||
</section>
|
||||
|
||||
<div className="toolbar">
|
||||
<SearchInput
|
||||
label="Champion search"
|
||||
placeholder="Search by name or tag"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
/>
|
||||
<Dropdown label="Role" options={roleOptions} value={role} onChange={(event) => setRole(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="champion-grid">
|
||||
{filteredChampions.map((champion) => (
|
||||
<Link key={champion.id} to={`/champions/${champion.id}`} className="champion-card">
|
||||
<div className="champion-sigil" style={{ "--champion-accent": champion.accentColor } as CSSProperties}>
|
||||
{champion.name.charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="champion-card-header">
|
||||
<div>
|
||||
<span className="eyebrow">{champion.role}</span>
|
||||
<h3>{champion.name}</h3>
|
||||
</div>
|
||||
<span className={`difficulty-badge difficulty-${champion.difficulty.toLowerCase()}`}>
|
||||
{champion.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
<p>{champion.shortDescription}</p>
|
||||
<div className="tag-row">
|
||||
{champion.tags.map((tag) => (
|
||||
<span key={tag}>{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<span className="card-action">View details</span>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/pages/HomePage.tsx
Normal file
68
src/pages/HomePage.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import { Link } from "react-router-dom";
|
||||
import { newsItems } from "../data/news.mock";
|
||||
import { Button, Panel, Tabs, Toast } from "../components/ui";
|
||||
|
||||
export function HomePage() {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="hero-panel">
|
||||
<div>
|
||||
<span className="eyebrow">Dashboard</span>
|
||||
<h2>Build the foundation before the spectacle.</h2>
|
||||
<p>
|
||||
A modular shell for future MOBA-inspired views, themes, social panels, and champion experiences.
|
||||
</p>
|
||||
</div>
|
||||
<div className="hero-actions">
|
||||
<Link className="action-link" to="/play">
|
||||
Play Prototype
|
||||
</Link>
|
||||
<Link className="text-link" to="/champions">
|
||||
Browse champions
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Panel title="Quick Actions" subtitle="Entry points for iteration 1">
|
||||
<div className="button-row">
|
||||
<Button>Open Play</Button>
|
||||
<Button variant="secondary">Review Collection</Button>
|
||||
<Button variant="ghost">Read Notes</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Patch Notes" subtitle="Mock news from separated data">
|
||||
<div className="list-stack">
|
||||
{newsItems.map((item) => (
|
||||
<article key={item.id} className="compact-card">
|
||||
<span className="eyebrow">{item.category}</span>
|
||||
<h3>{item.title}</h3>
|
||||
<p>{item.summary}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="System Status" subtitle="Local prototype state">
|
||||
<Tabs
|
||||
tabs={[
|
||||
{
|
||||
id: "layout",
|
||||
label: "Layout",
|
||||
content: <p>Shell, navigation, status bar, and content regions are wired.</p>
|
||||
},
|
||||
{
|
||||
id: "data",
|
||||
label: "Data",
|
||||
content: <p>Mock data is isolated from page and UI components.</p>
|
||||
}
|
||||
]}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<Toast title="Base GUI" message="Iteration 1 focuses on structure and testability." />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
src/pages/PlayPage.tsx
Normal file
49
src/pages/PlayPage.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react";
|
||||
import { Button, Modal, Panel, Tooltip } from "../components/ui";
|
||||
|
||||
const playModes = [
|
||||
{
|
||||
id: "rift",
|
||||
name: "Summoner Rift",
|
||||
description: "Structured 5v5 queue placeholder for the main arena experience."
|
||||
},
|
||||
{
|
||||
id: "aram",
|
||||
name: "ARAM",
|
||||
description: "Compact teamfight mode placeholder for faster sessions."
|
||||
},
|
||||
{
|
||||
id: "custom",
|
||||
name: "Custom",
|
||||
description: "Private lobby placeholder for future party and settings flows."
|
||||
}
|
||||
];
|
||||
|
||||
export function PlayPage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="page-heading">
|
||||
<span className="eyebrow">Queue prototype</span>
|
||||
<h2>Play</h2>
|
||||
<p>Select a mock mode and open the lobby dialog. No matchmaking exists in iteration 1.</p>
|
||||
</section>
|
||||
|
||||
<div className="mode-grid">
|
||||
{playModes.map((mode) => (
|
||||
<Panel key={mode.id} title={mode.name} subtitle="Mode placeholder">
|
||||
<p>{mode.description}</p>
|
||||
<Tooltip content="Opens a non-persistent lobby preview">
|
||||
<Button onClick={() => setModalOpen(true)}>Create Lobby</Button>
|
||||
</Tooltip>
|
||||
</Panel>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Modal open={modalOpen} title="Lobby Preview" onClose={() => setModalOpen(false)}>
|
||||
<p>This dialog proves that modal composition is available for later queue and party flows.</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
src/pages/ProfilePage.tsx
Normal file
59
src/pages/ProfilePage.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Panel } from "../components/ui";
|
||||
import { friends } from "../data/friends.mock";
|
||||
import { matchHistory } from "../data/matchHistory.mock";
|
||||
import { profileSummary } from "../features/profile/profileSummary";
|
||||
import { getFriendStatusLabel } from "../features/social/friendStatus";
|
||||
|
||||
export function ProfilePage() {
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="profile-header">
|
||||
<div className="profile-avatar" aria-hidden="true">
|
||||
T
|
||||
</div>
|
||||
<div>
|
||||
<span className="eyebrow">Player profile</span>
|
||||
<h2>{profileSummary.displayName}</h2>
|
||||
<p>
|
||||
Level {profileSummary.level} - {profileSummary.rank} - {profileSummary.note}
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid">
|
||||
<Panel title="Match History" subtitle="Mock entries">
|
||||
<div className="list-stack">
|
||||
{matchHistory.map((match) => (
|
||||
<article key={match.id} className="compact-card compact-card-row">
|
||||
<div>
|
||||
<h3>{match.championName}</h3>
|
||||
<p>
|
||||
{match.mode} - {match.duration} - {match.date}
|
||||
</p>
|
||||
</div>
|
||||
<strong className={match.result === "Victory" ? "result-win" : "result-loss"}>{match.result}</strong>
|
||||
<span>{match.kda}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
|
||||
<Panel title="Friends" subtitle="Social sidebar source data">
|
||||
<div className="list-stack">
|
||||
{friends.map((friend) => (
|
||||
<article key={friend.id} className="friend-row">
|
||||
<span className={`status-dot status-${friend.status}`} />
|
||||
<div>
|
||||
<h3>{friend.displayName}</h3>
|
||||
<p>
|
||||
{getFriendStatusLabel(friend.status)} - {friend.activity} {friend.level ? `- Level ${friend.level}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
76
src/pages/SettingsPage.tsx
Normal file
76
src/pages/SettingsPage.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { ThemePreset } from "../app/AppContext";
|
||||
import { useAppContext } from "../app/AppContext";
|
||||
import { Button, Dropdown, Panel, SearchInput } from "../components/ui";
|
||||
|
||||
const densityOptions = [
|
||||
{ label: "Comfortable", value: "comfortable" },
|
||||
{ label: "Compact", value: "compact" },
|
||||
{ label: "Dense", value: "dense" }
|
||||
];
|
||||
|
||||
const themeOptions = [
|
||||
{ label: "Dusk Gold", value: "dusk-gold" },
|
||||
{ label: "Arcane Teal", value: "arcane-teal" },
|
||||
{ label: "Obsidian", value: "obsidian" }
|
||||
];
|
||||
|
||||
export function SettingsPage() {
|
||||
const { notify, setTheme, theme } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<section className="page-heading">
|
||||
<span className="eyebrow">Configuration prototype</span>
|
||||
<h2>Settings</h2>
|
||||
<p>Demonstrates form controls for future theme and interface preferences.</p>
|
||||
</section>
|
||||
|
||||
<Panel title="Interface" subtitle="Non-persistent controls for iteration 1">
|
||||
<div className="settings-grid">
|
||||
<Dropdown
|
||||
label="Theme preset"
|
||||
options={themeOptions}
|
||||
value={theme}
|
||||
onChange={(event) => {
|
||||
const nextTheme = event.target.value as ThemePreset;
|
||||
setTheme(nextTheme);
|
||||
notify({
|
||||
title: "Theme preview applied",
|
||||
message: `${nextTheme} is active for this session.`,
|
||||
variant: "success"
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Dropdown label="Layout density" options={densityOptions} defaultValue="comfortable" />
|
||||
<SearchInput label="Search settings" placeholder="Find a future setting" />
|
||||
</div>
|
||||
<div className="button-row">
|
||||
<Button
|
||||
onClick={() =>
|
||||
notify({
|
||||
title: "Preview active",
|
||||
message: "Settings are session-only until persistence is added.",
|
||||
variant: "info"
|
||||
})
|
||||
}
|
||||
>
|
||||
Apply Preview
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setTheme("dusk-gold");
|
||||
notify({
|
||||
title: "Settings reset",
|
||||
message: "Theme preview returned to dusk-gold.",
|
||||
variant: "warning"
|
||||
});
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
681
src/styles/global.css
Normal file
681
src/styles/global.css
Normal file
@@ -0,0 +1,681 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgb(54 215 208 / 0.14), transparent 28rem),
|
||||
linear-gradient(135deg, #05070d 0%, var(--color-bg) 46%, #0b1220 100%);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-body);
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 17rem minmax(0, 1fr) 18rem;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--color-border);
|
||||
background: rgb(7 10 18 / 0.92);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.social-sidebar {
|
||||
border-left: 1px solid var(--color-border);
|
||||
background: linear-gradient(180deg, rgb(13 20 34 / 0.94), rgb(7 10 18 / 0.92));
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.social-sidebar h2 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.social-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.social-card {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(17 27 45 / 0.76);
|
||||
padding: var(--space-3);
|
||||
}
|
||||
|
||||
.social-card h3 {
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.social-card span:not(.status-dot) {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-6);
|
||||
}
|
||||
|
||||
.brand span:last-child {
|
||||
display: block;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.brand-mark,
|
||||
.nav-icon,
|
||||
.profile-avatar {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: linear-gradient(135deg, rgb(200 155 60 / 0.28), rgb(54 215 208 / 0.1));
|
||||
color: var(--color-gold-bright);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.nav-list {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 var(--space-3);
|
||||
color: var(--color-text-muted);
|
||||
transition: background var(--transition-fast), border-color var(--transition-fast), color var(--transition-fast);
|
||||
}
|
||||
|
||||
.nav-item:hover,
|
||||
.nav-item-active {
|
||||
border-color: var(--color-border-strong);
|
||||
background: rgb(200 155 60 / 0.1);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.nav-icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.app-frame {
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.topbar,
|
||||
.status-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: var(--space-4) var(--space-5);
|
||||
background: rgb(13 20 34 / 0.82);
|
||||
}
|
||||
|
||||
.topbar h1 {
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
}
|
||||
|
||||
.topbar-actions,
|
||||
.button-row,
|
||||
.hero-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-3);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.content-area {
|
||||
min-width: 0;
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.status-bar {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.page-heading,
|
||||
.hero-panel,
|
||||
.profile-header {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
background: linear-gradient(135deg, rgb(22 36 58 / 0.95), rgb(13 20 34 / 0.88));
|
||||
box-shadow: var(--shadow-panel);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.hero-panel {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-5);
|
||||
}
|
||||
|
||||
.hero-panel h2,
|
||||
.page-heading h2,
|
||||
.profile-header h2 {
|
||||
margin-bottom: var(--space-2);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: inline-block;
|
||||
margin-bottom: var(--space-2);
|
||||
color: var(--color-gold-bright);
|
||||
font-size: 0.74rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.dashboard-grid,
|
||||
.mode-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.champion-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.ui-panel,
|
||||
.champion-card,
|
||||
.compact-card,
|
||||
.ui-toast {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: rgb(17 27 45 / 0.9);
|
||||
box-shadow: var(--shadow-panel);
|
||||
padding: var(--space-4);
|
||||
}
|
||||
|
||||
.ui-panel-header {
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.ui-panel-header h2 {
|
||||
margin-bottom: var(--space-1);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.ui-button,
|
||||
.action-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 0 var(--space-4);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
transition: transform var(--transition-fast), border-color var(--transition-fast), background var(--transition-fast);
|
||||
}
|
||||
|
||||
.ui-button:hover,
|
||||
.action-link:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: var(--color-gold-bright);
|
||||
}
|
||||
|
||||
.ui-button-primary,
|
||||
.action-link {
|
||||
background: linear-gradient(135deg, #9b752b, #312613);
|
||||
}
|
||||
|
||||
.ui-button-secondary {
|
||||
background: rgb(54 215 208 / 0.12);
|
||||
border-color: rgb(54 215 208 / 0.55);
|
||||
}
|
||||
|
||||
.ui-button-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-button-sm {
|
||||
min-height: 2rem;
|
||||
padding: 0 var(--space-3);
|
||||
font-size: 0.86rem;
|
||||
}
|
||||
|
||||
.ui-button-lg {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.ui-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.ui-button.is-active {
|
||||
border-color: var(--color-teal);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ui-field {
|
||||
display: grid;
|
||||
gap: var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-input,
|
||||
.ui-select {
|
||||
width: 100%;
|
||||
min-height: 2.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
padding: 0 var(--space-3);
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.settings-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 14rem;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.settings-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
.list-stack {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.compact-card h3,
|
||||
.friend-row h3,
|
||||
.champion-card h3 {
|
||||
margin-bottom: var(--space-1);
|
||||
}
|
||||
|
||||
.compact-card-row,
|
||||
.friend-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.champion-card {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
transition: border-color var(--transition-fast), box-shadow var(--transition-fast), transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.champion-card:hover {
|
||||
border-color: var(--color-gold);
|
||||
box-shadow: var(--shadow-glow), var(--shadow-panel);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.champion-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-3);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.difficulty-badge,
|
||||
.card-action {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.difficulty-low {
|
||||
border-color: var(--color-success);
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.difficulty-medium {
|
||||
border-color: var(--color-warning);
|
||||
color: var(--color-warning);
|
||||
}
|
||||
|
||||
.difficulty-high {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.card-action {
|
||||
display: inline-flex;
|
||||
margin-top: var(--space-4);
|
||||
border-color: var(--color-gold);
|
||||
color: var(--color-gold-bright);
|
||||
}
|
||||
|
||||
.champion-sigil {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid var(--champion-accent, var(--color-gold));
|
||||
border-radius: var(--radius-md);
|
||||
background: color-mix(in srgb, var(--champion-accent, var(--color-gold)) 20%, transparent);
|
||||
color: var(--color-text);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
margin-top: var(--space-3);
|
||||
}
|
||||
|
||||
.tag-row span {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: var(--space-1) var(--space-2);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.champion-detail-hero {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: var(--space-5);
|
||||
align-items: center;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
radial-gradient(circle at top left, color-mix(in srgb, var(--champion-accent, var(--color-gold)) 24%, transparent), transparent 24rem),
|
||||
linear-gradient(135deg, rgb(22 36 58 / 0.96), rgb(7 10 18 / 0.94));
|
||||
box-shadow: var(--shadow-glow), var(--shadow-panel);
|
||||
padding: var(--space-6);
|
||||
}
|
||||
|
||||
.champion-detail-hero h2 {
|
||||
margin-bottom: var(--space-2);
|
||||
font-family: var(--font-display);
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.champion-portrait {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 8rem;
|
||||
height: 8rem;
|
||||
border: 1px solid var(--champion-accent, var(--color-gold));
|
||||
border-radius: var(--radius-lg);
|
||||
background:
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--champion-accent, var(--color-gold)) 28%, transparent), transparent),
|
||||
var(--color-surface-strong);
|
||||
color: var(--color-text);
|
||||
font-family: var(--font-display);
|
||||
font-size: 3rem;
|
||||
}
|
||||
|
||||
.stat-list {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.stat-list div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding-bottom: var(--space-2);
|
||||
}
|
||||
|
||||
.stat-list dt {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.stat-list dd {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.action-link-inline {
|
||||
margin-top: var(--space-4);
|
||||
}
|
||||
|
||||
.profile-avatar {
|
||||
width: 5rem;
|
||||
height: 5rem;
|
||||
border-radius: 50%;
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 0.75rem;
|
||||
height: 0.75rem;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.status-online,
|
||||
.status-in-game {
|
||||
background: var(--color-success);
|
||||
}
|
||||
|
||||
.status-away {
|
||||
background: var(--color-warning);
|
||||
}
|
||||
|
||||
.result-win {
|
||||
color: var(--color-success);
|
||||
}
|
||||
|
||||
.result-loss {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.text-link {
|
||||
color: var(--color-teal);
|
||||
}
|
||||
|
||||
.ui-tabs {
|
||||
display: grid;
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.ui-tab-list {
|
||||
display: flex;
|
||||
gap: var(--space-2);
|
||||
}
|
||||
|
||||
.ui-tab-panel {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.ui-tooltip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.ui-tooltip:hover::after {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
bottom: calc(100% + var(--space-2));
|
||||
z-index: 10;
|
||||
min-width: 12rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-elevated);
|
||||
color: var(--color-text);
|
||||
content: attr(data-tooltip);
|
||||
padding: var(--space-2);
|
||||
}
|
||||
|
||||
.ui-toast {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-1);
|
||||
border-color: rgb(54 215 208 / 0.45);
|
||||
}
|
||||
|
||||
.ui-toast div {
|
||||
display: grid;
|
||||
gap: var(--space-1);
|
||||
}
|
||||
|
||||
.toast-stack {
|
||||
position: fixed;
|
||||
right: var(--space-5);
|
||||
bottom: var(--space-5);
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
width: min(24rem, calc(100vw - 2rem));
|
||||
gap: var(--space-3);
|
||||
}
|
||||
|
||||
.toast-dismiss {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ui-toast-success {
|
||||
border-color: var(--color-success);
|
||||
}
|
||||
|
||||
.ui-toast-warning {
|
||||
border-color: var(--color-warning);
|
||||
}
|
||||
|
||||
.ui-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgb(0 0 0 / 0.62);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.ui-modal {
|
||||
width: min(34rem, 100%);
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-glow), var(--shadow-panel);
|
||||
padding: var(--space-5);
|
||||
}
|
||||
|
||||
.ui-modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.social-sidebar {
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.nav-list,
|
||||
.dashboard-grid,
|
||||
.mode-grid,
|
||||
.toolbar,
|
||||
.settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-panel,
|
||||
.champion-detail-hero,
|
||||
.topbar,
|
||||
.status-bar {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.champion-detail-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
60
src/styles/theme.css
Normal file
60
src/styles/theme.css
Normal file
@@ -0,0 +1,60 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
--color-bg: #070a12;
|
||||
--color-bg-elevated: #0d1422;
|
||||
--color-surface: #111b2d;
|
||||
--color-surface-strong: #16243a;
|
||||
--color-border: #2b3a52;
|
||||
--color-border-strong: #6f5622;
|
||||
--color-text: #f2f0e8;
|
||||
--color-text-muted: #aeb8c8;
|
||||
--color-gold: #c89b3c;
|
||||
--color-gold-bright: #f0c66c;
|
||||
--color-teal: #36d7d0;
|
||||
--color-blue: #5b8cff;
|
||||
--color-danger: #e56b6f;
|
||||
--color-warning: #f0b35f;
|
||||
--color-success: #61d394;
|
||||
--space-1: 0.25rem;
|
||||
--space-2: 0.5rem;
|
||||
--space-3: 0.75rem;
|
||||
--space-4: 1rem;
|
||||
--space-5: 1.5rem;
|
||||
--space-6: 2rem;
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
--shadow-panel: 0 18px 48px rgb(0 0 0 / 0.32);
|
||||
--shadow-glow: 0 0 28px rgb(54 215 208 / 0.14);
|
||||
--font-body: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--font-display: Georgia, "Times New Roman", serif;
|
||||
--transition-fast: 160ms ease;
|
||||
}
|
||||
|
||||
[data-theme="arcane-teal"] {
|
||||
--color-bg: #041216;
|
||||
--color-bg-elevated: #092029;
|
||||
--color-surface: #0c2a33;
|
||||
--color-surface-strong: #123945;
|
||||
--color-border: #1d5561;
|
||||
--color-border-strong: #36d7d0;
|
||||
--color-gold: #b7a46a;
|
||||
--color-gold-bright: #ead58a;
|
||||
--color-teal: #55f2e8;
|
||||
--color-blue: #61a4ff;
|
||||
--shadow-glow: 0 0 30px rgb(85 242 232 / 0.18);
|
||||
}
|
||||
|
||||
[data-theme="obsidian"] {
|
||||
--color-bg: #050507;
|
||||
--color-bg-elevated: #0d0d12;
|
||||
--color-surface: #15151c;
|
||||
--color-surface-strong: #20202a;
|
||||
--color-border: #343444;
|
||||
--color-border-strong: #8d6d2e;
|
||||
--color-gold: #b98a32;
|
||||
--color-gold-bright: #e4bc65;
|
||||
--color-teal: #6796a7;
|
||||
--color-blue: #7f8fbf;
|
||||
--shadow-glow: 0 0 24px rgb(185 138 50 / 0.14);
|
||||
}
|
||||
24
src/test/renderWithRouter.tsx
Normal file
24
src/test/renderWithRouter.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { render } from "@testing-library/react";
|
||||
import { AppProvider } from "../app/AppContext";
|
||||
import { AppRoutes } from "../app/router";
|
||||
import { AppShell } from "../layouts/AppShell";
|
||||
|
||||
export function renderAppAt(path = "/") {
|
||||
return render(
|
||||
<AppProvider>
|
||||
<MemoryRouter initialEntries={[path]} future={{ v7_relativeSplatPath: true, v7_startTransition: true }}>
|
||||
<AppShell>
|
||||
<AppRoutes />
|
||||
</AppShell>
|
||||
</MemoryRouter>
|
||||
</AppProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderWithRouter(children: ReactNode) {
|
||||
return render(
|
||||
<MemoryRouter future={{ v7_relativeSplatPath: true, v7_startTransition: true }}>{children}</MemoryRouter>
|
||||
);
|
||||
}
|
||||
1
src/test/setup.ts
Normal file
1
src/test/setup.ts
Normal file
@@ -0,0 +1 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
37
src/types/domain.ts
Normal file
37
src/types/domain.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export type Champion = {
|
||||
id: string;
|
||||
name: string;
|
||||
role: "Controller" | "Duelist" | "Guardian" | "Invoker" | "Striker";
|
||||
difficulty: "Low" | "Medium" | "High";
|
||||
tags: string[];
|
||||
shortDescription: string;
|
||||
accentColor?: string;
|
||||
};
|
||||
|
||||
export type FriendStatus = "online" | "away" | "in-game" | "offline";
|
||||
|
||||
export type Friend = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
status: FriendStatus;
|
||||
activity: string;
|
||||
level?: number;
|
||||
};
|
||||
|
||||
export type NewsItem = {
|
||||
id: string;
|
||||
title: string;
|
||||
category: string;
|
||||
date: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export type MatchHistoryEntry = {
|
||||
id: string;
|
||||
championName: string;
|
||||
mode: string;
|
||||
result: "Victory" | "Defeat";
|
||||
kda: string;
|
||||
duration: string;
|
||||
date: string;
|
||||
};
|
||||
16
src/types/navigation.ts
Normal file
16
src/types/navigation.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export type NavigationSection = "primary" | "collection" | "account" | "system";
|
||||
|
||||
export type NavigationItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
path: string;
|
||||
iconName?: string;
|
||||
section?: NavigationSection;
|
||||
enabled?: boolean;
|
||||
};
|
||||
|
||||
export type AppRoute = {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
};
|
||||
1
src/vite-env.d.ts
vendored
Normal file
1
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
20
tsconfig.json
Normal file
20
tsconfig.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ES2020"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
13
vite.config.ts
Normal file
13
vite.config.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/// <reference types="vitest" />
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
setupFiles: "./src/test/setup.ts"
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user