diff --git a/.claude/architecture.md b/.claude/architecture.md new file mode 100644 index 0000000..4b8629a --- /dev/null +++ b/.claude/architecture.md @@ -0,0 +1,25 @@ +# Architecture + +The system is a **modular monolith**: a single deployable unit divided into cohesive modules with explicit boundaries. Modules communicate through well-defined interfaces (ports); never by directly referencing each other's internals. A module maps to a bounded context. + +## Default: Clean (Onion) Architecture + +Apply when domain logic is non-trivial: + +- **Domain layer** (center): entities, value objects, aggregates, domain events, domain services. Zero dependencies on anything outside this layer. +- **Application layer**: use cases / command & query handlers (CQRS). Orchestrates domain objects. No infrastructure dependencies — depends on domain interfaces only. +- **Infrastructure layer**: persistence, messaging, external APIs. Implements interfaces defined inward. Never referenced by domain or application. +- **Presentation layer**: API controllers, CLI. Thin — validates input, delegates to application layer, maps response. +- Dependencies point **inward only**. Enforce with architecture tests (e.g., ArchUnit, NetArchTest). + +## Alternative: Vertical Slice Architecture + +Prefer vertical slices when a feature is CRUD-heavy with little shared domain logic and minimal interaction with other domain concepts. Each slice owns its request, handler, validation, and response — fully self-contained. When logic is shared across slices, extract it to the domain layer. + +## Domain-Driven Design + +- Use **ubiquitous language** everywhere: class names, methods, variables, and tests must use domain terminology, never technical jargon. +- **Bounded contexts** align with module boundaries. Each module owns its domain model. Shared concepts are translated at the boundary via an anti-corruption layer — never leaked directly. +- **Aggregates** protect invariants. Only the aggregate root is reachable from outside; enforce consistency within the aggregate boundary. +- **Value objects** for concepts defined by value, not identity (e.g., `Money`, `Email`, `DateRange`). Make them immutable. +- **Domain events** for cross-context communication. Never call another bounded context's internals directly. diff --git a/.claude/commands/pr.md b/.claude/commands/pr.md new file mode 100644 index 0000000..3a1d414 --- /dev/null +++ b/.claude/commands/pr.md @@ -0,0 +1,90 @@ +# Create PR with AI Code Review + +Create a pull request and post an AI code review to GitHub with line-specific comments. + +## Steps + +1. **Check for uncommitted changes** + - Run `git status` to check for staged/unstaged changes + - If there are uncommitted changes: + - Run `git diff` to understand what changed + - Ask the user if they want to commit first, suggesting a commit message based on the changes + - Use AskUserQuestion with options like: + - "Yes, commit with suggested message" (show the suggestion) + - "Yes, but let me provide the message" + - "No, I'll handle it myself" + - If yes, stage and commit the changes + +2. **Check branch state** + - Run `git branch --show-current` to get current branch + - If on master/main: + - Analyze the changes to suggest a branch name (e.g., `feature/add-user-auth`, `fix/login-validation`) + - Ask the user for the branch name using AskUserQuestion with options: + - The suggested branch name (recommended) + - "Let me type a different name" + - Create the branch: `git checkout -b ` + - Run `git log origin/master..HEAD --oneline` to see commits for PR + - Push branch if not yet pushed: `git push -u origin HEAD` + +3. **Analyze changes** + - Run `git diff origin/master...HEAD` to see all changes + - Understand what the PR accomplishes + +4. **Create the PR** + - Use `gh pr create` with a clear title and summary + - Format body as: + ``` + ## Summary + + + ## Test plan + + ``` + +5. **Perform code review** + Review the diff for: + - Bugs or logic errors + - Security issues (injection, secrets, auth) + - Missing error handling + - Performance concerns + - Anti-patterns (e.g., `catch (Exception ex) { throw ex; }` instead of `throw;`) + + Be pragmatic - this is a hackathon. Flag real issues, not nitpicks. + +6. **Post line-specific comments** + For each issue found, post a comment on the specific line: + ```bash + # Get PR number and commit SHA + PR_NUMBER=$(gh pr view --json number --jq '.number') + COMMIT_SHA=$(gh pr view --json headRefOid --jq '.headRefOid') + REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') + + # Post comment on specific line + gh api repos/$REPO/pulls/$PR_NUMBER/comments \ + --method POST \ + --field body="Your comment here" \ + --field commit_id="$COMMIT_SHA" \ + --field path="path/to/file.cs" \ + --field line=42 \ + --field side="RIGHT" + ``` + + Common issues to flag: + - `throw ex;` → "Use `throw;` to preserve stack trace" + - `catch (Exception) { }` → "Empty catch block swallows errors" + - Hardcoded secrets → "Move to configuration/environment variables" + - Missing null checks → "Potential NullReferenceException" + - SQL concatenation → "Use parameterized queries to prevent SQL injection" + +7. **Post summary review** + After line comments, post overall review: + ```bash + gh pr review --comment --body "AI Review: Found N issues - see inline comments." + ``` + + Or if no issues: + ```bash + gh pr review --approve --body "AI Review: Code looks good." + ``` + +8. **Return the PR URL** so the user can view it. diff --git a/.claude/frontend.md b/.claude/frontend.md new file mode 100644 index 0000000..755d6c0 --- /dev/null +++ b/.claude/frontend.md @@ -0,0 +1,47 @@ +# Frontend (React) + +## React Version & Patterns + +- Before implementing any React feature, consult **[react.dev](https://react.dev)** to verify the current recommended approach. Use `WebFetch` on react.dev when in doubt — never rely solely on training data for React APIs. +- Use **React Server Components (RSC)** for data fetching and server-side logic by default. Mark components `"use client"` only when interactivity or browser APIs require it. +- Use **hooks exclusively** — no class components, no legacy lifecycle methods. +- Prefer modern built-in hooks: `use()`, `useActionState`, `useOptimistic`, `useFormStatus`. Avoid legacy workarounds they replace. +- Never use patterns marked deprecated on react.dev. Migrate proactively on major releases. +- Avoid `useEffect` for data fetching; use RSC or Suspense-compatible data fetching instead. + +## Component Design + +- **Composition over configuration**: small, single-purpose components composed together. +- Keep components **pure**: no side effects during render. Side effects belong in event handlers or `useEffect` (sparingly). +- Colocate state as close as possible to where it is used. Lift only when siblings genuinely share it. +- The React Compiler handles most memoization — do not manually add `useMemo`/`useCallback`/`React.memo` unless profiling proves it necessary. + +## State Management + +- **Local state first** (`useState`, `useReducer`). Lift to shared context only when needed. +- Use `useContext` for low-frequency global state (theme, current user). For high-frequency updates, use Zustand or Jotai. +- **Server state belongs server-side**: RSC or TanStack Query — not `useState` + `useEffect`. +- Avoid Redux unless already established in the project. + +## Accessibility + +- Use **semantic HTML** elements. Never use a `
` or `` where a semantic element exists. +- Every interactive element must be keyboard-navigable and have an accessible label. +- Use ARIA attributes only when semantic HTML is insufficient. +- Accessibility violations are bugs. Include axe-core checks in the E2E suite. + +## TDD for Frontend + +- Use **React Testing Library + Vitest** for component and hook tests. +- Test **behaviour, not implementation**: query by role, label, or text — never by CSS class or component internals. +- Never assert on internal state directly. +- Mock at the **network boundary using MSW** (Mock Service Worker), not at the module level. +- One failing test at a time — same hard rules as backend TDD apply. + +## E2E Testing with Playwright + +- E2E tests cover **complete user journeys**: happy path + critical error paths. +- Use **Page Object Model (POM)** to encapsulate selectors and interactions. +- Prefer semantic selectors: `getByRole` → `getByLabel` → `getByText` → `data-testid`. +- Run Playwright tests in CI against a real running application, not mocks. +- Include **axe-core accessibility checks** (`@axe-core/playwright`) on every critical page. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..ac16bce --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,52 @@ +{ + "permissions": { + "allow": [ + "Bash(bun install)", + "Bash(bun run *)", + "Bash(bun add *)", + "Bash(bun remove *)", + "Bash(dotnet build*)", + "Bash(dotnet test*)", + "Bash(dotnet run*)", + "Bash(dotnet watch*)", + "Bash(dotnet format*)", + "Bash(dotnet ef migrations*)", + "Bash(dotnet restore*)", + "Bash(docker compose*)", + "Bash(git status*)", + "Bash(git diff*)", + "Bash(git log*)", + "Bash(git branch*)", + "Bash(git checkout*)", + "Bash(git switch*)", + "Bash(git add*)", + "Bash(git commit*)", + "Bash(git pull*)", + "Bash(git fetch*)", + "Bash(git stash*)", + "Bash(git merge*)", + "Bash(git rebase*)", + "Bash(npx playwright*)", + "Bash(npx vitest*)", + "Bash(ls *)", + "Bash(dir *)", + "Bash(pwd)", + "Bash(cat *)", + "Bash(head *)", + "Bash(tail *)" + ], + "deny": [ + "Bash(rm -rf /)", + "Bash(rm -rf ~)", + "Bash(rm -rf .)", + "Bash(git push --force*)", + "Bash(git push -f *)", + "Bash(git reset --hard*)", + "Bash(git clean -fd*)", + "Bash(git checkout -- .)", + "Bash(git restore .)", + "Bash(: > *)", + "Bash(truncate *)" + ] + } +} diff --git a/.claude/tdd.md b/.claude/tdd.md new file mode 100644 index 0000000..fd745e7 --- /dev/null +++ b/.claude/tdd.md @@ -0,0 +1,17 @@ +# Test-Driven Development + +Work **one test at a time** through the red-green-refactor cycle: + +1. **Red** — write one failing test for a single acceptance criterion. Run tests; confirm it fails for the right reason (not a compile error or wrong assertion). +2. **Green** — write the minimum code to make it pass. Fake it (hardcode a return value) if that suffices; only generalize when a new test forces it. Run tests; confirm green. +3. **Refactor** — improve structure and clarity without changing behaviour. Run tests; confirm still green. +4. Repeat for the next requirement. + +## Hard Rules + +- Never write production code without a currently failing test that requires it. +- Never have more than one failing test at a time. +- Never refactor while any test is red. +- Each test validates exactly one acceptance criterion — name it accordingly. + +For new features or non-trivial changes, always invoke the `tdd-guide` skill to drive the cycle. Skip only for trivial changes (typos, config, renaming). diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..cf80c12 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,96 @@ +name: Build & Test + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + backend: + name: Backend Build + runs-on: ubuntu-latest + + defaults: + run: + working-directory: Itenium.SkillForge/backend + + steps: + - uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + + - name: Authenticate to GitHub Packages + run: dotnet nuget update source itenium --username laoujin --password ${{ secrets.NUGET_AUTH_TOKEN }} --store-password-in-clear-text + + - name: Restore dependencies + run: dotnet restore + + - name: Build + run: dotnet build --no-restore + + - name: Format check + run: dotnet format --verify-no-changes --no-restore + + - name: Test + run: dotnet test --no-build --verbosity normal + + - name: Vulnerability scan + run: dotnet list package --vulnerable --include-transitive 2>&1 | tee /dev/stderr | grep -q "no vulnerable packages" || (echo "::error::Vulnerable packages found" && exit 1) + + frontend: + name: Frontend Build + runs-on: ubuntu-latest + + defaults: + run: + working-directory: Itenium.SkillForge/frontend + + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Authenticate to GitHub Packages (npm) + run: | + echo "@itenium-forge:registry=https://npm.pkg.github.com" >> .npmrc + echo "//npm.pkg.github.com/:_authToken=${{ secrets.NUGET_AUTH_TOKEN }}" >> .npmrc + + - name: Install dependencies + run: bun install + + - name: Format check + run: bun run format:check + + - name: Lint + run: bun run lint + + - name: Type check + run: bun run typecheck + + - name: Unit tests + run: bun run test + + - name: Build + run: bun run build + + - name: Vulnerability scan + run: bun audit --production || true + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Run E2E tests + run: bun run test:e2e + env: + NUGET_USER: ${{ github.actor }} + NUGET_TOKEN: ${{ secrets.NUGET_AUTH_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..67249c6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +~$Bootcamp-AI.pptx +_bmad +.claude/settings.local.json +.claude/commands/bmad* diff --git a/.idea/.idea.Bootcamp-AI/.idea/workspace.xml b/.idea/.idea.Bootcamp-AI/.idea/workspace.xml new file mode 100644 index 0000000..6d6faf8 --- /dev/null +++ b/.idea/.idea.Bootcamp-AI/.idea/workspace.xml @@ -0,0 +1,110 @@ + + + + Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj + + + + + + + + + + + + + + + + + + + + + + + + + + + + 1773245403450 + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/BMAD.md b/BMAD.md new file mode 100644 index 0000000..9f0eafd --- /dev/null +++ b/BMAD.md @@ -0,0 +1,18 @@ +BMAD +==== + +- [Github Source](https://github.com/bmad-code-org/BMAD-METHOD) +- [Official Docs](https://docs.bmad-method.org/) + +## Getting Started + +```ps1 +bunx bmad-method install +``` + +## Start BMadding + +```ps1 +claude +/bmad-help +``` diff --git a/Bootcamp-AI.pptx b/Bootcamp-AI.pptx new file mode 100644 index 0000000..54e6f55 Binary files /dev/null and b/Bootcamp-AI.pptx differ diff --git a/Bootcamp-AI.sln b/Bootcamp-AI.sln new file mode 100644 index 0000000..6a9271c --- /dev/null +++ b/Bootcamp-AI.sln @@ -0,0 +1,67 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.5.2.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Itenium.SkillForge", "Itenium.SkillForge", "{6DB7CC45-F2A5-D9B0-05BA-AFE29D27E357}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "backend", "backend", "{0158FB8E-C755-1129-7AB5-634557BC75DF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.WebApi", "Itenium.SkillForge\backend\Itenium.SkillForge.WebApi\Itenium.SkillForge.WebApi.csproj", "{20B49151-2266-6CBD-3C44-A7633EA2236C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.Services.Tests", "Itenium.SkillForge\backend\Itenium.SkillForge.Services.Tests\Itenium.SkillForge.Services.Tests.csproj", "{0D25CC2F-480E-3111-37EC-8E06A87F7092}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.WebApi.Tests", "Itenium.SkillForge\backend\Itenium.SkillForge.WebApi.Tests\Itenium.SkillForge.WebApi.Tests.csproj", "{797439FC-8450-A060-30DA-7A965A038F95}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.Data", "Itenium.SkillForge\backend\Itenium.SkillForge.Data\Itenium.SkillForge.Data.csproj", "{733CF448-8B21-A23B-6C6C-47E7DBAC5B21}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.Services", "Itenium.SkillForge\backend\Itenium.SkillForge.Services\Itenium.SkillForge.Services.csproj", "{F524B4BF-AB9A-E30F-A242-A7591D0E11C7}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Itenium.SkillForge.Entities", "Itenium.SkillForge\backend\Itenium.SkillForge.Entities\Itenium.SkillForge.Entities.csproj", "{B17D62F6-EC62-3D7A-0BF9-91BC424B0653}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {20B49151-2266-6CBD-3C44-A7633EA2236C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {20B49151-2266-6CBD-3C44-A7633EA2236C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {20B49151-2266-6CBD-3C44-A7633EA2236C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {20B49151-2266-6CBD-3C44-A7633EA2236C}.Release|Any CPU.Build.0 = Release|Any CPU + {0D25CC2F-480E-3111-37EC-8E06A87F7092}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0D25CC2F-480E-3111-37EC-8E06A87F7092}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0D25CC2F-480E-3111-37EC-8E06A87F7092}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0D25CC2F-480E-3111-37EC-8E06A87F7092}.Release|Any CPU.Build.0 = Release|Any CPU + {797439FC-8450-A060-30DA-7A965A038F95}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {797439FC-8450-A060-30DA-7A965A038F95}.Debug|Any CPU.Build.0 = Debug|Any CPU + {797439FC-8450-A060-30DA-7A965A038F95}.Release|Any CPU.ActiveCfg = Release|Any CPU + {797439FC-8450-A060-30DA-7A965A038F95}.Release|Any CPU.Build.0 = Release|Any CPU + {733CF448-8B21-A23B-6C6C-47E7DBAC5B21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {733CF448-8B21-A23B-6C6C-47E7DBAC5B21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {733CF448-8B21-A23B-6C6C-47E7DBAC5B21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {733CF448-8B21-A23B-6C6C-47E7DBAC5B21}.Release|Any CPU.Build.0 = Release|Any CPU + {F524B4BF-AB9A-E30F-A242-A7591D0E11C7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F524B4BF-AB9A-E30F-A242-A7591D0E11C7}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F524B4BF-AB9A-E30F-A242-A7591D0E11C7}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F524B4BF-AB9A-E30F-A242-A7591D0E11C7}.Release|Any CPU.Build.0 = Release|Any CPU + {B17D62F6-EC62-3D7A-0BF9-91BC424B0653}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B17D62F6-EC62-3D7A-0BF9-91BC424B0653}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B17D62F6-EC62-3D7A-0BF9-91BC424B0653}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B17D62F6-EC62-3D7A-0BF9-91BC424B0653}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {0158FB8E-C755-1129-7AB5-634557BC75DF} = {6DB7CC45-F2A5-D9B0-05BA-AFE29D27E357} + {20B49151-2266-6CBD-3C44-A7633EA2236C} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + {0D25CC2F-480E-3111-37EC-8E06A87F7092} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + {797439FC-8450-A060-30DA-7A965A038F95} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + {733CF448-8B21-A23B-6C6C-47E7DBAC5B21} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + {F524B4BF-AB9A-E30F-A242-A7591D0E11C7} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + {B17D62F6-EC62-3D7A-0BF9-91BC424B0653} = {0158FB8E-C755-1129-7AB5-634557BC75DF} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6E8CF174-0254-4E99-A565-9D99D6962818} + EndGlobalSection +EndGlobal diff --git a/Bootcamp-Setup.md b/Bootcamp-Setup.md new file mode 100644 index 0000000..9134e5c --- /dev/null +++ b/Bootcamp-Setup.md @@ -0,0 +1,166 @@ +# 🛠️ AI Bootcamp — Voorbereiding + +Installeer onderstaande tools **vóór de workshop**. Duurt ongeveer **30 minuten**. +Problemen? Neem contact op met je coach. + +--- + +## Te installeren + +| Tool | Waarvoor | Controleer | +|----------------|--------------------------|------------| +| Git | Versiebeheer | `git --version` | +| Node.js LTS | Frontend tooling | `node --version` (v24+) | +| Bun | Package manager frontend | `bun --version` | +| .NET 10 SDK | Backend | `dotnet --version` (10.x) | +| Docker Desktop | PostgreSQL database | `docker --version` | +| Claude Code | AI coding agent | `claude --version` | +| Github CLI | Github Interactie | `gh --version` | +| IDE | VS Code (aanbevolen) \| Cursor \| Antigravity (Google) | — | + + +Voer de commando's rechts uit in een terminal om te checken of iets al geïnstalleerd is. + +--- + +## Installatie + +Open **PowerShell als administrator** en voer onderstaande commando's uit: + +```powershell +winget install Git.Git +winget install OpenJS.NodeJS.LTS # of gebruik NVM! +winget install Oven-sh.Bun +winget install Microsoft.DotNet.SDK.10 +winget install Docker.DockerDesktop +winget install GitHub.cli +gh auth login +``` + +**IDE (kies één):** (of skip) +```powershell +winget install Microsoft.VisualStudioCode +winget install Anysphere.Cursor +# Antigravity: https://ide.google.com +``` + +> Start Docker Desktop na installatie en wacht tot het groen is. +> Herstart je terminal na installatie zodat alle commando's beschikbaar zijn. + + +--- + +## Claude Code installeren + +Je ontvangt later nog een mail met credentials met je subscription. Installeer Claude Code via: + +```powershell +irm https://claude.ai/install.ps1 | iex +``` + +--- + + +## Repository klonen + +```powershell +git clone xxx +cd SkillForge +``` + +## GitHub NuGet toegang instellen + +De backend gebruikt GithHub NuGet packages. Je hebt een GitHub token nodig. + +**Token aanmaken:** +1. Ga naar https://github.com/settings/tokens?type=beta +2. Genereer een nieuw token met **Packages: Read** permissie +3. Kopieer het token + +**Token instellen** (vervang de placeholders): +```powershell +dotnet nuget update source itenium ` + --username JOUW_GITHUB_GEBRUIKERSNAAM ` + --password JOUW_TOKEN ` + --store-password-in-clear-text ` + --configfile backend/nuget.config +``` + +--- + +## Claude Code installeren + +Je ontvangt een mail met credentials voor de team subscription. Installeer Claude Code via: + +**Inloggen**: +```powershell +claude +``` + +Bij de eerste keer opstarten word je gevraagd om in te loggen. Kies **"Sign in with Claude.ai"** en gebruik de credentials uit de mail. + +--- + +## BMAD installeren + +BMAD is het AI agent framework dat we tijdens de workshop gebruiken. Installeer het in de repository: + +```powershell +# Zorg dat je in de SkillForge map staat +cd SkillForge + +bunx bmad-method install +``` + +**Doorloop de setup wizard:** +1. **MCP integration** → kies **Claude Code** +2. **Installation location** → kies **In this repository** (niet globaal) +3. **Modules** → selecteer enkel **Core** (deselect de rest) +4. Bevestig de installatie + +Controleer of de installatie gelukt is: +```powershell +claude +``` + +BMAD-commando's zoals `/bmad` zouden nu beschikbaar moeten zijn in Claude Code. + +--- + +## Alles testen + +**Database starten:** +```powershell +cd Itenium.SkillForge +docker compose up -d +``` + +**Backend starten:** +```powershell +cd backend +dotnet restore +dotnet run --project Itenium.SkillForge.WebApi +``` +Controleer: http://localhost:5000/health/live → moet `Healthy` tonen. + +**Frontend starten** (nieuw terminal venster): +```powershell +cd frontend +bun install +bun run dev +``` +Controleer: http://localhost:5173 → je ziet de login pagina. + +--- + +## Testgebruikers + +| Gebruikersnaam | Wachtwoord | Rol | +|----------------|------------|-----| +| backoffice | AdminPassword123! | Admin | +| learner | UserPassword123! | Learner | + +--- + +Alles werkt? Je bent klaar voor de workshop! 🎉 +Loopt er iets mis? Neem dan **vóór de sessie** contact op met je coach. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..c8f4d9c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,26 @@ +# SkillForge + +## Setup + +- **Backend**: `docker compose up -d` before running +- **Package manager**: use `bun`, not npm/yarn + +## Before committing + +``` +bun run lint && bun run typecheck && bun run test +dotnet format && dotnet test +``` + +## Code Quality + +- Write **human-readable code**: intention-revealing names, small focused functions, no clever tricks. +- **DRY and SOLID apply strictly to production code.** Tests may duplicate setup/data/assertions — never sacrifice test clarity for abstraction. +- Prefer **explicit over implicit**: no magic, no hidden conventions, no surprising side effects. +- Keep the **domain model pure**: no framework annotations, no ORM attributes, no HTTP concerns inside domain or application layers. + +## Guidelines + +@.claude/architecture.md +@.claude/tdd.md +@.claude/frontend.md diff --git a/DagIndeling.md b/DagIndeling.md new file mode 100644 index 0000000..387b25e --- /dev/null +++ b/DagIndeling.md @@ -0,0 +1,40 @@ +Dag Indeling +============ + +Afspraak: Officenter Mechelen. + +Powerpoint Presentation +----------------------- + +1u + +- Technische Setup van de Applicatie + - .NET, EF, WebApi, TypeScript, React, ChadCN, Zustand, Zod, TanStack + - Testing: NUnit, Cucumber, Playwright, Jest +- Hoe gaan we tewerk met Claude Code (agents) + - Elk team kiest zelf hoe ze tewerk gaan + - Vibe Coding: gaan POs & Analisten code schrijven of analyses? + - Wordt er nog naar de code gekeken of niet? + - Sowieso alles met AI!! + - TrunkBased development: commit fast, commit often + - Git Worktree flow uitleggen? +- Welke App gaan we maken? + - SkillForge + - Verdeling van de teams + - De verschillende work areas & assignment van work area aan elk team + - De backlog per team uitleggen + - Wouter, CCCs gaan de backlog items gaan presenteren per team + acten also PO + +SkillForge +---------- + +Rest van de dag: Vibe Coding + +- Na de middag: 5-10min demo per team + + +Final Hour +---------- + +Elk team presenteert wat er gemaakt is + jury (Steven & Heleen?) +kiezen het winnende team. diff --git a/Itenium.SkillForge/.editorconfig b/Itenium.SkillForge/.editorconfig new file mode 100644 index 0000000..f85072d --- /dev/null +++ b/Itenium.SkillForge/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.cs] +indent_size = 4 + +[*.{csproj,props,targets,xml,config}] +indent_size = 2 diff --git a/Itenium.SkillForge/.gitignore b/Itenium.SkillForge/.gitignore new file mode 100644 index 0000000..4de8bf4 --- /dev/null +++ b/Itenium.SkillForge/.gitignore @@ -0,0 +1,33 @@ +# Build results +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio +.vs/ +*.user +*.suo +*.cache + +# Rider +.idea/ + +# Node +node_modules/ +dist/ + +# Databases +*.db +*.db-journal +skillforge.db-* + +# Environment +.env +.env.local +.env.e2e + +# Test artifacts +e2e/.test-state.json +test-results/ +playwright-report/ diff --git a/Itenium.SkillForge/Get-Token.ps1 b/Itenium.SkillForge/Get-Token.ps1 new file mode 100644 index 0000000..19a58c3 --- /dev/null +++ b/Itenium.SkillForge/Get-Token.ps1 @@ -0,0 +1,32 @@ +# Get-Token.ps1 +# Helper script to get an access token for testing the API + +param( + [string]$Username = "backoffice", + [string]$Password = "AdminPassword123!", + [string]$BaseUrl = "http://localhost:5000" +) + +$body = @{ + grant_type = "password" + username = $Username + password = $Password + client_id = "skillforge-spa" + scope = "openid profile email" +} + +try { + $response = Invoke-RestMethod -Uri "$BaseUrl/connect/token" -Method Post -Body $body -ContentType "application/x-www-form-urlencoded" + + Write-Host "Access Token:" -ForegroundColor Green + Write-Host $response.access_token + Write-Host "" + Write-Host "Expires in: $($response.expires_in) seconds" -ForegroundColor Yellow + + # Copy to clipboard + $response.access_token | Set-Clipboard + Write-Host "Token copied to clipboard!" -ForegroundColor Cyan +} +catch { + Write-Host "Error getting token: $_" -ForegroundColor Red +} diff --git a/Itenium.SkillForge/README.md b/Itenium.SkillForge/README.md new file mode 100644 index 0000000..b93c324 --- /dev/null +++ b/Itenium.SkillForge/README.md @@ -0,0 +1,169 @@ +Itenium.SkillForge +================== + +A learning management system built with .NET 10 and React. + +## Project Structure + +``` +Itenium.SkillForge/ +├── backend/ # .NET 10.0 WebApi +└── frontend/ # React + Vite + TypeScript +``` + +## Prerequisites + +### GitHub NuGet Authentication + +This project uses private NuGet packages from GitHub Packages. You need to authenticate before running `dotnet restore`. + +#### Step 1: Create a Personal Access Token (PAT) + +1. Go to https://github.com/settings/tokens?type=beta +2. Click **Generate new token** +3. Give it a name (e.g., "NuGet packages") +4. Set expiration (e.g., 90 days) +5. Under **Repository access**, select "Public Repositories (read-only)" +6. Under **Permissions** → **Account permissions** → **Packages**, select **Read** +7. Click **Generate token** +8. Copy the token (you won't see it again!) + +#### Step 2: Configure NuGet + +Run this command (replace `YOUR_GITHUB_USERNAME` and `YOUR_PAT`): + +```bash +dotnet nuget update source itenium \ + --username YOUR_GITHUB_USERNAME \ + --password YOUR_PAT \ + --store-password-in-clear-text \ + --configfile backend/nuget.config +``` + +This only needs to be done once. The credentials are stored in your user-level NuGet config. + +## Getting Started + +### PostgreSQL Database + +Start PostgreSQL using Docker: + +```bash +docker compose up -d +``` + +This starts a PostgreSQL container with: +- **Host:** localhost +- **Port:** 5432 +- **Database:** skillforge +- **Username:** skillforge +- **Password:** skillforge + +### Backend + +```bash +cd backend +dotnet restore +dotnet run --project Itenium.SkillForge.WebApi + +# Or watch changes and rebuild+restart: +dotnet watch run --project Itenium.SkillForge.WebApi +``` + +Migrations run automatically at startup. + +- [API at :5000](http://localhost:5000) +- [Swagger](http://localhost:5000/swagger) + - Run `.\Get-Token.ps1` to create a JWT +- Health + - [Live](http://localhost:5000/health/live) + - [Ready](http://localhost:5000/health/ready) + + +### Frontend + +```bash +cd frontend +bun install +bun run dev +``` + +The frontend will be available at http://localhost:5173 + +## Test Users + +| Username | Password | Role | Teams | +|------------|-------------------|------------|-----------------| +| backoffice | AdminPassword123! | backoffice | All | +| java | UserPassword123! | manager | Java | +| dotnet | UserPassword123! | manager | .NET | +| multi | UserPassword123! | manager | Java + .NET | +| learner | UserPassword123! | learner | - | + + +## Database Migrations + +Migrations run automatically at startup. To create new migrations after modifying entities: + +```bash +cd backend + +# Add a new migration +dotnet ef migrations add \ + --project Itenium.SkillForge.Data \ + --startup-project Itenium.SkillForge.WebApi \ + --output-dir Migrations + +# Remove the last migration (if not yet applied) +dotnet ef migrations remove \ + --project Itenium.SkillForge.Data \ + --startup-project Itenium.SkillForge.WebApi + +# Generate SQL script for all migrations +dotnet ef migrations script \ + --project Itenium.SkillForge.Data \ + --startup-project Itenium.SkillForge.WebApi \ + --output migrations.sql +``` + +## Running Tests + +### Backend Tests + +Tests use Testcontainers to spin up a PostgreSQL container automatically: + +```bash +cd backend +dotnet test +``` + +### Frontend Tests + +```bash +cd frontend +bun run test +``` + + +### E2E Tests + +E2E tests use Playwright and Testcontainers to spin up both PostgreSQL and the backend: + +```bash +cd frontend + +# Option 1: Use Docker (full e2e setup) +# Set environment variables for GitHub Packages authentication: +$env:NUGET_USER="your-github-username" +$env:NUGET_TOKEN="your-github-pat-with-read:packages" +bun run test:e2e + +# Option 2: Use locally running backend (faster for development) +# Start the backend first, then: +bun run test:e2e:local + +# Other test commands: +bun run test:e2e:ui # Run with Playwright UI +bun run test:e2e:headed # Run with visible browser +bun run test:e2e:debug # Debug mode +``` diff --git a/Itenium.SkillForge/backend/.dockerignore b/Itenium.SkillForge/backend/.dockerignore new file mode 100644 index 0000000..030feeb --- /dev/null +++ b/Itenium.SkillForge/backend/.dockerignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +**/.vs/ +**/*.user +**/TestResults/ +*.DotSettings.user diff --git a/Itenium.SkillForge/backend/.editorconfig b/Itenium.SkillForge/backend/.editorconfig new file mode 100644 index 0000000..9a8ad62 --- /dev/null +++ b/Itenium.SkillForge/backend/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*.cs] + +# CA - Naming +dotnet_diagnostic.CA1707.severity = none # Remove underscores from member name (conflicts with test naming conventions) + +# Meziantou +dotnet_diagnostic.MA0004.severity = none # Use ConfigureAwait(false) (not needed in ASP.NET Core) +dotnet_diagnostic.MA0006.severity = none # Use string.Equals instead of == (too strict) +dotnet_diagnostic.MA0011.severity = none # IFormatProvider (too noisy for general code) +dotnet_diagnostic.MA0026.severity = none # Fix TODO comment +dotnet_diagnostic.MA0048.severity = none # File name must match type name +dotnet_diagnostic.MA0051.severity = none # Method is too long + +# Roslynator +dotnet_diagnostic.RCS1021.severity = none # Convert lambda to method group (style preference) + +# Auto-generated migrations +[**/Migrations/*.cs] +generated_code = true diff --git a/Itenium.SkillForge/backend/Directory.Build.props b/Itenium.SkillForge/backend/Directory.Build.props new file mode 100644 index 0000000..6e299af --- /dev/null +++ b/Itenium.SkillForge/backend/Directory.Build.props @@ -0,0 +1,18 @@ + + + + net10.0 + enable + enable + CS1591;IDE0290 + true + true + latest-recommended + + + + + + + + diff --git a/Itenium.SkillForge/backend/Directory.Packages.props b/Itenium.SkillForge/backend/Directory.Packages.props new file mode 100644 index 0000000..6bcaac3 --- /dev/null +++ b/Itenium.SkillForge/backend/Directory.Packages.props @@ -0,0 +1,32 @@ + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/Dockerfile b/Itenium.SkillForge/backend/Dockerfile new file mode 100644 index 0000000..a3440db --- /dev/null +++ b/Itenium.SkillForge/backend/Dockerfile @@ -0,0 +1,40 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src + +ARG NUGET_USER +ARG NUGET_TOKEN + +# Configure NuGet authentication for GitHub Packages +RUN dotnet nuget add source https://nuget.pkg.github.com/Itenium-Forge/index.json \ + --name itenium \ + --username ${NUGET_USER} \ + --password ${NUGET_TOKEN} \ + --store-password-in-clear-text + +# Copy project files for restore +COPY nuget.config ./ +COPY Directory.Build.props ./ +COPY Directory.Packages.props ./ +COPY Itenium.SkillForge.WebApi/*.csproj ./Itenium.SkillForge.WebApi/ +COPY Itenium.SkillForge.Data/*.csproj ./Itenium.SkillForge.Data/ +COPY Itenium.SkillForge.Entities/*.csproj ./Itenium.SkillForge.Entities/ +COPY Itenium.SkillForge.Services/*.csproj ./Itenium.SkillForge.Services/ + +# Restore dependencies +RUN dotnet restore ./Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj + +# Copy source code and build +COPY . . +RUN dotnet publish ./Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj -c Release -o /app --no-restore + +# Runtime image +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app + +ENV ASPNETCORE_URLS=http://+:8080 +ENV DOTNET_ENVIRONMENT=Docker + +COPY --from=build /app . +EXPOSE 8080 + +ENTRYPOINT ["dotnet", "Itenium.SkillForge.WebApi.dll"] diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/AppDbContext.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/AppDbContext.cs new file mode 100644 index 0000000..2356720 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/AppDbContext.cs @@ -0,0 +1,22 @@ +using Itenium.Forge.Security.OpenIddict; +using Itenium.SkillForge.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Itenium.SkillForge.Data; + +public class AppDbContext : ForgeIdentityDbContext +{ + public AppDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet Teams => Set(); + + public DbSet Courses => Set(); + + protected override void OnModelCreating(ModelBuilder builder) + { + base.OnModelCreating(builder); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Itenium.SkillForge.Data.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Itenium.SkillForge.Data.csproj new file mode 100644 index 0000000..1945849 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Itenium.SkillForge.Data.csproj @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.Designer.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.Designer.cs new file mode 100644 index 0000000..7f4f1b2 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.Designer.cs @@ -0,0 +1,578 @@ +// +using System; +using Itenium.SkillForge.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Itenium.SkillForge.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260105205825_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Itenium.Forge.Security.OpenIddict.ForgeUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Itenium.SkillForge.Entities.CourseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Itenium.SkillForge.Entities.TeamEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.cs new file mode 100644 index 0000000..85786e1 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/20260105205825_InitialCreate.cs @@ -0,0 +1,408 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Itenium.SkillForge.Data.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + Name = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Courses", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false), + Description = table.Column(type: "character varying(2000)", maxLength: 2000, nullable: true), + Category = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Level = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Courses", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictApplications", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ClientId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + ClientSecret = table.Column(type: "text", nullable: true), + ClientType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + ConsentType = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + JsonWebKeySet = table.Column(type: "text", nullable: true), + Permissions = table.Column(type: "text", nullable: true), + PostLogoutRedirectUris = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedirectUris = table.Column(type: "text", nullable: true), + Requirements = table.Column(type: "text", nullable: true), + Settings = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictApplications", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictScopes", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Description = table.Column(type: "text", nullable: true), + Descriptions = table.Column(type: "text", nullable: true), + DisplayName = table.Column(type: "text", nullable: true), + DisplayNames = table.Column(type: "text", nullable: true), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: true), + Properties = table.Column(type: "text", nullable: true), + Resources = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictScopes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Teams", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + Name = table.Column(type: "character varying(200)", maxLength: 200, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Teams", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + FirstName = table.Column(type: "text", nullable: true), + LastName = table.Column(type: "text", nullable: true), + UserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column(type: "boolean", nullable: false), + PasswordHash = table.Column(type: "text", nullable: true), + SecurityStamp = table.Column(type: "text", nullable: true), + ConcurrencyStamp = table.Column(type: "text", nullable: true), + PhoneNumber = table.Column(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column(type: "boolean", nullable: false), + LockoutEnd = table.Column(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column(type: "boolean", nullable: false), + AccessFailedCount = table.Column(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictAuthorizations", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + Properties = table.Column(type: "text", nullable: true), + Scopes = table.Column(type: "text", nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(50)", maxLength: 50, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictAuthorizations", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictAuthorizations_OpenIddictApplications_Application~", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column(type: "text", nullable: false), + ClaimType = table.Column(type: "text", nullable: true), + ClaimValue = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column(type: "text", nullable: false), + ProviderKey = table.Column(type: "text", nullable: false), + ProviderDisplayName = table.Column(type: "text", nullable: true), + UserId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + RoleId = table.Column(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column(type: "text", nullable: false), + LoginProvider = table.Column(type: "text", nullable: false), + Name = table.Column(type: "text", nullable: false), + Value = table.Column(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "OpenIddictTokens", + columns: table => new + { + Id = table.Column(type: "text", nullable: false), + ApplicationId = table.Column(type: "text", nullable: true), + AuthorizationId = table.Column(type: "text", nullable: true), + ConcurrencyToken = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + CreationDate = table.Column(type: "timestamp with time zone", nullable: true), + ExpirationDate = table.Column(type: "timestamp with time zone", nullable: true), + Payload = table.Column(type: "text", nullable: true), + Properties = table.Column(type: "text", nullable: true), + RedemptionDate = table.Column(type: "timestamp with time zone", nullable: true), + ReferenceId = table.Column(type: "character varying(100)", maxLength: 100, nullable: true), + Status = table.Column(type: "character varying(50)", maxLength: 50, nullable: true), + Subject = table.Column(type: "character varying(400)", maxLength: 400, nullable: true), + Type = table.Column(type: "character varying(150)", maxLength: 150, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_OpenIddictTokens", x => x.Id); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictApplications_ApplicationId", + column: x => x.ApplicationId, + principalTable: "OpenIddictApplications", + principalColumn: "Id"); + table.ForeignKey( + name: "FK_OpenIddictTokens_OpenIddictAuthorizations_AuthorizationId", + column: x => x.AuthorizationId, + principalTable: "OpenIddictAuthorizations", + principalColumn: "Id"); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictApplications_ClientId", + table: "OpenIddictApplications", + column: "ClientId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictAuthorizations_ApplicationId_Status_Subject_Type", + table: "OpenIddictAuthorizations", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictScopes_Name", + table: "OpenIddictScopes", + column: "Name", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ApplicationId_Status_Subject_Type", + table: "OpenIddictTokens", + columns: new[] { "ApplicationId", "Status", "Subject", "Type" }); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_AuthorizationId", + table: "OpenIddictTokens", + column: "AuthorizationId"); + + migrationBuilder.CreateIndex( + name: "IX_OpenIddictTokens_ReferenceId", + table: "OpenIddictTokens", + column: "ReferenceId", + unique: true); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "Users", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "Users", + column: "NormalizedUserName", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "Courses"); + + migrationBuilder.DropTable( + name: "OpenIddictScopes"); + + migrationBuilder.DropTable( + name: "OpenIddictTokens"); + + migrationBuilder.DropTable( + name: "Teams"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "OpenIddictAuthorizations"); + + migrationBuilder.DropTable( + name: "OpenIddictApplications"); + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/AppDbContextModelSnapshot.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..d4aa171 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,575 @@ +// +using System; +using Itenium.SkillForge.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Itenium.SkillForge.Data.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Itenium.Forge.Security.OpenIddict.ForgeUser", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .HasColumnType("text"); + + b.Property("LastName") + .HasColumnType("text"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("Users", (string)null); + }); + + modelBuilder.Entity("Itenium.SkillForge.Entities.CourseEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Category") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(2000) + .HasColumnType("character varying(2000)"); + + b.Property("Level") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Courses"); + }); + + modelBuilder.Entity("Itenium.SkillForge.Entities.TeamEntity", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Name") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ClientId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ClientSecret") + .HasColumnType("text"); + + b.Property("ClientType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("ConsentType") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("JsonWebKeySet") + .HasColumnType("text"); + + b.Property("Permissions") + .HasColumnType("text"); + + b.Property("PostLogoutRedirectUris") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedirectUris") + .HasColumnType("text"); + + b.Property("Requirements") + .HasColumnType("text"); + + b.Property("Settings") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ClientId") + .IsUnique(); + + b.ToTable("OpenIddictApplications", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Scopes") + .HasColumnType("text"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictAuthorizations", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreScope", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Descriptions") + .HasColumnType("text"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("DisplayNames") + .HasColumnType("text"); + + b.Property("Name") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("Resources") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("OpenIddictScopes", (string)null); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("text"); + + b.Property("ApplicationId") + .HasColumnType("text"); + + b.Property("AuthorizationId") + .HasColumnType("text"); + + b.Property("ConcurrencyToken") + .IsConcurrencyToken() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("CreationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationDate") + .HasColumnType("timestamp with time zone"); + + b.Property("Payload") + .HasColumnType("text"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RedemptionDate") + .HasColumnType("timestamp with time zone"); + + b.Property("ReferenceId") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Subject") + .HasMaxLength(400) + .HasColumnType("character varying(400)"); + + b.Property("Type") + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.HasKey("Id"); + + b.HasIndex("AuthorizationId"); + + b.HasIndex("ReferenceId") + .IsUnique(); + + b.HasIndex("ApplicationId", "Status", "Subject", "Type"); + + b.ToTable("OpenIddictTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Itenium.Forge.Security.OpenIddict.ForgeUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Authorizations") + .HasForeignKey("ApplicationId"); + + b.Navigation("Application"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreToken", b => + { + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", "Application") + .WithMany("Tokens") + .HasForeignKey("ApplicationId"); + + b.HasOne("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", "Authorization") + .WithMany("Tokens") + .HasForeignKey("AuthorizationId"); + + b.Navigation("Application"); + + b.Navigation("Authorization"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b => + { + b.Navigation("Authorizations"); + + b.Navigation("Tokens"); + }); + + modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreAuthorization", b => + { + b.Navigation("Tokens"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Data/SeedData.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/SeedData.cs new file mode 100644 index 0000000..2d7c6c2 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Data/SeedData.cs @@ -0,0 +1,148 @@ +using System.Security.Claims; +using Itenium.Forge.Security.OpenIddict; +using Itenium.SkillForge.Entities; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace Itenium.SkillForge.Data; + +public static class SeedData +{ + public static async Task SeedDevelopmentData(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var db = scope.ServiceProvider.GetRequiredService(); + + await SeedTeams(db); + await SeedCourses(db); + await app.SeedTestUsers(); + } + + private static async Task SeedTeams(AppDbContext db) + { + if (!await db.Teams.AnyAsync()) + { + db.Teams.AddRange( + new TeamEntity { Id = 1, Name = "Java" }, + new TeamEntity { Id = 2, Name = ".NET" }, + new TeamEntity { Id = 3, Name = "PO & Analysis" }, + new TeamEntity { Id = 4, Name = "QA" }); + await db.SaveChangesAsync(); + } + } + + private static async Task SeedCourses(AppDbContext db) + { + if (!await db.Courses.AnyAsync()) + { + db.Courses.AddRange( + new CourseEntity { Id = 1, Name = "Introduction to Programming", Description = "Learn the basics of programming", Category = "Development", Level = "Beginner" }, + new CourseEntity { Id = 2, Name = "Advanced C#", Description = "Master C# programming language", Category = "Development", Level = "Advanced" }, + new CourseEntity { Id = 3, Name = "Cloud Architecture", Description = "Design scalable cloud solutions", Category = "Architecture", Level = "Intermediate" }, + new CourseEntity { Id = 4, Name = "Agile Project Management", Description = "Learn agile methodologies", Category = "Management", Level = "Beginner" }); + await db.SaveChangesAsync(); + } + } + + private static async Task SeedTestUsers(this WebApplication app) + { + using var scope = app.Services.CreateScope(); + var userManager = scope.ServiceProvider.GetRequiredService>(); + + // BackOffice admin - no team claim (manages all) + if (await userManager.FindByEmailAsync("backoffice@test.local") == null) + { + var admin = new ForgeUser + { + UserName = "backoffice", + Email = "backoffice@test.local", + EmailConfirmed = true, + FirstName = "BackOffice", + LastName = "Admin" + }; + var result = await userManager.CreateAsync(admin, "AdminPassword123!"); + if (result.Succeeded) + { + await userManager.AddToRolesAsync(admin, ["backoffice"]); + } + } + + // Local user for Java team only + if (await userManager.FindByEmailAsync("java@test.local") == null) + { + var user = new ForgeUser + { + UserName = "java", + Email = "java@test.local", + EmailConfirmed = true, + FirstName = "Java", + LastName = "Developer" + }; + var result = await userManager.CreateAsync(user, "UserPassword123!"); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, "manager"); + await userManager.AddClaimAsync(user, new Claim("team", "1")); // Java + } + } + + // Local user for .NET team only + if (await userManager.FindByEmailAsync("dotnet@test.local") == null) + { + var user = new ForgeUser + { + UserName = "dotnet", + Email = "dotnet@test.local", + EmailConfirmed = true, + FirstName = "DotNet", + LastName = "Developer" + }; + var result = await userManager.CreateAsync(user, "UserPassword123!"); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, "manager"); + await userManager.AddClaimAsync(user, new Claim("team", "2")); // .NET + } + } + + // User with access to multiple teams (Java + .NET) + if (await userManager.FindByEmailAsync("multi@test.local") == null) + { + var user = new ForgeUser + { + UserName = "multi", + Email = "multi@test.local", + EmailConfirmed = true, + FirstName = "Multi", + LastName = "Team" + }; + var result = await userManager.CreateAsync(user, "UserPassword123!"); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, "manager"); + await userManager.AddClaimAsync(user, new Claim("team", "1")); // Java + await userManager.AddClaimAsync(user, new Claim("team", "2")); // .NET + } + } + + // Learner user - basic learner role + if (await userManager.FindByEmailAsync("learner@test.local") == null) + { + var user = new ForgeUser + { + UserName = "learner", + Email = "learner@test.local", + EmailConfirmed = true, + FirstName = "Test", + LastName = "Learner" + }; + var result = await userManager.CreateAsync(user, "UserPassword123!"); + if (result.Succeeded) + { + await userManager.AddToRoleAsync(user, "learner"); + } + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/CourseEntity.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/CourseEntity.cs new file mode 100644 index 0000000..4ee85e6 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/CourseEntity.cs @@ -0,0 +1,29 @@ +using System.ComponentModel.DataAnnotations; + +namespace Itenium.SkillForge.Entities; + +/// +/// Course master data managed by central management. +/// +public class CourseEntity +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(200)] + public required string Name { get; set; } + + [MaxLength(2000)] + public string? Description { get; set; } + + [MaxLength(100)] + public string? Category { get; set; } + + [MaxLength(50)] + public string? Level { get; set; } + + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + + public override string ToString() => $"{Name} ({Category})"; +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/Itenium.SkillForge.Entities.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/Itenium.SkillForge.Entities.csproj new file mode 100644 index 0000000..814c865 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/Itenium.SkillForge.Entities.csproj @@ -0,0 +1,7 @@ + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/TeamEntity.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/TeamEntity.cs new file mode 100644 index 0000000..33ffb00 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Entities/TeamEntity.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Itenium.SkillForge.Entities; + +/// +/// A team is a CompetenceCenter like Java or PO-Analysis. +/// +public class TeamEntity +{ + [Key] + public int Id { get; set; } + + [Required] + [MaxLength(200)] + public required string Name { get; set; } + + public override string ToString() => Name; +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/Itenium.SkillForge.Services.Tests.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/Itenium.SkillForge.Services.Tests.csproj new file mode 100644 index 0000000..d93126e --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/Itenium.SkillForge.Services.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/SkillForgeUserTests.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/SkillForgeUserTests.cs new file mode 100644 index 0000000..b61f24c --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services.Tests/SkillForgeUserTests.cs @@ -0,0 +1,101 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using NSubstitute; + +namespace Itenium.SkillForge.Services.Tests; + +[TestFixture] +public class SkillForgeUserTests +{ + private static readonly int[] ExpectedTeamIds = [1, 3, 5]; + private IHttpContextAccessor _httpContextAccessor = null!; + + [SetUp] + public void Setup() + { + _httpContextAccessor = Substitute.For(); + } + + [Test] + public void IsBackOffice_WhenUserHasBackofficeRole_ReturnsTrue() + { + var claims = new List { new(ClaimTypes.Role, "backoffice") }; + SetupUser(claims); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.IsBackOffice; + + Assert.That(result, Is.True); + } + + [Test] + public void IsBackOffice_WhenUserDoesNotHaveBackofficeRole_ReturnsFalse() + { + var claims = new List { new(ClaimTypes.Role, "learner") }; + SetupUser(claims); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.IsBackOffice; + + Assert.That(result, Is.False); + } + + [Test] + public void IsBackOffice_WhenNoUser_ReturnsFalse() + { + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.IsBackOffice; + + Assert.That(result, Is.False); + } + + [Test] + public void Teams_WhenUserHasTeamClaims_ReturnsTeamIds() + { + var claims = new List + { + new("team", "1"), + new("team", "3"), + new("team", "5") + }; + SetupUser(claims); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.Teams; + + Assert.That(result, Is.EquivalentTo(ExpectedTeamIds)); + } + + [Test] + public void Teams_WhenUserHasNoTeamClaims_ReturnsEmpty() + { + var claims = new List { new(ClaimTypes.Role, "learner") }; + SetupUser(claims); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.Teams; + + Assert.That(result, Is.Empty); + } + + [Test] + public void Teams_WhenNoUser_ReturnsEmpty() + { + _httpContextAccessor.HttpContext.Returns((HttpContext?)null); + var sut = new SkillForgeUser(_httpContextAccessor); + + var result = sut.Teams; + + Assert.That(result, Is.Empty); + } + + private void SetupUser(List claims) + { + var identity = new ClaimsIdentity(claims, "TestAuth"); + var principal = new ClaimsPrincipal(identity); + var httpContext = new DefaultHttpContext { User = principal }; + _httpContextAccessor.HttpContext.Returns(httpContext); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Capability.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Capability.cs new file mode 100644 index 0000000..43d905d --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Capability.cs @@ -0,0 +1,11 @@ +namespace Itenium.SkillForge.Services; + +/// +/// Fine-grained capabilities for the SkillForge application. +/// Configure role-capability mappings in appsettings.json. +/// +public enum Capability +{ + ReadCourse, + ManageCourse, +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services/ISkillForgeUser.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/ISkillForgeUser.cs new file mode 100644 index 0000000..48e2274 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/ISkillForgeUser.cs @@ -0,0 +1,19 @@ +using Itenium.Forge.Security; + +namespace Itenium.SkillForge.Services; + +/// +/// Provides access to the current user. +/// +public interface ISkillForgeUser : ICurrentUser +{ + /// + /// Gets a value indicating whether the current user is BackOffice management. + /// + bool IsBackOffice { get; } + + /// + /// Gets the IDs of the Teams the user has access to. + /// + ICollection Teams { get; } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Itenium.SkillForge.Services.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Itenium.SkillForge.Services.csproj new file mode 100644 index 0000000..3176d01 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/Itenium.SkillForge.Services.csproj @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.Services/SkillForgeUser.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/SkillForgeUser.cs new file mode 100644 index 0000000..3fbcd51 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.Services/SkillForgeUser.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using Itenium.Forge.Security; +using Microsoft.AspNetCore.Http; + +namespace Itenium.SkillForge.Services; + +public class SkillForgeUser : CurrentUser, ISkillForgeUser +{ + public SkillForgeUser(IHttpContextAccessor httpContextAccessor) + : base(httpContextAccessor) + { + } + + public bool IsBackOffice => User?.IsInRole("backoffice") ?? false; + + public ICollection Teams + { + get + { + if (User == null) + { + return []; + } + + var teams = User.FindAll("team").Select(c => int.Parse(c.Value, CultureInfo.InvariantCulture)).ToArray(); + return teams; + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/CourseControllerTests.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/CourseControllerTests.cs new file mode 100644 index 0000000..647f134 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/CourseControllerTests.cs @@ -0,0 +1,135 @@ +using Itenium.SkillForge.Entities; +using Itenium.SkillForge.WebApi.Controllers; +using Microsoft.AspNetCore.Mvc; + +namespace Itenium.SkillForge.WebApi.Tests; + +[TestFixture] +public class CourseControllerTests : DatabaseTestBase +{ + private CourseController _sut = null!; + + [SetUp] + public void Setup() + { + _sut = new CourseController(Db); + } + + [Test] + public async Task GetCourses_ReturnsAllCourses() + { + Db.Courses.AddRange( + new CourseEntity { Name = "C# Basics" }, + new CourseEntity { Name = "Advanced .NET" }); + await Db.SaveChangesAsync(); + + var result = await _sut.GetCourses(); + + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var courses = okResult!.Value as List; + Assert.That(courses, Has.Count.EqualTo(2)); + } + + [Test] + public async Task GetCourses_WhenNoCourses_ReturnsEmptyList() + { + var result = await _sut.GetCourses(); + + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var courses = okResult!.Value as List; + Assert.That(courses, Is.Empty); + } + + [Test] + public async Task GetCourse_WhenExists_ReturnsCourse() + { + var course = new CourseEntity { Name = "C# Basics", Description = "Learn C#" }; + Db.Courses.Add(course); + await Db.SaveChangesAsync(); + + var result = await _sut.GetCourse(course.Id); + + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var returnedCourse = okResult!.Value as CourseEntity; + Assert.That(returnedCourse!.Name, Is.EqualTo("C# Basics")); + Assert.That(returnedCourse.Description, Is.EqualTo("Learn C#")); + } + + [Test] + public async Task GetCourse_WhenNotExists_ReturnsNotFound() + { + var result = await _sut.GetCourse(999); + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task CreateCourse_AddsCourseAndReturnsCreated() + { + var request = new CreateCourseRequest("New Course", "Description", "Programming", "Beginner"); + + var result = await _sut.CreateCourse(request); + + var createdResult = result.Result as CreatedAtActionResult; + Assert.That(createdResult, Is.Not.Null); + var course = createdResult!.Value as CourseEntity; + Assert.That(course!.Name, Is.EqualTo("New Course")); + Assert.That(course.Description, Is.EqualTo("Description")); + Assert.That(course.Category, Is.EqualTo("Programming")); + Assert.That(course.Level, Is.EqualTo("Beginner")); + + var savedCourse = await Db.Courses.FindAsync(course.Id); + Assert.That(savedCourse, Is.Not.Null); + Assert.That(savedCourse!.Name, Is.EqualTo("New Course")); + } + + [Test] + public async Task UpdateCourse_WhenExists_UpdatesAndReturnsOk() + { + var course = new CourseEntity { Name = "Old Name", Description = "Old Desc" }; + Db.Courses.Add(course); + await Db.SaveChangesAsync(); + var request = new UpdateCourseRequest("New Name", "New Desc", "New Category", "Advanced"); + + var result = await _sut.UpdateCourse(course.Id, request); + + var okResult = result.Result as OkObjectResult; + Assert.That(okResult, Is.Not.Null); + var updatedCourse = okResult!.Value as CourseEntity; + Assert.That(updatedCourse!.Name, Is.EqualTo("New Name")); + Assert.That(updatedCourse.Description, Is.EqualTo("New Desc")); + Assert.That(updatedCourse.Category, Is.EqualTo("New Category")); + Assert.That(updatedCourse.Level, Is.EqualTo("Advanced")); + } + + [Test] + public async Task UpdateCourse_WhenNotExists_ReturnsNotFound() + { + var request = new UpdateCourseRequest("Name", "Desc", null, null); + var result = await _sut.UpdateCourse(999, request); + Assert.That(result.Result, Is.TypeOf()); + } + + [Test] + public async Task DeleteCourse_WhenExists_RemovesAndReturnsNoContent() + { + var course = new CourseEntity { Name = "To Delete" }; + Db.Courses.Add(course); + await Db.SaveChangesAsync(); + + var result = await _sut.DeleteCourse(course.Id); + + Assert.That(result, Is.TypeOf()); + var deletedCourse = await Db.Courses.FindAsync(course.Id); + Assert.That(deletedCourse, Is.Null); + } + + [Test] + public async Task DeleteCourse_WhenNotExists_ReturnsNotFound() + { + var result = await _sut.DeleteCourse(999); + Assert.That(result, Is.TypeOf()); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/DatabaseTestBase.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/DatabaseTestBase.cs new file mode 100644 index 0000000..6702cf4 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/DatabaseTestBase.cs @@ -0,0 +1,26 @@ +using Itenium.SkillForge.Data; +using Microsoft.EntityFrameworkCore.Storage; + +namespace Itenium.SkillForge.WebApi.Tests; + +public abstract class DatabaseTestBase +{ + private IDbContextTransaction _transaction = null!; + + protected AppDbContext Db { get; private set; } = null!; + + [SetUp] + public async Task BaseSetUp() + { + Db = new AppDbContext(PostgresFixture.CreateDbContextOptions()); + _transaction = await Db.Database.BeginTransactionAsync(); + } + + [TearDown] + public async Task BaseTearDown() + { + await _transaction.RollbackAsync(); + await _transaction.DisposeAsync(); + await Db.DisposeAsync(); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/Itenium.SkillForge.WebApi.Tests.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/Itenium.SkillForge.WebApi.Tests.csproj new file mode 100644 index 0000000..b33af72 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/Itenium.SkillForge.WebApi.Tests.csproj @@ -0,0 +1,31 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/PostgresFixture.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/PostgresFixture.cs new file mode 100644 index 0000000..6c54ff6 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/PostgresFixture.cs @@ -0,0 +1,43 @@ +using System.Reflection; +using Itenium.SkillForge.Data; +using Microsoft.EntityFrameworkCore; +using Testcontainers.PostgreSql; + +namespace Itenium.SkillForge.WebApi.Tests; + +[SetUpFixture] +public class PostgresFixture +{ + private static readonly Assembly MigrationAssembly = typeof(AppDbContext).Assembly; + private static PostgreSqlContainer _container = null!; + + public static string ConnectionString { get; private set; } = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUp() + { + _container = new PostgreSqlBuilder() + .WithImage("postgres:17") + .Build(); + + await _container.StartAsync(); + ConnectionString = _container.GetConnectionString(); + + var options = CreateDbContextOptions(); + await using var db = new AppDbContext(options); + await db.Database.MigrateAsync(); + } + + [OneTimeTearDown] + public async Task OneTimeTearDown() + { + await _container.DisposeAsync(); + } + + internal static DbContextOptions CreateDbContextOptions() + { + return new DbContextOptionsBuilder() + .UseNpgsql(ConnectionString, x => x.MigrationsAssembly(MigrationAssembly)) + .Options; + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/TeamControllerTests.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/TeamControllerTests.cs new file mode 100644 index 0000000..1c7cac9 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi.Tests/TeamControllerTests.cs @@ -0,0 +1,91 @@ +using Itenium.SkillForge.Entities; +using Itenium.SkillForge.Services; +using Itenium.SkillForge.WebApi.Controllers; +using NSubstitute; + +namespace Itenium.SkillForge.WebApi.Tests; + +[TestFixture] +public class TeamControllerTests : DatabaseTestBase +{ + private ISkillForgeUser _user = null!; + private TeamController _sut = null!; + + [SetUp] + public void Setup() + { + _user = Substitute.For(); + _sut = new TeamController(Db, _user); + } + + [Test] + public async Task GetUserTeams_WhenBackOffice_ReturnsAllTeams() + { + Db.Teams.AddRange( + new TeamEntity { Name = "Java" }, + new TeamEntity { Name = ".NET" }, + new TeamEntity { Name = "QA" }); + await Db.SaveChangesAsync(); + _user.IsBackOffice.Returns(true); + + var result = await _sut.GetUserTeams(); + + var teams = result.Value!; + Assert.That(teams, Has.Count.EqualTo(3)); + Assert.That(teams.Select(t => t.Name), Contains.Item("Java")); + Assert.That(teams.Select(t => t.Name), Contains.Item(".NET")); + Assert.That(teams.Select(t => t.Name), Contains.Item("QA")); + } + + [Test] + public async Task GetUserTeams_WhenNotBackOffice_ReturnsOnlyUserTeams() + { + var javaTeam = new TeamEntity { Name = "Java" }; + var dotnetTeam = new TeamEntity { Name = ".NET" }; + var qaTeam = new TeamEntity { Name = "QA" }; + Db.Teams.AddRange(javaTeam, dotnetTeam, qaTeam); + await Db.SaveChangesAsync(); + _user.IsBackOffice.Returns(false); + _user.Teams.Returns(new[] { javaTeam.Id, qaTeam.Id }); + + var result = await _sut.GetUserTeams(); + + var teams = result.Value!; + Assert.That(teams, Has.Count.EqualTo(2)); + Assert.That(teams.Select(t => t.Name), Contains.Item("Java")); + Assert.That(teams.Select(t => t.Name), Contains.Item("QA")); + Assert.That(teams.Select(t => t.Name), Does.Not.Contain(".NET")); + } + + [Test] + public async Task GetUserTeams_WhenNotBackOfficeAndNoTeams_ReturnsEmpty() + { + Db.Teams.AddRange( + new TeamEntity { Name = "Java" }, + new TeamEntity { Name = ".NET" }); + await Db.SaveChangesAsync(); + _user.IsBackOffice.Returns(false); + _user.Teams.Returns(Array.Empty()); + + var result = await _sut.GetUserTeams(); + + var teams = result.Value!; + Assert.That(teams, Is.Empty); + } + + [Test] + public async Task GetUserTeams_WhenUserHasNonExistentTeamId_IgnoresIt() + { + var javaTeam = new TeamEntity { Name = "Java" }; + Db.Teams.Add(javaTeam); + await Db.SaveChangesAsync(); + _user.IsBackOffice.Returns(false); + _user.Teams.Returns(new[] { javaTeam.Id, 999 }); + + var result = await _sut.GetUserTeams(); + + var teams = result.Value!; + Assert.That(teams, Has.Count.EqualTo(1)); + Assert.That(teams.Select(t => t.Name), Contains.Item("Java")); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CourseController.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CourseController.cs new file mode 100644 index 0000000..757bf4f --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CourseController.cs @@ -0,0 +1,105 @@ +using Itenium.SkillForge.Data; +using Itenium.SkillForge.Entities; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Itenium.SkillForge.WebApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class CourseController : ControllerBase +{ + private readonly AppDbContext _db; + + public CourseController(AppDbContext db) + { + _db = db; + } + + /// + /// Get all courses. + /// + [HttpGet] + public async Task>> GetCourses() + { + var courses = await _db.Courses.ToListAsync(); + return Ok(courses); + } + + /// + /// Get a course by ID. + /// + [HttpGet("{id:int}")] + public async Task> GetCourse(int id) + { + var course = await _db.Courses.FindAsync(id); + if (course == null) + { + return NotFound(); + } + + return Ok(course); + } + + /// + /// Create a new course. + /// + [HttpPost] + public async Task> CreateCourse([FromBody] CreateCourseRequest request) + { + var course = new CourseEntity + { + Name = request.Name, + Description = request.Description, + Category = request.Category, + Level = request.Level + }; + + _db.Courses.Add(course); + await _db.SaveChangesAsync(); + + return CreatedAtAction(nameof(GetCourse), new { id = course.Id }, course); + } + + /// + /// Update an existing course. + /// + [HttpPut("{id:int}")] + public async Task> UpdateCourse(int id, [FromBody] UpdateCourseRequest request) + { + var course = await _db.Courses.FindAsync(id); + if (course == null) + { + return NotFound(); + } + + course.Name = request.Name; + course.Description = request.Description; + course.Category = request.Category; + course.Level = request.Level; + + await _db.SaveChangesAsync(); + + return Ok(course); + } + + /// + /// Delete a course. + /// + [HttpDelete("{id:int}")] + public async Task DeleteCourse(int id) + { + var course = await _db.Courses.FindAsync(id); + if (course == null) + { + return NotFound(); + } + + _db.Courses.Remove(course); + await _db.SaveChangesAsync(); + + return NoContent(); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CreateCourseRequest.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CreateCourseRequest.cs new file mode 100644 index 0000000..462748e --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/CreateCourseRequest.cs @@ -0,0 +1,3 @@ +namespace Itenium.SkillForge.WebApi.Controllers; + +public record CreateCourseRequest(string Name, string? Description, string? Category, string? Level); diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/TeamController.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/TeamController.cs new file mode 100644 index 0000000..44312f9 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/TeamController.cs @@ -0,0 +1,39 @@ +using Itenium.SkillForge.Data; +using Itenium.SkillForge.Entities; +using Itenium.SkillForge.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Itenium.SkillForge.WebApi.Controllers; + +[ApiController] +[Route("api/[controller]")] +[Authorize] +public class TeamController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly ISkillForgeUser _user; + + public TeamController(AppDbContext db, ISkillForgeUser user) + { + _db = db; + _user = user; + } + + /// + /// Get the teams the current user has access to. + /// + [HttpGet] + public async Task>> GetUserTeams() + { + if (_user.IsBackOffice) + { + return await _db.Teams.ToListAsync(); + } + + return await _db.Teams + .Where(t => _user.Teams.Contains(t.Id)) + .ToListAsync(); + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/UpdateCourseRequest.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/UpdateCourseRequest.cs new file mode 100644 index 0000000..7abcb0a --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Controllers/UpdateCourseRequest.cs @@ -0,0 +1,3 @@ +namespace Itenium.SkillForge.WebApi.Controllers; + +public record UpdateCourseRequest(string Name, string? Description, string? Category, string? Level); diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj new file mode 100644 index 0000000..4f64e4d --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Itenium.SkillForge.WebApi.csproj @@ -0,0 +1,31 @@ + + + + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Program.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Program.cs new file mode 100644 index 0000000..111196d --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Program.cs @@ -0,0 +1,68 @@ +using Itenium.Forge.Controllers; +using Itenium.Forge.HealthChecks; +using Itenium.Forge.Logging; +using Itenium.Forge.Security; +using Itenium.Forge.Security.OpenIddict; +using Itenium.Forge.Settings; +using Itenium.Forge.Swagger; +using Itenium.SkillForge.Data; +using Itenium.SkillForge.Services; +using Itenium.SkillForge.WebApi; +using Microsoft.EntityFrameworkCore; +using Serilog; + +Log.Logger = LoggingExtensions.CreateBootstrapLogger(); + +try +{ + var builder = WebApplication.CreateBuilder(args); + var settings = builder.AddForgeSettings(); + builder.AddForgeLogging(); + + var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); + builder.AddForgeOpenIddict(options => options.UseNpgsql(connectionString)); + + builder.Services.AddScoped(); + + builder.AddForgeControllers(); + builder.AddForgeSwagger(); + builder.AddForgeHealthChecks(); + + WebApplication app = builder.Build(); + + // Apply migrations and seed data + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + await db.Database.MigrateAsync(); + } + + await app.SeedOpenIddictDataAsync(); + await app.SeedDevelopmentData(); + + app.UseForgeLogging(); + app.UseForgeSecurity(); + + app.UseForgeControllers(); + if (app.Environment.IsDevelopment()) + { + app.UseForgeSwagger(); + } + + app.UseForgeHealthChecks(); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + await Log.CloseAndFlushAsync(); +} + +// Make Program class accessible for WebApplicationFactory in tests +public partial class Program +{ +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Properties/launchSettings.json b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Properties/launchSettings.json new file mode 100644 index 0000000..900b31c --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "Itenium.SkillForge.WebApi": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:5000" + } + } +} \ No newline at end of file diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/SkillForgeSettings.cs b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/SkillForgeSettings.cs new file mode 100644 index 0000000..b59626f --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/SkillForgeSettings.cs @@ -0,0 +1,9 @@ +using Itenium.Forge.Core; +using Itenium.Forge.Settings; + +namespace Itenium.SkillForge.WebApi; + +public class SkillForgeSettings : IForgeSettings +{ + public ForgeSettings Forge { get; set; } = new(); +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Development.json b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Docker.json b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Docker.json new file mode 100644 index 0000000..5494da9 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.Docker.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "Host=postgres;Port=5432;Database=skillforge;Username=skillforge;Password=skillforge" + } +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.json b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.json new file mode 100644 index 0000000..a092905 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.WebApi/appsettings.json @@ -0,0 +1,35 @@ +{ + "$schema": "https://itenium-forge.github.io/appsetting-schemas/AppSettingsSchemaV1.json", + "ConnectionStrings": { + "DefaultConnection": "Host=localhost;Port=5432;Database=skillforge;Username=skillforge;Password=skillforge" + }, + "Hosting": { + "CorsOrigins": "http://localhost:5173,http://localhost:3000" + }, + "Forge": { + "ServiceName": "SkillForge.WebApi", + "TeamName": "Core", + "Tenant": "itenium", + "Environment": "", + "Application": "SkillForge" + }, + "ForgeConfiguration": { + "Logging": { + "FilePath": "./logs" + }, + "Security": { + "ClientId": "skillforge-spa", + "ClientDisplayName": "SkillForge SPA", + "RedirectUris": "http://localhost:3000/callback,http://localhost:5173/callback", + "AccessTokenLifetimeMinutes": 60, + "RefreshTokenLifetimeDays": 14, + "DisableAccessTokenEncryption": true, + "RoleCapabilities": { + "backoffice": [ "ReadCourse", "ManageCourse" ], + "manager": [ "ReadCourse", "ManageCourse" ], + "learner": [ "ReadCourse" ] + } + } + }, + "AllowedHosts": "*" +} diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.sln.DotSettings b/Itenium.SkillForge/backend/Itenium.SkillForge.sln.DotSettings new file mode 100644 index 0000000..5d3ba64 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.sln.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/Itenium.SkillForge/backend/Itenium.SkillForge.slnx b/Itenium.SkillForge/backend/Itenium.SkillForge.slnx new file mode 100644 index 0000000..8818845 --- /dev/null +++ b/Itenium.SkillForge/backend/Itenium.SkillForge.slnx @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/backend/nuget.config b/Itenium.SkillForge/backend/nuget.config new file mode 100644 index 0000000..685086b --- /dev/null +++ b/Itenium.SkillForge/backend/nuget.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/Itenium.SkillForge/docker-compose.yml b/Itenium.SkillForge/docker-compose.yml new file mode 100644 index 0000000..a5fafbb --- /dev/null +++ b/Itenium.SkillForge/docker-compose.yml @@ -0,0 +1,16 @@ +services: + postgres: + image: postgres:17 + container_name: postgres + environment: + POSTGRES_USER: skillforge + POSTGRES_PASSWORD: skillforge + POSTGRES_DB: skillforge + ports: + - "5433:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + - ./scripts/setup-db.sh:/docker-entrypoint-initdb.d/init-user-db.sh + +volumes: + postgres_data: diff --git a/Itenium.SkillForge/frontend/.husky/pre-commit b/Itenium.SkillForge/frontend/.husky/pre-commit new file mode 100644 index 0000000..e02e92b --- /dev/null +++ b/Itenium.SkillForge/frontend/.husky/pre-commit @@ -0,0 +1,7 @@ +cd Itenium.SkillForge/frontend && npx lint-staged + +# Backend: check formatting on staged .cs files +if git diff --cached --name-only | grep -q '\.cs$'; then + cd "$OLDPWD" + cd Itenium.SkillForge/backend && dotnet format --verify-no-changes --no-restore +fi diff --git a/Itenium.SkillForge/frontend/.npmrc b/Itenium.SkillForge/frontend/.npmrc new file mode 100644 index 0000000..c092900 --- /dev/null +++ b/Itenium.SkillForge/frontend/.npmrc @@ -0,0 +1 @@ +@itenium-forge:registry=https://npm.pkg.github.com diff --git a/Itenium.SkillForge/frontend/.prettierignore b/Itenium.SkillForge/frontend/.prettierignore new file mode 100644 index 0000000..d6cfa15 --- /dev/null +++ b/Itenium.SkillForge/frontend/.prettierignore @@ -0,0 +1,3 @@ +dist +node_modules +routeTree.gen.ts diff --git a/Itenium.SkillForge/frontend/.prettierrc.json b/Itenium.SkillForge/frontend/.prettierrc.json new file mode 100644 index 0000000..6d99a58 --- /dev/null +++ b/Itenium.SkillForge/frontend/.prettierrc.json @@ -0,0 +1,6 @@ +{ + "singleQuote": true, + "trailingComma": "all", + "printWidth": 120, + "tabWidth": 2 +} diff --git a/Itenium.SkillForge/frontend/bun.lock b/Itenium.SkillForge/frontend/bun.lock new file mode 100644 index 0000000..d43406c --- /dev/null +++ b/Itenium.SkillForge/frontend/bun.lock @@ -0,0 +1,1683 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "@itenium/skillforge-frontend", + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@itenium-forge/ui": "0.0.3", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.16", + "@tanstack/react-router": "^1.144.0", + "@tanstack/react-router-with-query": "^1.130.17", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-plugin": "^1.145.2", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "i18next": "^25.7.3", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.562.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.70.0", + "react-i18next": "^16.5.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.4", + "zustand": "^5.0.9", + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.57.0", + "@tailwindcss/vite": "^4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^25.0.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "cross-env": "^10.1.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.0.0", + "husky": "^9.1.7", + "jsdom": "^28.0.0", + "lint-staged": "^16.2.7", + "prettier": "^3.8.1", + "tailwindcss": "^4.1.18", + "testcontainers": "^11.11.0", + "typescript": "~5.8.2", + "typescript-eslint": "^8.52.0", + "vite": "^6.0.0", + "vitest": "^4.0.18", + }, + }, + }, + "packages": { + "@acemir/cssom": ["@acemir/cssom@0.9.31", "", {}, "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA=="], + + "@adobe/css-tools": ["@adobe/css-tools@4.4.4", "", {}, "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@4.1.2", "", { "dependencies": { "@csstools/css-calc": "^3.0.0", "@csstools/css-color-parser": "^4.0.1", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.5" } }, "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@6.7.8", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.1.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.5" } }, "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ=="], + + "@asamuzakjp/nwsapi": ["@asamuzakjp/nwsapi@2.3.9", "", {}, "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q=="], + + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], + + "@babel/compat-data": ["@babel/compat-data@7.28.5", "", {}, "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA=="], + + "@babel/core": ["@babel/core@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.28.3", "@babel/helpers": "^7.28.4", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw=="], + + "@babel/generator": ["@babel/generator@7.28.5", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ=="], + + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.27.2", "", { "dependencies": { "@babel/compat-data": "^7.27.2", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ=="], + + "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], + + "@babel/helper-module-imports": ["@babel/helper-module-imports@7.27.1", "", { "dependencies": { "@babel/traverse": "^7.27.1", "@babel/types": "^7.27.1" } }, "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w=="], + + "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.3", "", { "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", "@babel/traverse": "^7.28.3" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw=="], + + "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.27.1", "", {}, "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw=="], + + "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], + + "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + + "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], + + "@babel/helpers": ["@babel/helpers@7.28.4", "", { "dependencies": { "@babel/template": "^7.27.2", "@babel/types": "^7.28.4" } }, "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w=="], + + "@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="], + + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w=="], + + "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-xfYCBMxveHrRMnAWl1ZlPXOZjzkN82THFvLhQhFXFt81Z5HnN+EtUkZhv/zcKpmT3fzmWZB0ywiBrbC3vogbwQ=="], + + "@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="], + + "@babel/template": ["@babel/template@7.27.2", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/parser": "^7.27.2", "@babel/types": "^7.27.1" } }, "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw=="], + + "@babel/traverse": ["@babel/traverse@7.28.5", "", { "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "debug": "^4.3.1" } }, "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ=="], + + "@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], + + "@balena/dockerignore": ["@balena/dockerignore@1.0.2", "", {}, "sha512-wMue2Sy4GAVTk6Ic4tJVcnfdau+gx2EnG7S+uAEe+TWJFqE4YoWN4/H8MSLj4eYJKxGg26lZwboEniNiNwZQ6Q=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.0.1", "", {}, "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.0.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.0.1", "", { "dependencies": { "@csstools/color-helpers": "^6.0.1", "@csstools/css-calc": "^3.0.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.0.26", "", {}, "sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + + "@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" } }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@epic-web/invariant": ["@epic-web/invariant@1.0.0", "", {}, "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="], + + "@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="], + + "@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="], + + "@exodus/bytes": ["@exodus/bytes@1.11.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-wO3vd8nsEHdumsXrjGO/v4p6irbg7hy9kvIeR6i2AwylZSk4HJdWgL0FNaVquW1+AweJcdvU1IEpuIWk/WaPnA=="], + + "@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="], + + "@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="], + + "@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="], + + "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + + "@grpc/grpc-js": ["@grpc/grpc-js@1.14.3", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA=="], + + "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], + + "@hookform/resolvers": ["@hookform/resolvers@5.2.2", "", { "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { "react-hook-form": "^7.55.0" } }, "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@itenium-forge/ui": ["@itenium-forge/ui@0.0.3", "https://npm.pkg.github.com/download/@itenium-forge/ui/0.0.3/bef3887725f6fe41c114c576272d5b555a35d9ce", { "dependencies": { "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.2", "@radix-ui/react-dialog": "^1.1.14", "@radix-ui/react-dropdown-menu": "^2.1.15", "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-radio-group": "^1.3.7", "@radix-ui/react-scroll-area": "^1.2.9", "@radix-ui/react-select": "^2.2.5", "@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-switch": "^1.2.5", "@radix-ui/react-tooltip": "^1.2.7", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.511.0", "react-hook-form": "^7.54.0", "tailwind-merge": "^3.3.1" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-BkGWX0AVhoKwoVyvZMa3kt+se3YW8q+eqH1LNhHKkMUWrjL/M8XWMTNpZOnanRXR8dFF9AjO+NJjHnCB0db9vQ=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@js-sdsl/ordered-map": ["@js-sdsl/ordered-map@4.4.2", "", {}, "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw=="], + + "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@0.2.12", "", { "dependencies": { "@emnapi/core": "^1.4.3", "@emnapi/runtime": "^1.4.3", "@tybys/wasm-util": "^0.10.0" } }, "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + + "@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="], + + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="], + + "@radix-ui/react-avatar": ["@radix-ui/react-avatar@1.1.11", "", { "dependencies": { "@radix-ui/react-context": "1.1.3", "@radix-ui/react-primitive": "2.1.4", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-is-hydrated": "0.1.0", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0Qk603AHGV28BOBO34p7IgD5m+V5Sg/YovfayABkoDDBM5d3NCx0Mp4gGrjzLGes1jV5eNOE1r3itqOR33VC6Q=="], + + "@radix-ui/react-checkbox": ["@radix-ui/react-checkbox@1.3.3", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw=="], + + "@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ieIFACdMpYfMEjF0rEf5KLvfVyIkOz6PDGyNnP+u+4xQ6jny3VCgA4OgXOwNx2aUkxn8zx9fiVcM8CfFYv9Lxw=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-direction": ["@radix-ui/react-direction@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-dropdown-menu": ["@radix-ui/react-dropdown-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-menu": "2.1.16", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-1PLGQEynI/3OX/ftV54COn+3Sud/Mn8vALg2rWnBLnRaGtJDduNW/22XjlGgPdpcIbiQxjKtb7BkcjP00nqfJw=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-label": ["@radix-ui/react-label@2.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-FmXs37I6hSBVDlO4y764TNz1rLgKwjJMQ0EGte6F3Cb3f4bIuHB/iLa/8I9VKkmOy+gNHq8rql3j686ACVV21A=="], + + "@radix-ui/react-menu": ["@radix-ui/react-menu@2.1.16", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-72F2T+PLlphrqLcAotYPp0uJMr5SjP5SL01wfEspJbru5Zs5vQaSHb4VB3ZMJPimgHHCHG7gMOeOB9H3Hdmtxg=="], + + "@radix-ui/react-popover": ["@radix-ui/react-popover@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA=="], + + "@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-radio-group": ["@radix-ui/react-radio-group@1.3.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-VBKYIYImA5zsxACdisNQ3BjCBfmbGH3kQlnFVqlWU4tXwjy7cGX8ta80BcrO+WJXIn5iBylEH3K6ZTlee//lgQ=="], + + "@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="], + + "@radix-ui/react-scroll-area": ["@radix-ui/react-scroll-area@1.2.10", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tAXIa1g3sM5CGpVT0uIbUx/U3Gs5N8T52IICuCtObaos1S8fzsrPXG5WObkQN3S6NVl6wKgPhAIiBGbWnvc97A=="], + + "@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="], + + "@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@radix-ui/react-switch": ["@radix-ui/react-switch@1.2.6", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-use-size": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ=="], + + "@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-is-hydrated": ["@radix-ui/react-use-is-hydrated@0.1.0", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="], + + "@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="], + + "@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="], + + "@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="], + + "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.55.1", "", { "os": "android", "cpu": "arm" }, "sha512-9R0DM/ykwfGIlNu6+2U09ga0WXeZ9MRC2Ter8jnz8415VbuIykVuc6bhdrbORFZANDmTDvq26mJrEVTl8TdnDg=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.55.1", "", { "os": "android", "cpu": "arm64" }, "sha512-eFZCb1YUqhTysgW3sj/55du5cG57S7UTNtdMjCW7LwVcj3dTTcowCsC8p7uBdzKsZYa8J7IDE8lhMI+HX1vQvg=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.55.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-p3grE2PHcQm2e8PSGZdzIhCKbMCw/xi9XvMPErPhwO17vxtvCN5FEA2mSLgmKlCjHGMQTP6phuQTYWUnKewwGg=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.55.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-rDUjG25C9qoTm+e02Esi+aqTKSBYwVTaoS1wxcN47/Luqef57Vgp96xNANwt5npq9GDxsH7kXxNkJVEsWEOEaQ=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.55.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+JiU7Jbp5cdxekIgdte0jfcu5oqw4GCKr6i3PJTlXTCU5H5Fvtkpbs4XJHRmWNXF+hKmn4v7ogI5OQPaupJgOg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.55.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-V5xC1tOVWtLLmr3YUk2f6EJK4qksksOYiz/TCsFHu/R+woubcLWdC9nZQmwjOAbmExBIVKsm1/wKmEy4z4u4Bw=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-Rn3n+FUk2J5VWx+ywrG/HGPTD9jXNbicRtTM11e/uorplArnXZYsVifnPPqNNP5BsO3roI4n8332ukpY/zN7rQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.55.1", "", { "os": "linux", "cpu": "arm" }, "sha512-grPNWydeKtc1aEdrJDWk4opD7nFtQbMmV7769hiAaYyUKCT1faPRm2av8CX1YJsZ4TLAZcg9gTR1KvEzoLjXkg=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-a59mwd1k6x8tXKcUxSyISiquLwB5pX+fJW9TkWU46lCqD/GRDe9uDN31jrMmVP3feI3mhAdvcCClhV8V5MhJFQ=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.55.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-puS1MEgWX5GsHSoiAsF0TYrpomdvkaXm0CofIMG5uVkP6IBV+ZO9xhC5YEN49nsgYo1DuuMquF9+7EDBVYu4uA=="], + + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-r3Wv40in+lTsULSb6nnoudVbARdOwb2u5fpeoOAZjFLznp6tDU8kd+GTHmJoqZ9lt6/Sys33KdIHUaQihFcu7g=="], + + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-MR8c0+UxAlB22Fq4R+aQSPBayvYa3+9DrwG/i1TKQXFYEaoW3B5b/rkSRIypcZDdWjWnpcvxbNaAJDcSbJU3Lw=="], + + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-3KhoECe1BRlSYpMTeVrD4sh2Pw2xgt4jzNSZIIPLFEsnQn9gAnZagW9+VqDqAHgm1Xc77LzJOo2LdigS5qZ+gw=="], + + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.55.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-ziR1OuZx0vdYZZ30vueNZTg73alF59DicYrPViG0NEgDVN8/Jl87zkAPu4u6VjZST2llgEUjaiNl9JM6HH1Vdw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-uW0Y12ih2XJRERZ4jAfKamTyIHVMPQnTZcQjme2HMVDAHY4amf5u414OqNYC+x+LzRdRcnIG1YodLrrtA8xsxw=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.55.1", "", { "os": "linux", "cpu": "none" }, "sha512-u9yZ0jUkOED1BFrqu3BwMQoixvGHGZ+JhJNkNKY/hyoEgOwlqKb62qu+7UjbPSHYjiVy8kKJHvXKv5coH4wDeg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], + + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.55.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-eLXw0dOiqE4QmvikfQ6yjgkg/xDM+MdU9YJuP4ySTibXU0oAvnEWXt7UDJmD4UkYialMfOGFPJnIHSe/kdzPxg=="], + + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.55.1", "", { "os": "none", "cpu": "arm64" }, "sha512-xzm44KgEP11te3S2HCSyYf5zIzWmx3n8HDCc7EE59+lTcswEWNpvMLfd9uJvVX8LCg9QWG67Xt75AuHn4vgsXw=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.55.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-yR6Bl3tMC/gBok5cz/Qi0xYnVbIxGx5Fcf/ca0eB6/6JwOY+SRUcJfI0OpeTpPls7f194as62thCt/2BjxYN8g=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.55.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-3fZBidchE0eY0oFZBnekYCfg+5wAB0mbpCBuofh5mZuzIU/4jIVkbESmd2dOsFNS78b53CYv3OAtwqkZZmU5nA=="], + + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-xGGY5pXj69IxKb4yv/POoocPy/qmEGhimy/FoTpTSVju3FYXUQQMFCaZZXJVidsmGxRioZAwpThl/4zX41gRKg=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.55.1", "", { "os": "win32", "cpu": "x64" }, "sha512-SPEpaL6DX4rmcXtnhdrQYgzQ5W2uW3SCJch88lB2zImhJRhIIK44fkUrgIV/Q8yUNfw5oyZ5vkeQsZLhCb06lw=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-schema/utils": ["@standard-schema/utils@0.3.0", "", {}, "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="], + + "@swc/core": ["@swc/core@1.15.8", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.25" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.15.8", "@swc/core-darwin-x64": "1.15.8", "@swc/core-linux-arm-gnueabihf": "1.15.8", "@swc/core-linux-arm64-gnu": "1.15.8", "@swc/core-linux-arm64-musl": "1.15.8", "@swc/core-linux-x64-gnu": "1.15.8", "@swc/core-linux-x64-musl": "1.15.8", "@swc/core-win32-arm64-msvc": "1.15.8", "@swc/core-win32-ia32-msvc": "1.15.8", "@swc/core-win32-x64-msvc": "1.15.8" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.15.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.15.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.15.8", "", { "os": "linux", "cpu": "arm" }, "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.15.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.15.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.15.8", "", { "os": "linux", "cpu": "x64" }, "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.15.8", "", { "os": "linux", "cpu": "x64" }, "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.15.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.15.8", "", { "os": "win32", "cpu": "ia32" }, "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.15.8", "", { "os": "win32", "cpu": "x64" }, "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/types": ["@swc/types@0.1.25", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + + "@tailwindcss/vite": ["@tailwindcss/vite@4.1.18", "", { "dependencies": { "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "tailwindcss": "4.1.18" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA=="], + + "@tanstack/history": ["@tanstack/history@1.141.0", "", {}, "sha512-LS54XNyxyTs5m/pl1lkwlg7uZM3lvsv2FIIV1rsJgnfwVCnI+n4ZGZ2CcjNT13BPu/3hPP+iHmliBSscJxW5FQ=="], + + "@tanstack/query-core": ["@tanstack/query-core@5.90.16", "", {}, "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww=="], + + "@tanstack/react-query": ["@tanstack/react-query@5.90.16", "", { "dependencies": { "@tanstack/query-core": "5.90.16" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ=="], + + "@tanstack/react-router": ["@tanstack/react-router@1.145.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/react-store": "^0.8.0", "@tanstack/router-core": "1.145.6", "isbot": "^5.1.22", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-hXCSqf9689C24SjfJJILX/pdsFknqzyhmCFXt278IwAfBgMKThePEY7x7rG8VCnWC29tdVC9YptCHqiNJYauxA=="], + + "@tanstack/react-router-with-query": ["@tanstack/react-router-with-query@1.130.17", "", { "peerDependencies": { "@tanstack/react-query": ">=5.49.2", "@tanstack/react-router": ">=1.43.2", "@tanstack/router-core": ">=1.114.7", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-TNaSocW20KuPwUojEm130DLWTr9M5hsSzxiu4QqS2jNCnrGLuDrwMHyP+6fq13lG3YuU4u9O1qajxfJIGomZCg=="], + + "@tanstack/react-store": ["@tanstack/react-store@0.8.0", "", { "dependencies": { "@tanstack/store": "0.8.0", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-1vG9beLIuB7q69skxK9r5xiLN3ztzIPfSQSs0GfeqWGO2tGIyInZx0x1COhpx97RKaONSoAb8C3dxacWksm1ow=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/router-core": ["@tanstack/router-core@1.145.6", "", { "dependencies": { "@tanstack/history": "1.141.0", "@tanstack/store": "^0.8.0", "cookie-es": "^2.0.0", "seroval": "^1.4.1", "seroval-plugins": "^1.4.0", "tiny-invariant": "^1.3.3", "tiny-warning": "^1.0.3" } }, "sha512-pXUwrkMEwsM4w7G6QSGt/LwSl23NoyEXvTygpZiyzCzJatMvW9312mFVGbDGYZxAxNpCob1kJnKNxIH14a86nQ=="], + + "@tanstack/router-generator": ["@tanstack/router-generator@1.145.6", "", { "dependencies": { "@tanstack/router-core": "1.145.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-OBQ+vWgrFm9ThQWI8vUN/uHpqvQ8idI31QFS09q7s/K6+mODonBS1OcnlRfPzBbQ8VHOv93yg63nkPifmmjM3A=="], + + "@tanstack/router-plugin": ["@tanstack/router-plugin@1.145.6", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-syntax-typescript": "^7.27.1", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.145.6", "@tanstack/router-generator": "1.145.6", "@tanstack/router-utils": "1.143.11", "@tanstack/virtual-file-routes": "1.145.4", "babel-dead-code-elimination": "^1.0.11", "chokidar": "^3.6.0", "unplugin": "^2.1.2", "zod": "^3.24.2" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2", "@tanstack/react-router": "^1.145.6", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0", "vite-plugin-solid": "^2.11.10", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-/tMeAScTlHmt+aDr/YGiJDQ9Epm4869kk+LB3xaC8/1hnRS7i2Tp5gReNKPQu5NbqzVZimJHa8qOgxexmN3b+g=="], + + "@tanstack/router-utils": ["@tanstack/router-utils@1.143.11", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "ansis": "^4.1.0", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-N24G4LpfyK8dOlnP8BvNdkuxg1xQljkyl6PcrdiPSA301pOjatRT1y8wuCCJZKVVD8gkd0MpCZ0VEjRMGILOtA=="], + + "@tanstack/store": ["@tanstack/store@0.8.0", "", {}, "sha512-Om+BO0YfMZe//X2z0uLF2j+75nQga6TpTJgLJQBiq85aOyZNIhkCgleNcud2KQg4k4v9Y9l+Uhru3qWMPGTOzQ=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + + "@tanstack/virtual-file-routes": ["@tanstack/virtual-file-routes@1.145.4", "", {}, "sha512-CI75JrfqSluhdGwLssgVeQBaCphgfkMQpi8MCY3UJX1hoGzXa8kHYJcUuIFMOLs1q7zqHy++EVVtMK03osR5wQ=="], + + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + + "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], + + "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + + "@types/docker-modem": ["@types/docker-modem@3.0.6", "", { "dependencies": { "@types/node": "*", "@types/ssh2": "*" } }, "sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg=="], + + "@types/dockerode": ["@types/dockerode@3.3.47", "", { "dependencies": { "@types/docker-modem": "*", "@types/node": "*", "@types/ssh2": "*" } }, "sha512-ShM1mz7rCjdssXt7Xz0u1/R2BJC7piWa3SJpUBiVjCf2A3XNn4cP6pUVaD8bLanpPVVn4IKzJuw3dOvkJ8IbYw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/node": ["@types/node@25.0.3", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-W609buLVRVmeW693xKfzHeIV6nJGGz98uCPfeXI1ELMLXVeKYZ9m15fAMSaUPBHYLGFsVRcMmSCksQOrZV9BYA=="], + + "@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + + "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], + + "@types/ssh2-streams": ["@types/ssh2-streams@0.1.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-faHyY3brO9oLEA0QlcO8N2wT7R0+1sHWZvQ+y3rMLwdY1ZyS1z0W3t65j9PqT4HmQ6ALzNe7RZlNuCNE0wBSWA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="], + + "@unrs/resolver-binding-android-arm-eabi": ["@unrs/resolver-binding-android-arm-eabi@1.11.1", "", { "os": "android", "cpu": "arm" }, "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw=="], + + "@unrs/resolver-binding-android-arm64": ["@unrs/resolver-binding-android-arm64@1.11.1", "", { "os": "android", "cpu": "arm64" }, "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g=="], + + "@unrs/resolver-binding-darwin-arm64": ["@unrs/resolver-binding-darwin-arm64@1.11.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g=="], + + "@unrs/resolver-binding-darwin-x64": ["@unrs/resolver-binding-darwin-x64@1.11.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ=="], + + "@unrs/resolver-binding-freebsd-x64": ["@unrs/resolver-binding-freebsd-x64@1.11.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw=="], + + "@unrs/resolver-binding-linux-arm-gnueabihf": ["@unrs/resolver-binding-linux-arm-gnueabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw=="], + + "@unrs/resolver-binding-linux-arm-musleabihf": ["@unrs/resolver-binding-linux-arm-musleabihf@1.11.1", "", { "os": "linux", "cpu": "arm" }, "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw=="], + + "@unrs/resolver-binding-linux-arm64-gnu": ["@unrs/resolver-binding-linux-arm64-gnu@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ=="], + + "@unrs/resolver-binding-linux-arm64-musl": ["@unrs/resolver-binding-linux-arm64-musl@1.11.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w=="], + + "@unrs/resolver-binding-linux-ppc64-gnu": ["@unrs/resolver-binding-linux-ppc64-gnu@1.11.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA=="], + + "@unrs/resolver-binding-linux-riscv64-gnu": ["@unrs/resolver-binding-linux-riscv64-gnu@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ=="], + + "@unrs/resolver-binding-linux-riscv64-musl": ["@unrs/resolver-binding-linux-riscv64-musl@1.11.1", "", { "os": "linux", "cpu": "none" }, "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew=="], + + "@unrs/resolver-binding-linux-s390x-gnu": ["@unrs/resolver-binding-linux-s390x-gnu@1.11.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg=="], + + "@unrs/resolver-binding-linux-x64-gnu": ["@unrs/resolver-binding-linux-x64-gnu@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w=="], + + "@unrs/resolver-binding-linux-x64-musl": ["@unrs/resolver-binding-linux-x64-musl@1.11.1", "", { "os": "linux", "cpu": "x64" }, "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA=="], + + "@unrs/resolver-binding-wasm32-wasi": ["@unrs/resolver-binding-wasm32-wasi@1.11.1", "", { "dependencies": { "@napi-rs/wasm-runtime": "^0.2.11" }, "cpu": "none" }, "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ=="], + + "@unrs/resolver-binding-win32-arm64-msvc": ["@unrs/resolver-binding-win32-arm64-msvc@1.11.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw=="], + + "@unrs/resolver-binding-win32-ia32-msvc": ["@unrs/resolver-binding-win32-ia32-msvc@1.11.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ=="], + + "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.11.0", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.27", "@swc/core": "^1.12.11" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7" } }, "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w=="], + + "@vitest/expect": ["@vitest/expect@4.0.18", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ=="], + + "@vitest/mocker": ["@vitest/mocker@4.0.18", "", { "dependencies": { "@vitest/spy": "4.0.18", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ=="], + + "@vitest/pretty-format": ["@vitest/pretty-format@4.0.18", "", { "dependencies": { "tinyrainbow": "^3.0.3" } }, "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw=="], + + "@vitest/runner": ["@vitest/runner@4.0.18", "", { "dependencies": { "@vitest/utils": "4.0.18", "pathe": "^2.0.3" } }, "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw=="], + + "@vitest/snapshot": ["@vitest/snapshot@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "magic-string": "^0.30.21", "pathe": "^2.0.3" } }, "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA=="], + + "@vitest/spy": ["@vitest/spy@4.0.18", "", {}, "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw=="], + + "@vitest/utils": ["@vitest/utils@4.0.18", "", { "dependencies": { "@vitest/pretty-format": "4.0.18", "tinyrainbow": "^3.0.3" } }, "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA=="], + + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "ansis": ["ansis@4.2.0", "", {}, "sha512-HqZ5rWlFjGiV0tDm3UxxgNRqsOTniqoKZu0pIAfh7TZQMGuZK+hH0drySty0si0QXj1ieop4+SkSfPZBPPkHig=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "archiver": ["archiver@7.0.1", "", { "dependencies": { "archiver-utils": "^5.0.2", "async": "^3.2.4", "buffer-crc32": "^1.0.0", "readable-stream": "^4.0.0", "readdir-glob": "^1.1.2", "tar-stream": "^3.0.0", "zip-stream": "^6.0.1" } }, "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ=="], + + "archiver-utils": ["archiver-utils@5.0.2", "", { "dependencies": { "glob": "^10.0.0", "graceful-fs": "^4.2.0", "is-stream": "^2.0.1", "lazystream": "^1.0.0", "lodash": "^4.17.15", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + + "ast-types": ["ast-types@0.16.1", "", { "dependencies": { "tslib": "^2.0.1" } }, "sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg=="], + + "async": ["async@3.2.6", "", {}, "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA=="], + + "async-lock": ["async-lock@1.4.1", "", {}, "sha512-Az2ZTpuytrtqENulXwO3GGv1Bztugx6TT37NIo7imr/Qo0gsYiGtSdBa2B6fsXhTpVZDNfu1Qn3pk531e3q+nQ=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + + "axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="], + + "b4a": ["b4a@1.7.3", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-5Q2mfq2WfGuFp3uS//0s6baOJLMoVduPYVeNmDYxu5OUA1/cBfvr2RIS7vi62LdNj/urk1hfmj867I3qt6uZ7Q=="], + + "babel-dead-code-elimination": ["babel-dead-code-elimination@1.0.11", "", { "dependencies": { "@babel/core": "^7.23.7", "@babel/parser": "^7.23.6", "@babel/traverse": "^7.23.7", "@babel/types": "^7.23.6" } }, "sha512-mwq3W3e/pKSI6TG8lXMiDWvEi1VXYlSBlJlB3l+I0bAb5u1RNUl88udos85eOPNK3m5EXK9uO7d2g08pesTySQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "bare-events": ["bare-events@2.8.2", "", { "peerDependencies": { "bare-abort-controller": "*" }, "optionalPeers": ["bare-abort-controller"] }, "sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ=="], + + "bare-fs": ["bare-fs@4.5.2", "", { "dependencies": { "bare-events": "^2.5.4", "bare-path": "^3.0.0", "bare-stream": "^2.6.4", "bare-url": "^2.2.2", "fast-fifo": "^1.3.2" }, "peerDependencies": { "bare-buffer": "*" }, "optionalPeers": ["bare-buffer"] }, "sha512-veTnRzkb6aPHOvSKIOy60KzURfBdUflr5VReI+NSaPL6xf+XLdONQgZgpYvUuZLVQ8dCqxpBAudaOM1+KpAUxw=="], + + "bare-os": ["bare-os@3.6.2", "", {}, "sha512-T+V1+1srU2qYNBmJCXZkUY5vQ0B4FSlL3QDROnKQYOqeiQR8UbjNHlPa+TIbM4cuidiN9GaTaOZgSEgsvPbh5A=="], + + "bare-path": ["bare-path@3.0.0", "", { "dependencies": { "bare-os": "^3.0.1" } }, "sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw=="], + + "bare-stream": ["bare-stream@2.7.0", "", { "dependencies": { "streamx": "^2.21.0" }, "peerDependencies": { "bare-buffer": "*", "bare-events": "*" }, "optionalPeers": ["bare-buffer", "bare-events"] }, "sha512-oyXQNicV1y8nc2aKffH+BUHFRXmx6VrPzlnaEvMhram0nPBrKcEdcyBg5r08D0i8VxngHFAiVyn1QKXpSG0B8A=="], + + "bare-url": ["bare-url@2.3.2", "", { "dependencies": { "bare-path": "^3.0.0" } }, "sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "baseline-browser-mapping": ["baseline-browser-mapping@2.9.11", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-Sg0xJUNDU1sJNGdfGWhVHX0kkZ+HWcvmVymJbj6NSgZZmW/8S9Y2HQ5euytnIgakgxN6papOAWiwDo1ctFDcoQ=="], + + "bcrypt-pbkdf": ["bcrypt-pbkdf@1.0.2", "", { "dependencies": { "tweetnacl": "^0.14.3" } }, "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w=="], + + "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + + "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + + "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + + "buildcheck": ["buildcheck@0.0.7", "", {}, "sha512-lHblz4ahamxpTmnsk+MNTRWsjYKv965MwOrSJyeD588rR3Jcu7swE+0wN5F+PbL5cjgu/9ObkhfzEPuofEMwLA=="], + + "byline": ["byline@5.0.0", "", {}, "sha512-s6webAy+R4SR8XVuJWt2V2rGvhnrhxN+9S15GNuTK3wKPOXFF6RNc+8ug2XhH+2s4f+uudG4kUVYmYOQWL2g0Q=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001762", "", {}, "sha512-PxZwGNvH7Ak8WX5iXzoK1KPZttBXNPuaOvI2ZYU7NrlM+d9Ov+TUvlLOBNGzVXAntMSMMlJPd+jY6ovrVjSmUw=="], + + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "cli-cursor": ["cli-cursor@5.0.0", "", { "dependencies": { "restore-cursor": "^5.0.0" } }, "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw=="], + + "cli-truncate": ["cli-truncate@5.1.1", "", { "dependencies": { "slice-ansi": "^7.1.0", "string-width": "^8.0.0" } }, "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A=="], + + "cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "colorette": ["colorette@2.0.20", "", {}, "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], + + "comment-parser": ["comment-parser@1.4.5", "", {}, "sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw=="], + + "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + + "cookie-es": ["cookie-es@2.0.0", "", {}, "sha512-RAj4E421UYRgqokKUmotqAwuplYw15qtdXfY+hGzgCJ/MBjCVZcSoHK/kH9kocfjRjcDME7IiDWR/1WX1TM2Pg=="], + + "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + + "cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="], + + "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], + + "crc32-stream": ["crc32-stream@6.0.0", "", { "dependencies": { "crc-32": "^1.2.0", "readable-stream": "^4.0.0" } }, "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g=="], + + "cross-env": ["cross-env@10.1.0", "", { "dependencies": { "@epic-web/invariant": "^1.0.0", "cross-spawn": "^7.0.6" }, "bin": { "cross-env": "dist/bin/cross-env.js", "cross-env-shell": "dist/bin/cross-env-shell.js" } }, "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "css-tree": ["css-tree@3.1.0", "", { "dependencies": { "mdn-data": "2.12.2", "source-map-js": "^1.0.1" } }, "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + + "cssstyle": ["cssstyle@5.3.7", "", { "dependencies": { "@asamuzakjp/css-color": "^4.1.1", "@csstools/css-syntax-patches-for-csstree": "^1.0.21", "css-tree": "^3.1.0", "lru-cache": "^11.2.4" } }, "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "docker-compose": ["docker-compose@1.3.0", "", { "dependencies": { "yaml": "^2.2.2" } }, "sha512-7Gevk/5eGD50+eMD+XDnFnOrruFkL0kSd7jEG4cjmqweDSUhB7i0g8is/nBdVpl+Bx338SqIB2GLKm32M+Vs6g=="], + + "docker-modem": ["docker-modem@5.0.6", "", { "dependencies": { "debug": "^4.1.1", "readable-stream": "^3.5.0", "split-ca": "^1.0.1", "ssh2": "^1.15.0" } }, "sha512-ens7BiayssQz/uAxGzH8zGXCtiV24rRWXdjNha5V4zSOcxmAZsfGVm/PPFbwQdqEkDnhG+SyR9E3zSHUbOKXBQ=="], + + "dockerode": ["dockerode@4.0.9", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@grpc/grpc-js": "^1.11.1", "@grpc/proto-loader": "^0.7.13", "docker-modem": "^5.0.6", "protobufjs": "^7.3.2", "tar-fs": "^2.1.4", "uuid": "^10.0.0" } }, "sha512-iND4mcOWhPaCNh54WmK/KoSb35AFqPAUWFMffTQcp52uQt36b5uNwEJTSXntJZBbeGad72Crbi/hvDIv6us/6Q=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.267", "", {}, "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw=="], + + "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + + "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + + "environment": ["environment@1.1.0", "", {}, "sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + + "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="], + + "eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="], + + "eslint-import-context": ["eslint-import-context@0.1.9", "", { "dependencies": { "get-tsconfig": "^4.10.1", "stable-hash-x": "^0.2.0" }, "peerDependencies": { "unrs-resolver": "^1.0.0" }, "optionalPeers": ["unrs-resolver"] }, "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg=="], + + "eslint-plugin-import-x": ["eslint-plugin-import-x@4.16.1", "", { "dependencies": { "@typescript-eslint/types": "^8.35.0", "comment-parser": "^1.4.1", "debug": "^4.4.1", "eslint-import-context": "^0.1.9", "is-glob": "^4.0.3", "minimatch": "^9.0.3 || ^10.0.1", "semver": "^7.7.2", "stable-hash-x": "^0.2.0", "unrs-resolver": "^1.9.2" }, "peerDependencies": { "@typescript-eslint/utils": "^8.0.0", "eslint": "^8.57.0 || ^9.0.0", "eslint-import-resolver-node": "*" }, "optionalPeers": ["@typescript-eslint/utils", "eslint-import-resolver-node"] }, "sha512-vPZZsiOKaBAIATpFE2uMI4w5IRwdv/FpQ+qZZMR4E+PeOcM4OeoEbqxRMnywdxP19TyB/3h6QBB0EWon7letSQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@7.0.1", "", { "dependencies": { "@babel/core": "^7.24.4", "@babel/parser": "^7.24.4", "hermes-parser": "^0.25.1", "zod": "^3.25.0 || ^4.0.0", "zod-validation-error": "^3.5.0 || ^4.0.0" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.26", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], + + "esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + + "eventemitter3": ["eventemitter3@5.0.4", "", {}, "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw=="], + + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], + + "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + + "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + + "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-port": ["get-port@7.1.0", "", {}, "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "get-tsconfig": ["get-tsconfig@4.13.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ=="], + + "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@17.0.0", "", {}, "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hermes-estree": ["hermes-estree@0.25.1", "", {}, "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw=="], + + "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], + + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "html-parse-stringify": ["html-parse-stringify@3.0.1", "", { "dependencies": { "void-elements": "3.1.0" } }, "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "husky": ["husky@9.1.7", "", { "bin": { "husky": "bin.js" } }, "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA=="], + + "i18next": ["i18next@25.7.3", "", { "dependencies": { "@babel/runtime": "^7.28.4" }, "peerDependencies": { "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-2XaT+HpYGuc2uTExq9TVRhLsso+Dxym6PWaKpn36wfBmTI779OQ7iP/XaZHzrnGyzU4SHpFrTYLKfVyBfAhVNA=="], + + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@5.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.1" } }, "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], + + "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], + + "isbot": ["isbot@5.1.32", "", {}, "sha512-VNfjM73zz2IBZmdShMfAUg10prm6t7HFUQmNAEOAVS4YH92ZrZcvkMcGX6cIgBJAzWDzPent/EeAtYEHNPNPBQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + + "jsdom": ["jsdom@28.0.0", "", { "dependencies": { "@acemir/cssom": "^0.9.31", "@asamuzakjp/dom-selector": "^6.7.6", "@exodus/bytes": "^1.11.0", "cssstyle": "^5.3.7", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "http-proxy-agent": "^7.0.2", "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.0", "undici": "^7.20.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA=="], + + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], + + "jwt-decode": ["jwt-decode@4.0.0", "", {}, "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "lint-staged": ["lint-staged@16.2.7", "", { "dependencies": { "commander": "^14.0.2", "listr2": "^9.0.5", "micromatch": "^4.0.8", "nano-spawn": "^2.0.0", "pidtree": "^0.6.0", "string-argv": "^0.3.2", "yaml": "^2.8.1" }, "bin": { "lint-staged": "bin/lint-staged.js" } }, "sha512-lDIj4RnYmK7/kXMya+qJsmkRFkGolciXjrsZ6PC25GdTfWOAWetR0ZbsNXRAj1EHHImRSalc+whZFg56F5DVow=="], + + "listr2": ["listr2@9.0.5", "", { "dependencies": { "cli-truncate": "^5.0.0", "colorette": "^2.0.20", "eventemitter3": "^5.0.1", "log-update": "^6.1.0", "rfdc": "^1.4.1", "wrap-ansi": "^9.0.0" } }, "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "lodash.camelcase": ["lodash.camelcase@4.3.0", "", {}, "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "log-update": ["log-update@6.1.0", "", { "dependencies": { "ansi-escapes": "^7.0.0", "cli-cursor": "^5.0.0", "slice-ansi": "^7.1.0", "strip-ansi": "^7.1.0", "wrap-ansi": "^9.0.0" } }, "sha512-9ie8ItPR6tjY5uYJh8K/Zrv/RMZ5VOlOWvtZdEHYSTFKZfIBPQa9tOAEeAWhd+AnIneLJ22w5fjOYtoutpWq5w=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.2.5", "", {}, "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw=="], + + "lucide-react": ["lucide-react@0.562.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "mkdirp": ["mkdirp@1.0.4", "", { "bin": { "mkdirp": "bin/cmd.js" } }, "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nan": ["nan@2.24.0", "", {}, "sha512-Vpf9qnVW1RaDkoNKFUvfxqAbtI8ncb8OJlqZ9wwpXzWPEsvsB1nvdUi6oYrHIkQ1Y/tMDnr1h4nczS0VB9Xykg=="], + + "nano-spawn": ["nano-spawn@2.0.0", "", {}, "sha512-tacvGzUY5o2D8CBh2rrwxyNojUsZNU2zjNTzKQrkgGJQTbGAfArVWXSKMBokBeeg6C7OLRGUEyoFlYbfeWQIqw=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-postinstall": ["napi-postinstall@0.3.4", "", { "bin": { "napi-postinstall": "lib/cli.js" } }, "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + + "pidtree": ["pidtree@0.6.0", "", { "bin": { "pidtree": "bin/pidtree.js" } }, "sha512-eG2dWTVw5bzqGRztnHExczNxt5VGsE6OwTeCG3fdUf9KBsZzO3R5OIIIzWR+iZA0NtZ+RDVdaoE2dK1cn6jH4g=="], + + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "prettier": ["prettier@3.8.1", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg=="], + + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], + + "process-nextick-args": ["process-nextick-args@2.0.1", "", {}, "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "properties-reader": ["properties-reader@2.3.0", "", { "dependencies": { "mkdirp": "^1.0.4" } }, "sha512-z597WicA7nDZxK12kZqHr2TcvwNU1GCfA5UwfDY/HDp3hXPoPlb5rlEx9bwGTiJnc0OqbBTkU975jDToth8Gxw=="], + + "protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], + + "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + + "react-hook-form": ["react-hook-form@7.70.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-COOMajS4FI3Wuwrs3GPpi/Jeef/5W1DRR84Yl5/ShlT3dKVFUfoGiEZ/QE6Uw8P4T2/CLJdcTVYKvWBMQTEpvw=="], + + "react-i18next": ["react-i18next@16.5.1", "", { "dependencies": { "@babel/runtime": "^7.28.4", "html-parse-stringify": "^3.0.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "i18next": ">= 25.6.2", "react": ">= 16.8.0", "typescript": "^5" }, "optionalPeers": ["typescript"] }, "sha512-Hks6UIRZWW4c+qDAnx1csVsCGYeIR4MoBGQgJ+NUoNnO6qLxXuf8zu0xdcinyXUORgGzCdRsexxO1Xzv3sTdnw=="], + + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], + + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="], + + "rollup": ["rollup@4.55.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.55.1", "@rollup/rollup-android-arm64": "4.55.1", "@rollup/rollup-darwin-arm64": "4.55.1", "@rollup/rollup-darwin-x64": "4.55.1", "@rollup/rollup-freebsd-arm64": "4.55.1", "@rollup/rollup-freebsd-x64": "4.55.1", "@rollup/rollup-linux-arm-gnueabihf": "4.55.1", "@rollup/rollup-linux-arm-musleabihf": "4.55.1", "@rollup/rollup-linux-arm64-gnu": "4.55.1", "@rollup/rollup-linux-arm64-musl": "4.55.1", "@rollup/rollup-linux-loong64-gnu": "4.55.1", "@rollup/rollup-linux-loong64-musl": "4.55.1", "@rollup/rollup-linux-ppc64-gnu": "4.55.1", "@rollup/rollup-linux-ppc64-musl": "4.55.1", "@rollup/rollup-linux-riscv64-gnu": "4.55.1", "@rollup/rollup-linux-riscv64-musl": "4.55.1", "@rollup/rollup-linux-s390x-gnu": "4.55.1", "@rollup/rollup-linux-x64-gnu": "4.55.1", "@rollup/rollup-linux-x64-musl": "4.55.1", "@rollup/rollup-openbsd-x64": "4.55.1", "@rollup/rollup-openharmony-arm64": "4.55.1", "@rollup/rollup-win32-arm64-msvc": "4.55.1", "@rollup/rollup-win32-ia32-msvc": "4.55.1", "@rollup/rollup-win32-x64-gnu": "4.55.1", "@rollup/rollup-win32-x64-msvc": "4.55.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-wDv/Ht1BNHB4upNbK74s9usvl7hObDnvVzknxqY/E/O3X6rW1U1rV1aENEfJ54eFZDTNo7zv1f5N4edCluH7+A=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "seroval": ["seroval@1.4.2", "", {}, "sha512-N3HEHRCZYn3cQbsC4B5ldj9j+tHdf4JZoYPlcI4rRYu0Xy4qN8MQf1Z08EibzB0WpgRG5BGK08FTrmM66eSzKQ=="], + + "seroval-plugins": ["seroval-plugins@1.4.2", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-X7p4MEDTi+60o2sXZ4bnDBhgsUYDSkQEvzYZuJyFqWg9jcoPsHts5nrg5O956py2wyt28lUrBxk0M0/wU8URpA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "slice-ansi": ["slice-ansi@7.1.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "is-fullwidth-code-point": "^5.0.0" } }, "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w=="], + + "sonner": ["sonner@2.0.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w=="], + + "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], + + "ssh-remote-port-forward": ["ssh-remote-port-forward@1.0.4", "", { "dependencies": { "@types/ssh2": "^0.5.48", "ssh2": "^1.4.0" } }, "sha512-x0LV1eVDwjf1gmG7TTnfqIzf+3VPRz7vrNIjX6oYLbeCrf/PeVY6hkT68Mg+q02qXxQhrLjB0jfgvhevoCRmLQ=="], + + "ssh2": ["ssh2@1.17.0", "", { "dependencies": { "asn1": "^0.2.6", "bcrypt-pbkdf": "^1.0.2" }, "optionalDependencies": { "cpu-features": "~0.0.10", "nan": "^2.23.0" } }, "sha512-wPldCk3asibAjQ/kziWQQt1Wh3PgDFpC0XpwclzKcdT1vql6KeYxf5LIt4nlFkUeR8WuphYMKqUA56X4rjbfgQ=="], + + "stable-hash-x": ["stable-hash-x@0.2.0", "", {}, "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ=="], + + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], + + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], + + "string-argv": ["string-argv@0.3.2", "", {}, "sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q=="], + + "string-width": ["string-width@8.1.1", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + + "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tailwind-merge": ["tailwind-merge@3.4.0", "", {}, "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar-fs": ["tar-fs@3.1.1", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-LZA0oaPOc2fVo82Txf3gw+AkEd38szODlptMYejQUhndHMLQ9M059uXR+AfS7DNo0NpINvSqDsvyaCrBVkptWg=="], + + "tar-stream": ["tar-stream@3.1.7", "", { "dependencies": { "b4a": "^1.6.4", "fast-fifo": "^1.2.0", "streamx": "^2.15.0" } }, "sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ=="], + + "testcontainers": ["testcontainers@11.11.0", "", { "dependencies": { "@balena/dockerignore": "^1.0.2", "@types/dockerode": "^3.3.47", "archiver": "^7.0.1", "async-lock": "^1.4.1", "byline": "^5.0.0", "debug": "^4.4.3", "docker-compose": "^1.3.0", "dockerode": "^4.0.9", "get-port": "^7.1.0", "proper-lockfile": "^4.1.2", "properties-reader": "^2.3.0", "ssh-remote-port-forward": "^1.0.4", "tar-fs": "^3.1.1", "tmp": "^0.2.5", "undici": "^7.16.0" } }, "sha512-nKTJn3n/gkyGg/3SVkOwX+isPOGSHlfI+CWMobSmvQrsj7YW01aWvl2pYIfV4LMd+C8or783yYrzKSK2JlP+Qw=="], + + "text-decoder": ["text-decoder@1.2.3", "", { "dependencies": { "b4a": "^1.6.4" } }, "sha512-3/o9z3X0X0fTupwsYvR03pJ/DjWuqqrfwBgTQzdWDiQSm9KitAyz/9WqsT2JQW7KV2m+bC2ol/zqpW37NHxLaA=="], + + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + + "tiny-warning": ["tiny-warning@1.0.3", "", {}, "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="], + + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], + + "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], + + "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + + "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], + + "tldts": ["tldts@7.0.22", "", { "dependencies": { "tldts-core": "^7.0.22" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-nqpKFC53CgopKPjT6Wfb6tpIcZXHcI6G37hesvikhx0EmUGPkZrujRyAjgnmp1SHNgpQfKVanZ+KfpANFt2Hxw=="], + + "tldts-core": ["tldts-core@7.0.22", "", {}, "sha512-KgbTDC5wzlL6j/x6np6wCnDSMUq4kucHNm00KXPbfNzmllCmtmvtykJHfmgdHntwIeupW04y8s1N/43S1PkQDw=="], + + "tmp": ["tmp@0.2.5", "", {}, "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "tough-cookie": ["tough-cookie@6.0.0", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + + "ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="], + + "tweetnacl": ["tweetnacl@0.14.5", "", {}, "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.52.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.52.0", "@typescript-eslint/parser": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-atlQQJ2YkO4pfTVQmQ+wvYQwexPDOIgo+RaVcD7gHgzy/IQA+XTyuxNM9M9TVXvttkF7koBHmcwisKdOAf2EcA=="], + + "undici": ["undici@7.21.0", "", {}, "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg=="], + + "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + + "unplugin": ["unplugin@2.3.11", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "acorn": "^8.15.0", "picomatch": "^4.0.3", "webpack-virtual-modules": "^0.6.2" } }, "sha512-5uKD0nqiYVzlmCRs01Fhs2BdkEgBS3SAVP6ndrBsuK42iC2+JHyxM05Rm9G8+5mkmRtzMZGY8Ct5+mliZxU/Ww=="], + + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], + + "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], + + "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], + + "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + + "vitest": ["vitest@4.0.18", "", { "dependencies": { "@vitest/expect": "4.0.18", "@vitest/mocker": "4.0.18", "@vitest/pretty-format": "4.0.18", "@vitest/runner": "4.0.18", "@vitest/snapshot": "4.0.18", "@vitest/spy": "4.0.18", "@vitest/utils": "4.0.18", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.18", "@vitest/browser-preview": "4.0.18", "@vitest/browser-webdriverio": "4.0.18", "@vitest/ui": "4.0.18", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ=="], + + "void-elements": ["void-elements@3.1.0", "", {}, "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "webpack-virtual-modules": ["webpack-virtual-modules@0.6.2", "", {}, "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@16.0.0", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@9.0.2", "", { "dependencies": { "ansi-styles": "^6.2.1", "string-width": "^7.0.0", "strip-ansi": "^7.1.0" } }, "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + + "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], + + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], + + "yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], + + "yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], + + "zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="], + + "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], + + "zustand": ["zustand@5.0.9", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg=="], + + "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@grpc/grpc-js/@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="], + + "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "@itenium-forge/ui/lucide-react": ["lucide-react@0.511.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-VK5a2ydJ7xm8GvBeKLS9mu1pVK6ucef9780JVUjw6bAjJL/QXnd4Y0p7SPeOUMC27YhzNCZvm5d/QX0Tp3rc0w=="], + + "@radix-ui/react-arrow/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-checkbox/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-checkbox/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collection/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-collection/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-collection/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dialog/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dialog/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-menu/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-menu/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popover/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popover/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-popover/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-radio-group/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-radio-group/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-select/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-tooltip/@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-tooltip/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "@tanstack/router-generator/prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], + + "@tanstack/router-generator/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@tanstack/router-plugin/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + + "@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "bl/buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], + + "bl/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "cliui/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "docker-modem/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + + "dockerode/tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "eslint-plugin-import-x/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "foreground-child/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + + "pretty-format/ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "readdir-glob/minimatch": ["minimatch@5.1.6", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "recast/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], + + "restore-cursor/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "slice-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "ssh-remote-port-forward/@types/ssh2": ["@types/ssh2@0.5.52", "", { "dependencies": { "@types/node": "*", "@types/ssh2-streams": "*" } }, "sha512-lbLLlXxdCZOSJMCInKH2+9V/77ET2J6NPQHpFI0kda61Dd1KglJs+fPQBchizmzYSOJBgdTajhPqBO1xxLywvg=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], + + "testcontainers/undici": ["undici@7.18.0", "", {}, "sha512-CfPufgPFHCYu0W4h1NiKW9+tNJ39o3kWm7Cm29ET1enSJx+AERfz7A2wAr26aY0SZbYzZlTBQtcHy15o60VZfQ=="], + + "tsx/esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "wrap-ansi/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "@isaacs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "@radix-ui/react-arrow/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-checkbox/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-dropdown-menu/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-popper/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-radio-group/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-roving-focus/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-scroll-area/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-switch/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-visually-hidden/@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "cliui/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "cliui/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "dockerode/tar-fs/tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + + "eslint-plugin-import-x/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], + + "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], + + "readdir-glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], + + "tsx/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], + + "tsx/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.2", "", { "os": "android", "cpu": "arm64" }, "sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA=="], + + "tsx/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.2", "", { "os": "android", "cpu": "x64" }, "sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A=="], + + "tsx/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg=="], + + "tsx/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA=="], + + "tsx/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g=="], + + "tsx/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA=="], + + "tsx/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.2", "", { "os": "linux", "cpu": "arm" }, "sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw=="], + + "tsx/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw=="], + + "tsx/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w=="], + + "tsx/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg=="], + + "tsx/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw=="], + + "tsx/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ=="], + + "tsx/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.2", "", { "os": "linux", "cpu": "none" }, "sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA=="], + + "tsx/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w=="], + + "tsx/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.2", "", { "os": "linux", "cpu": "x64" }, "sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA=="], + + "tsx/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw=="], + + "tsx/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.2", "", { "os": "none", "cpu": "x64" }, "sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA=="], + + "tsx/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA=="], + + "tsx/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg=="], + + "tsx/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.2", "", { "os": "none", "cpu": "arm64" }, "sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag=="], + + "tsx/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg=="], + + "tsx/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg=="], + + "tsx/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ=="], + + "tsx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "yargs/string-width/is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "yargs/string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "dockerode/tar-fs/tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], + } +} diff --git a/Itenium.SkillForge/frontend/e2e/auth.spec.ts b/Itenium.SkillForge/frontend/e2e/auth.spec.ts new file mode 100644 index 0000000..fa89dcc --- /dev/null +++ b/Itenium.SkillForge/frontend/e2e/auth.spec.ts @@ -0,0 +1,62 @@ +import { test, expect, testUsers } from './fixtures'; + +test.describe('Authentication', () => { + test.beforeEach(async ({ page }) => { + // Set English language before each test + await page.addInitScript(() => { + localStorage.setItem('language', 'en'); + }); + }); + + test('should show login page for unauthenticated users', async ({ page }) => { + await page.goto('/'); + + await expect(page).toHaveURL(/sign-in/); + await expect(page.getByText('Welcome')).toBeVisible(); + }); + + test('should login with valid credentials', async ({ page }) => { + await page.goto('/sign-in'); + + // Wait for the form to be visible + await expect(page.getByText('Welcome')).toBeVisible(); + + // Fill in credentials using placeholder text + await page.getByPlaceholder(/enter your username/i).fill(testUsers.backoffice.username); + await page.getByPlaceholder(/enter your password/i).fill(testUsers.backoffice.password); + await page.getByRole('button', { name: /sign in/i }).click(); + + // Should be redirected to dashboard after successful login + await expect(page).toHaveURL('/'); + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + }); + + test('should show error for invalid credentials', async ({ page }) => { + await page.goto('/sign-in'); + + await expect(page.getByText('Welcome')).toBeVisible(); + + await page.getByPlaceholder(/enter your username/i).fill('invalid'); + await page.getByPlaceholder(/enter your password/i).fill('wrongpassword'); + await page.getByRole('button', { name: /sign in/i }).click(); + + // Should show error message + await expect(page.getByText(/invalid/i)).toBeVisible(); + }); + + test('should logout and redirect to login', async ({ authenticatedPage }) => { + await authenticatedPage.goto('/'); + + // Wait for dashboard to load + await expect(authenticatedPage.getByRole('heading', { name: 'Dashboard' })).toBeVisible(); + + // Click on user menu button (matches "B backoffice" format) + await authenticatedPage.getByRole('button', { name: 'B backoffice' }).click(); + + // Click on Sign Out in the dropdown + await authenticatedPage.getByRole('menuitem', { name: /sign out/i }).click(); + + // Should be redirected to sign-in + await expect(authenticatedPage).toHaveURL(/sign-in/); + }); +}); diff --git a/Itenium.SkillForge/frontend/e2e/fixtures.ts b/Itenium.SkillForge/frontend/e2e/fixtures.ts new file mode 100644 index 0000000..56b4900 --- /dev/null +++ b/Itenium.SkillForge/frontend/e2e/fixtures.ts @@ -0,0 +1,84 @@ +import { test as base, expect, type Page } from '@playwright/test'; +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATE_FILE = path.join(__dirname, '.test-state.json'); + +type TestFixtures = { + apiUrl: string; + authenticatedPage: Page; +}; + +function getApiUrl(): string { + if (fs.existsSync(STATE_FILE)) { + const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + return state.apiUrl; + } + throw new Error('Test state file not found. Is the backend container running?'); +} + +export const test = base.extend({ + apiUrl: async ({}, use) => { + const apiUrl = getApiUrl(); + await use(apiUrl); + }, + + authenticatedPage: async ({ page }, use) => { + // Set English language before any navigation + await page.addInitScript(() => { + localStorage.setItem('language', 'en'); + }); + + // Navigate to sign-in page + await page.goto('/sign-in'); + + // Wait for the login form to appear + await page.waitForSelector('text=Welcome'); + + // Fill in credentials + await page.getByPlaceholder(/enter your username/i).fill('backoffice'); + await page.getByPlaceholder(/enter your password/i).fill('AdminPassword123!'); + + // Click sign in button + await page.getByRole('button', { name: /sign in/i }).click(); + + // Wait for successful navigation to dashboard + await page.waitForURL('/'); + await page.waitForSelector('h1:has-text("Dashboard")'); + + await use(page); + }, +}); + +export { expect }; + +// Test users available in the seeded database +export const testUsers = { + backoffice: { + username: 'backoffice', + password: 'AdminPassword123!', + email: 'backoffice@test.local', + }, + java: { + username: 'java', + password: 'UserPassword123!', + email: 'java@test.local', + }, + dotnet: { + username: 'dotnet', + password: 'UserPassword123!', + email: 'dotnet@test.local', + }, + multi: { + username: 'multi', + password: 'UserPassword123!', + email: 'multi@test.local', + }, + learner: { + username: 'learner', + password: 'UserPassword123!', + email: 'learner@test.local', + }, +}; diff --git a/Itenium.SkillForge/frontend/e2e/global-setup.ts b/Itenium.SkillForge/frontend/e2e/global-setup.ts new file mode 100644 index 0000000..aa83b19 --- /dev/null +++ b/Itenium.SkillForge/frontend/e2e/global-setup.ts @@ -0,0 +1,98 @@ +import { GenericContainer, Network, Wait } from 'testcontainers'; +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATE_FILE = path.join(__dirname, '.test-state.json'); + +export default async function globalSetup() { + // Option 1: Use a locally running backend (faster for local dev) + const backendUrl = process.env.BACKEND_URL; + if (backendUrl) { + console.log(`Using existing backend at ${backendUrl}`); + const state = { apiUrl: backendUrl, backendContainerId: null, postgresContainerId: null, networkId: null }; + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + return; + } + + // Option 2: Start backend + PostgreSQL in Docker using Testcontainers + console.log('Starting e2e test environment...'); + + const nugetUser = process.env.NUGET_USER; + const nugetToken = process.env.NUGET_TOKEN; + + if (!nugetUser || !nugetToken) { + throw new Error( + 'Missing NUGET_USER or NUGET_TOKEN environment variables.\n\n' + + 'Option 1 - Use Docker (requires GitHub Packages auth):\n' + + ' $env:NUGET_USER="your-github-username"\n' + + ' $env:NUGET_TOKEN="your-github-pat-with-read:packages"\n' + + ' npm run test:e2e\n\n' + + 'Option 2 - Use locally running backend (faster for local dev):\n' + + ' # Start the backend manually first, then:\n' + + ' $env:BACKEND_URL="https://localhost:5001"\n' + + ' npm run test:e2e', + ); + } + + // Create a network for containers to communicate + const network = await new Network().start(); + + // Start PostgreSQL container + console.log('Starting PostgreSQL container...'); + const postgresContainer = await new GenericContainer('postgres:17') + .withNetwork(network) + .withNetworkAliases('postgres') + .withEnvironment({ + POSTGRES_USER: 'skillforge', + POSTGRES_PASSWORD: 'skillforge', + POSTGRES_DB: 'skillforge', + }) + .withExposedPorts(5432) + .withWaitStrategy(Wait.forListeningPorts()) + .start(); + + console.log('PostgreSQL started'); + + // Build backend Docker image + const backendPath = path.resolve(__dirname, '../../backend'); + console.log('Building backend Docker image (this may take a few minutes)...'); + + const backendImage = await GenericContainer.fromDockerfile(backendPath) + .withBuildArgs({ + NUGET_USER: nugetUser, + NUGET_TOKEN: nugetToken, + }) + .build('skillforge-backend-test', { deleteOnExit: false }); + + // Start backend container connected to PostgreSQL + // Uses DOTNET_ENVIRONMENT=Docker from Dockerfile -> appsettings.Docker.json (Host=postgres) + console.log('Starting backend container...'); + const backendContainer = await backendImage + .withNetwork(network) + .withExposedPorts(8080) + .withStartupTimeout(120000) + .withWaitStrategy(Wait.forHttp('/health/live', 8080).forStatusCode(200).withStartupTimeout(120000)) + .start(); + + const apiPort = backendContainer.getMappedPort(8080); + const apiHost = backendContainer.getHost(); + const apiUrl = `http://${apiHost}:${apiPort}`; + + console.log(`Backend started at ${apiUrl}`); + + // Save state for tests and teardown + const state = { + backendContainerId: backendContainer.getId(), + postgresContainerId: postgresContainer.getId(), + networkId: network.getId(), + apiUrl, + }; + + fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2)); + + // Write .env.e2e for Vite to pick up + const envFile = path.resolve(__dirname, '../.env.e2e'); + fs.writeFileSync(envFile, `VITE_API_URL=${apiUrl}\n`); +} diff --git a/Itenium.SkillForge/frontend/e2e/global-teardown.ts b/Itenium.SkillForge/frontend/e2e/global-teardown.ts new file mode 100644 index 0000000..89e6b9e --- /dev/null +++ b/Itenium.SkillForge/frontend/e2e/global-teardown.ts @@ -0,0 +1,54 @@ +import * as path from 'path'; +import * as fs from 'fs'; +import { fileURLToPath } from 'url'; +import { getContainerRuntimeClient } from 'testcontainers'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const STATE_FILE = path.join(__dirname, '.test-state.json'); + +export default async function globalTeardown() { + try { + if (!fs.existsSync(STATE_FILE)) { + return; + } + + const state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf-8')); + const client = await getContainerRuntimeClient(); + + // Stop backend container + if (state.backendContainerId) { + console.log('Stopping backend container...'); + const backendContainer = client.container.getById(state.backendContainerId); + await backendContainer.stop(); + await backendContainer.remove(); + console.log('Backend container stopped and removed'); + } + + // Stop PostgreSQL container + if (state.postgresContainerId) { + console.log('Stopping PostgreSQL container...'); + const postgresContainer = client.container.getById(state.postgresContainerId); + await postgresContainer.stop(); + await postgresContainer.remove(); + console.log('PostgreSQL container stopped and removed'); + } + + // Remove network + if (state.networkId) { + console.log('Removing network...'); + const network = client.network.getById(state.networkId); + await network.remove(); + console.log('Network removed'); + } + + fs.unlinkSync(STATE_FILE); + + // Clean up .env.e2e file + const envFile = path.resolve(__dirname, '../.env.e2e'); + if (fs.existsSync(envFile)) { + fs.unlinkSync(envFile); + } + } catch (error) { + console.error('Error during teardown:', error); + } +} diff --git a/Itenium.SkillForge/frontend/eslint.config.js b/Itenium.SkillForge/frontend/eslint.config.js new file mode 100644 index 0000000..cba336d --- /dev/null +++ b/Itenium.SkillForge/frontend/eslint.config.js @@ -0,0 +1,44 @@ +import js from '@eslint/js'; +import globals from 'globals'; +import reactHooks from 'eslint-plugin-react-hooks'; +import reactRefresh from 'eslint-plugin-react-refresh'; +import importX from 'eslint-plugin-import-x'; +import tseslint from 'typescript-eslint'; +import prettier from 'eslint-config-prettier'; + +export default tseslint.config( + { ignores: ['dist', 'e2e'] }, + { + extends: [js.configs.recommended, ...tseslint.configs.strict, ...tseslint.configs.stylistic], + files: ['**/*.{ts,tsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + 'import-x': importX, + }, + rules: { + ...reactHooks.configs.recommended.rules, + 'react-refresh/only-export-components': ['warn', { allowConstantExport: true }], + + // Prevent stray debug code + 'no-console': 'warn', + 'no-debugger': 'error', + + // Import ordering + 'import-x/order': [ + 'warn', + { + groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], + 'newlines-between': 'never', + }, + ], + 'import-x/no-duplicates': 'warn', + }, + }, + // Prettier must be last to disable conflicting rules + prettier, +); diff --git a/Itenium.SkillForge/frontend/index.html b/Itenium.SkillForge/frontend/index.html new file mode 100644 index 0000000..d449012 --- /dev/null +++ b/Itenium.SkillForge/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + Itenium SkillForge + + +
+ + + diff --git a/Itenium.SkillForge/frontend/package.json b/Itenium.SkillForge/frontend/package.json new file mode 100644 index 0000000..26faadb --- /dev/null +++ b/Itenium.SkillForge/frontend/package.json @@ -0,0 +1,96 @@ +{ + "name": "@itenium/skillforge-frontend", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "lint": "eslint .", + "format": "prettier --write .", + "format:check": "prettier --check .", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:e2e": "playwright test", + "test:e2e:local": "cross-env BACKEND_URL=https://localhost:5001 playwright test", + "test:e2e:ui": "playwright test --ui", + "test:e2e:headed": "playwright test --headed", + "test:e2e:debug": "playwright test --debug", + "prepare": "cd ../.. && husky Itenium.SkillForge/frontend/.husky" + }, + "lint-staged": { + "*.{ts,tsx}": [ + "eslint --fix", + "prettier --write" + ], + "*.{json,md,css}": [ + "prettier --write" + ] + }, + "dependencies": { + "@hookform/resolvers": "^5.2.2", + "@itenium-forge/ui": "0.0.3", + "@radix-ui/react-avatar": "^1.1.11", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.8", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.8", + "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.16", + "@tanstack/react-router": "^1.144.0", + "@tanstack/react-router-with-query": "^1.130.17", + "@tanstack/react-table": "^8.21.3", + "@tanstack/router-plugin": "^1.145.2", + "axios": "^1.13.2", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "i18next": "^25.7.3", + "jwt-decode": "^4.0.0", + "lucide-react": "^0.562.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-hook-form": "^7.70.0", + "react-i18next": "^16.5.1", + "sonner": "^2.0.7", + "tailwind-merge": "^3.4.0", + "zod": "^4.3.4", + "zustand": "^5.0.9" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@playwright/test": "^1.57.0", + "@tailwindcss/vite": "^4.1.18", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/node": "^25.0.3", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react-swc": "^3.5.0", + "cross-env": "^10.1.0", + "eslint": "^9.39.2", + "eslint-config-prettier": "^10.1.8", + "eslint-plugin-import-x": "^4.16.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", + "globals": "^17.0.0", + "husky": "^9.1.7", + "jsdom": "^28.0.0", + "lint-staged": "^16.2.7", + "prettier": "^3.8.1", + "tailwindcss": "^4.1.18", + "testcontainers": "^11.11.0", + "typescript": "~5.8.2", + "typescript-eslint": "^8.52.0", + "vite": "^6.0.0", + "vitest": "^4.0.18" + } +} diff --git a/Itenium.SkillForge/frontend/playwright.config.ts b/Itenium.SkillForge/frontend/playwright.config.ts new file mode 100644 index 0000000..8c60f0d --- /dev/null +++ b/Itenium.SkillForge/frontend/playwright.config.ts @@ -0,0 +1,31 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + workers: process.env.CI ? 1 : undefined, + reporter: process.env.CI ? 'github' : 'html', + use: { + baseURL: 'http://localhost:5173', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + ignoreHTTPSErrors: true, + locale: 'en-US', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'npm run dev -- --mode e2e', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, + globalSetup: './e2e/global-setup.ts', + globalTeardown: './e2e/global-teardown.ts', +}); diff --git a/Itenium.SkillForge/frontend/public/favicon.png b/Itenium.SkillForge/frontend/public/favicon.png new file mode 100644 index 0000000..3af3993 Binary files /dev/null and b/Itenium.SkillForge/frontend/public/favicon.png differ diff --git a/Itenium.SkillForge/frontend/public/login-bg-orig.png b/Itenium.SkillForge/frontend/public/login-bg-orig.png new file mode 100644 index 0000000..188f86b Binary files /dev/null and b/Itenium.SkillForge/frontend/public/login-bg-orig.png differ diff --git a/Itenium.SkillForge/frontend/public/login-bg.png b/Itenium.SkillForge/frontend/public/login-bg.png new file mode 100644 index 0000000..1d43d57 Binary files /dev/null and b/Itenium.SkillForge/frontend/public/login-bg.png differ diff --git a/Itenium.SkillForge/frontend/src/api/client.ts b/Itenium.SkillForge/frontend/src/api/client.ts new file mode 100644 index 0000000..94b85c9 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/api/client.ts @@ -0,0 +1,74 @@ +import axios from 'axios'; +import { useAuthStore } from '../stores'; + +const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + +export const api = axios.create({ + baseURL: API_BASE_URL, +}); + +// Add auth token to requests +api.interceptors.request.use((config) => { + const token = useAuthStore.getState().accessToken; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +// Handle 401 responses +api.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + useAuthStore.getState().logout(); + } + return Promise.reject(error); + }, +); + +export interface LoginResponse { + access_token: string; + token_type: string; + expires_in: number; +} + +export async function loginApi(username: string, password: string): Promise { + const params = new URLSearchParams(); + params.append('grant_type', 'password'); + params.append('username', username); + params.append('password', password); + params.append('client_id', 'skillforge-spa'); + params.append('scope', 'openid profile email'); + + const response = await axios.post(`${API_BASE_URL}/connect/token`, params, { + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }); + + return response.data; +} + +export interface Team { + id: number; + name: string; +} + +export async function fetchUserTeams(): Promise { + const response = await api.get('/api/team'); + return response.data; +} + +export interface Course { + id: number; + name: string; + description: string | null; + category: string | null; + level: string | null; +} + +export async function fetchCourses(): Promise { + const response = await api.get('/api/course'); + return response.data; +} diff --git a/Itenium.SkillForge/frontend/src/components/Layout.tsx b/Itenium.SkillForge/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..8168eae --- /dev/null +++ b/Itenium.SkillForge/frontend/src/components/Layout.tsx @@ -0,0 +1,477 @@ +import { useState, useEffect } from 'react'; +import { Outlet, Link, useRouter } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { + SidebarProvider, + Sidebar, + SidebarHeader, + SidebarContent, + SidebarFooter, + SidebarMenu, + SidebarMenuItem, + SidebarMenuButton, + SidebarGroup, + SidebarGroupLabel, + SidebarGroupContent, + SidebarInset, + SidebarTrigger, + useSidebar, + Button, + Input, + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + Avatar, + AvatarFallback, + ScrollArea, +} from '@itenium-forge/ui'; +import { + LayoutDashboard, + Users, + LogOut, + Sun, + Moon, + Component, + ChevronsUpDown, + Briefcase, + Search, + BookOpen, + GraduationCap, + Award, + Settings, + Library, + TrendingUp, + BarChart3, + ClipboardList, + MessageSquare, + CheckCircle, +} from 'lucide-react'; +import { useAuthStore, useTeamStore, useThemeStore, type Team } from '@/stores'; +import { fetchUserTeams } from '@/api/client'; + +const languages = [ + { code: 'nl', name: 'NL' }, + { code: 'en', name: 'EN' }, +]; + +function TeamSwitcher() { + const { t } = useTranslation(); + const { isMobile } = useSidebar(); + const { user } = useAuthStore(); + const { mode, setMode, selectedTeam, setSelectedTeam, teams } = useTeamStore(); + const [searchQuery, setSearchQuery] = useState(''); + const isBackOffice = user?.isBackOffice ?? false; + const isLearner = !isBackOffice && teams.length === 0; + + const filteredTeams = teams.filter((team) => team.name.toLowerCase().includes(searchQuery.toLowerCase())); + + const getDisplayName = () => { + if (isLearner) return t('app.learner'); + if (mode === 'backoffice') return t('app.backoffice'); + return selectedTeam?.name || ''; + }; + + const getIcon = () => { + if (isLearner) return ; + if (mode === 'backoffice') return ; + return ; + }; + + // Disable switcher if user is learner or only has access to one team and is not backoffice + const canSwitch = !isLearner && (isBackOffice || teams.length > 1); + + const handleSelectBackOffice = () => { + setMode('backoffice'); + setSearchQuery(''); + }; + + const handleSelectTeam = (team: Team) => { + setMode('manager'); + setSelectedTeam(team); + setSearchQuery(''); + }; + + const buttonContent = ( + <> +
+ {getIcon()} +
+
+ {t('app.title')} + {getDisplayName()} +
+ {canSwitch && } + + ); + + // If user cannot switch (learner or single team), show static display without dropdown + if (!canSwitch) { + return ( + + + + {buttonContent} + + + + ); + } + + return ( + + + + + + {buttonContent} + + + +
+
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + className="pl-8 h-8" + /> +
+
+ + {isBackOffice && ( + <> + +
+ +
+ {t('app.backoffice')} + {mode === 'backoffice' && ( + {t('common.active')} + )} +
+ + + )} + {isBackOffice && ( + {t('app.teams')} + )} + + {filteredTeams.map((team) => ( + handleSelectTeam(team)} className="gap-2 p-2"> +
+ +
+ {team.name} + {mode === 'manager' && selectedTeam?.id === team.id && ( + {t('common.active')} + )} +
+ ))} + {filteredTeams.length === 0 && ( +
{t('common.noResults')}
+ )} +
+
+
+
+
+ ); +} + +export function Layout() { + const { t, i18n } = useTranslation(); + const router = useRouter(); + const { user, logout } = useAuthStore(); + const { resolvedTheme, setTheme } = useThemeStore(); + const { mode, setTeams } = useTeamStore(); + + const isBackOffice = user?.isBackOffice ?? false; + + // Fetch teams on mount + const { data: teams } = useQuery({ + queryKey: ['teams'], + queryFn: fetchUserTeams, + }); + + useEffect(() => { + if (teams) { + setTeams(teams, isBackOffice); + } + }, [teams, isBackOffice, setTeams]); + + const handleLogout = () => { + logout(); + router.navigate({ to: '/sign-in' }); + }; + + // Determine if user is learner only (no team management access) + const isLearnerOnly = !isBackOffice && (!teams || teams.length === 0); + + // Dashboard - always shown + const dashboardItem = { path: '/', icon: LayoutDashboard, label: t('nav.dashboard') }; + + // My Learning section - shown for learners and managers + const myLearningNavItems = [ + { path: '/my-courses', icon: BookOpen, label: t('nav.myCourses') }, + { path: '/my-progress', icon: TrendingUp, label: t('nav.myProgress') }, + { path: '/my-certificates', icon: Award, label: t('nav.myCertificates') }, + ]; + + // Catalog - shown for all users + const catalogNavItems = [{ path: '/catalog', icon: Library, label: t('nav.catalog') }]; + + // Team section - shown for managers + const teamNavItems = [ + { path: '/team/members', icon: Users, label: t('nav.teamMembers') }, + { path: '/team/progress', icon: BarChart3, label: t('nav.teamProgress') }, + { path: '/team/assignments', icon: ClipboardList, label: t('nav.assignments') }, + ]; + + // Courses management - shown for managers + const coursesNavItems = [{ path: '/courses', icon: BookOpen, label: t('nav.courses') }]; + + // Administration - shown for backoffice + const adminNavItems = [ + { path: '/admin/users', icon: Users, label: t('nav.users') }, + { path: '/admin/teams', icon: Component, label: t('nav.teams') }, + ]; + + // Reports - shown for backoffice + const reportsNavItems = [ + { path: '/reports/usage', icon: BarChart3, label: t('nav.usage') }, + { path: '/reports/completion', icon: CheckCircle, label: t('nav.completion') }, + { path: '/reports/feedback', icon: MessageSquare, label: t('nav.feedback') }, + ]; + + return ( + + + + + + + + {/* Dashboard - always shown */} + + + + + + + + {dashboardItem.label} + + + + + + + + {/* My Learning - shown for learners only */} + {isLearnerOnly && ( + + {t('nav.myLearning')} + + + {myLearningNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + )} + + {/* Catalog - shown for all users */} + + {t('nav.catalogSection')} + + + {catalogNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + + {/* Team - shown for managers (not learners) */} + {mode === 'manager' && !isLearnerOnly && ( + + {t('nav.team')} + + + {teamNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + )} + + {/* Courses management - shown for managers (not learners) */} + {mode === 'manager' && !isLearnerOnly && ( + + {t('nav.coursesSection')} + + + {coursesNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + )} + + {/* Administration - shown for backoffice */} + {mode === 'backoffice' && ( + + {t('nav.administration')} + + + {adminNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + )} + + {/* Reports - shown for backoffice */} + {mode === 'backoffice' && ( + + {t('nav.reports')} + + + {reportsNavItems.map((item) => ( + + + + + {item.label} + + + + ))} + + + + )} + + + + + + + + + + {user?.name?.charAt(0).toUpperCase() || 'U'} + + {user?.name || t('common.user')} + + + + + + + {t('nav.settings')} + + + + + + {t('nav.signOut')} + + + + + + + + + +
+
+ +
+ +
+ {/* Language Switcher */} +
+ {languages.map((lang) => ( + + ))} +
+ + {/* Theme Toggle */} + +
+
+ +
+ +
+
+
+ ); +} diff --git a/Itenium.SkillForge/frontend/src/components/__tests__/Layout.test.tsx b/Itenium.SkillForge/frontend/src/components/__tests__/Layout.test.tsx new file mode 100644 index 0000000..1a30ac6 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/components/__tests__/Layout.test.tsx @@ -0,0 +1,257 @@ +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { vi } from 'vitest'; +import { useAuthStore } from '@/stores/authStore'; +import { useTeamStore } from '@/stores/teamStore'; +import { useThemeStore } from '@/stores/themeStore'; +// eslint-disable-next-line import-x/order -- must come after vi.mock calls +import { Layout } from '../Layout'; + +// Mock react-i18next: return the key as the translation +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string) => key, + i18n: { language: 'en', changeLanguage: vi.fn() }, + }), +})); + +// Mock TanStack Router +vi.mock('@tanstack/react-router', () => ({ + Outlet: () =>
, + Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, + useRouter: () => ({ navigate: vi.fn() }), +})); + +// Mock TanStack Query: return empty teams by default +const mockUseQuery = vi.fn().mockReturnValue({ data: undefined }); +vi.mock('@tanstack/react-query', () => ({ + useQuery: (...args: unknown[]) => mockUseQuery(...args), +})); + +// Mock the UI library with minimal stubs +// Note: vi.mock factories are hoisted, so we must define stubs inline +vi.mock('@itenium-forge/ui', () => { + const S = ({ children }: { children?: React.ReactNode }) =>
{children}
; + return { + SidebarProvider: S, + Sidebar: S, + SidebarHeader: S, + SidebarContent: S, + SidebarFooter: S, + SidebarMenu: S, + SidebarMenuItem: S, + SidebarMenuButton: S, + SidebarGroup: S, + SidebarGroupLabel: S, + SidebarGroupContent: S, + SidebarInset: S, + SidebarTrigger: S, + useSidebar: () => ({ isMobile: false }), + Button: S, + Input: () => , + DropdownMenu: S, + DropdownMenuTrigger: S, + DropdownMenuContent: S, + DropdownMenuItem: S, + DropdownMenuLabel: S, + DropdownMenuSeparator: () =>
, + Avatar: S, + AvatarFallback: S, + ScrollArea: S, + }; +}); + +// Mock lucide-react icons +vi.mock('lucide-react', () => { + const I = () => ; + return { + LayoutDashboard: I, + Users: I, + LogOut: I, + Sun: I, + Moon: I, + Component: I, + ChevronsUpDown: I, + Briefcase: I, + Search: I, + BookOpen: I, + GraduationCap: I, + Award: I, + Settings: I, + Library: I, + TrendingUp: I, + BarChart3: I, + ClipboardList: I, + MessageSquare: I, + CheckCircle: I, + }; +}); + +// Mock the API client +vi.mock('@/api/client', () => ({ + fetchUserTeams: vi.fn(), +})); + +function setupStores(options: { + isBackOffice?: boolean; + mode?: 'backoffice' | 'manager'; + teams?: { id: number; name: string }[]; + selectedTeam?: { id: number; name: string } | null; + userName?: string; +}) { + const { + isBackOffice = false, + mode = 'backoffice', + teams = [], + selectedTeam = null, + userName = 'Test User', + } = options; + + useAuthStore.setState({ + accessToken: 'fake-token', + isAuthenticated: true, + user: { + id: 'user-1', + email: 'test@test.com', + name: userName, + isBackOffice, + }, + }); + + useTeamStore.setState({ mode, teams, selectedTeam }); + useThemeStore.setState({ resolvedTheme: 'light', theme: 'light' }); +} + +beforeEach(() => { + useAuthStore.setState({ accessToken: null, user: null, isAuthenticated: false }); + useTeamStore.setState({ mode: 'backoffice', selectedTeam: null, teams: [] }); + mockUseQuery.mockReturnValue({ data: undefined }); + localStorage.clear(); +}); + +describe('Layout', () => { + describe('navigation visibility', () => { + it('always shows Dashboard', () => { + setupStores({ isBackOffice: true }); + render(); + expect(screen.getByText('nav.dashboard')).toBeInTheDocument(); + }); + + it('always shows Catalog section', () => { + setupStores({ isBackOffice: false, teams: [] }); + render(); + expect(screen.getByText('nav.catalogSection')).toBeInTheDocument(); + expect(screen.getByText('nav.catalog')).toBeInTheDocument(); + }); + + it('shows My Learning section for learner-only users', () => { + setupStores({ isBackOffice: false, teams: [] }); + render(); + + expect(screen.getByText('nav.myLearning')).toBeInTheDocument(); + expect(screen.getByText('nav.myCourses')).toBeInTheDocument(); + expect(screen.getByText('nav.myProgress')).toBeInTheDocument(); + expect(screen.getByText('nav.myCertificates')).toBeInTheDocument(); + }); + + it('hides My Learning section for backoffice users', () => { + setupStores({ isBackOffice: true, mode: 'backoffice' }); + render(); + expect(screen.queryByText('nav.myLearning')).not.toBeInTheDocument(); + }); + + it('shows Administration and Reports in backoffice mode', () => { + setupStores({ isBackOffice: true, mode: 'backoffice' }); + render(); + + expect(screen.getByText('nav.administration')).toBeInTheDocument(); + expect(screen.getByText('nav.users')).toBeInTheDocument(); + expect(screen.getByText('nav.teams')).toBeInTheDocument(); + + expect(screen.getByText('nav.reports')).toBeInTheDocument(); + expect(screen.getByText('nav.usage')).toBeInTheDocument(); + expect(screen.getByText('nav.completion')).toBeInTheDocument(); + expect(screen.getByText('nav.feedback')).toBeInTheDocument(); + }); + + it('hides Administration and Reports in manager mode', () => { + setupStores({ + isBackOffice: true, + mode: 'manager', + teams: [{ id: 1, name: 'Team A' }], + selectedTeam: { id: 1, name: 'Team A' }, + }); + render(); + + expect(screen.queryByText('nav.administration')).not.toBeInTheDocument(); + expect(screen.queryByText('nav.reports')).not.toBeInTheDocument(); + }); + + it('shows Team and Courses sections in manager mode', () => { + setupStores({ + isBackOffice: true, + mode: 'manager', + teams: [{ id: 1, name: 'Team A' }], + selectedTeam: { id: 1, name: 'Team A' }, + }); + render(); + + expect(screen.getByText('nav.team')).toBeInTheDocument(); + expect(screen.getByText('nav.teamMembers')).toBeInTheDocument(); + expect(screen.getByText('nav.teamProgress')).toBeInTheDocument(); + expect(screen.getByText('nav.assignments')).toBeInTheDocument(); + + expect(screen.getByText('nav.coursesSection')).toBeInTheDocument(); + expect(screen.getByText('nav.courses')).toBeInTheDocument(); + }); + + it('hides Team and Courses sections for learner-only users', () => { + setupStores({ isBackOffice: false, teams: [] }); + render(); + + expect(screen.queryByText('nav.team')).not.toBeInTheDocument(); + expect(screen.queryByText('nav.coursesSection')).not.toBeInTheDocument(); + }); + }); + + describe('TeamSwitcher', () => { + it('shows "app.learner" for learner-only users', () => { + setupStores({ isBackOffice: false, teams: [] }); + render(); + expect(screen.getByText('app.learner')).toBeInTheDocument(); + }); + + it('shows "app.backoffice" in backoffice mode', () => { + setupStores({ isBackOffice: true, mode: 'backoffice' }); + render(); + // Appears in both the switcher button and the dropdown option + expect(screen.getAllByText('app.backoffice').length).toBeGreaterThanOrEqual(1); + }); + + it('shows selected team name in manager mode', () => { + setupStores({ + isBackOffice: true, + mode: 'manager', + teams: [{ id: 1, name: 'Team Alpha' }], + selectedTeam: { id: 1, name: 'Team Alpha' }, + }); + render(); + // Appears in both the switcher button and the team list + expect(screen.getAllByText('Team Alpha').length).toBeGreaterThanOrEqual(1); + }); + }); + + describe('user menu', () => { + it('shows the user name', () => { + setupStores({ userName: 'Alice' }); + render(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + }); + + it('shows the first letter as avatar fallback', () => { + setupStores({ userName: 'Bob' }); + render(); + expect(screen.getByText('B')).toBeInTheDocument(); + }); + }); +}); diff --git a/Itenium.SkillForge/frontend/src/i18n/index.ts b/Itenium.SkillForge/frontend/src/i18n/index.ts new file mode 100644 index 0000000..f553c76 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/i18n/index.ts @@ -0,0 +1,18 @@ +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import nl from './locales/nl.json'; +import en from './locales/en.json'; + +i18n.use(initReactI18next).init({ + resources: { + nl: { translation: nl }, + en: { translation: en }, + }, + lng: localStorage.getItem('language') || 'nl', + fallbackLng: 'en', + interpolation: { + escapeValue: false, + }, +}); + +export default i18n; diff --git a/Itenium.SkillForge/frontend/src/i18n/locales/en.json b/Itenium.SkillForge/frontend/src/i18n/locales/en.json new file mode 100644 index 0000000..416fbaa --- /dev/null +++ b/Itenium.SkillForge/frontend/src/i18n/locales/en.json @@ -0,0 +1,112 @@ +{ + "app": { + "title": "Itenium SkillForge", + "backoffice": "BackOffice", + "teams": "Teams", + "learner": "Learner" + }, + "nav": { + "navigation": "Navigation", + "dashboard": "Dashboard", + "courses": "Courses", + "learners": "Learners", + "settings": "Settings", + "signOut": "Sign Out", + "admin": "Admin", + "users": "Users", + "teams": "Teams", + "operations": "Operations", + "enrollments": "Enrollments", + "progress": "Progress", + "certificates": "Certificates", + "myLearning": "My Learning", + "myCourses": "My Courses", + "myProgress": "My Progress", + "myCertificates": "My Certificates", + "catalogSection": "Catalog", + "catalog": "Browse Courses", + "team": "Team", + "teamMembers": "Members", + "teamProgress": "Progress", + "assignments": "Assignments", + "coursesSection": "Courses", + "administration": "Administration", + "reports": "Reports", + "usage": "Usage", + "completion": "Completion", + "feedback": "Feedback", + "settingsSection": "Settings", + "certificateTemplates": "Certificate Templates" + }, + "auth": { + "signIn": "Sign In", + "signOut": "Sign Out", + "username": "Username", + "password": "Password", + "forgotPassword": "Forgot password?", + "enterUsername": "Enter your username", + "enterPassword": "Enter your password", + "welcome": "Welcome", + "signInDescription": "Enter your credentials to sign in", + "invalidCredentials": "Invalid username or password", + "usernameRequired": "Username is required", + "passwordRequired": "Password is required", + "loginBackground": "Login background" + }, + "dashboard": { + "title": "Dashboard", + "welcome": "Welcome to Itenium SkillForge", + "totalCourses": "Total Courses", + "activeLearners": "Active Learners", + "completedCourses": "Completed Courses" + }, + "courses": { + "title": "Courses", + "name": "Name", + "description": "Description", + "category": "Category", + "level": "Level", + "status": "Status", + "active": "Active", + "inactive": "Inactive", + "actions": "Actions", + "addCourse": "Add Course", + "editCourse": "Edit Course", + "deleteCourse": "Delete Course", + "noCourses": "No courses found" + }, + "learners": { + "title": "Learners", + "name": "Name", + "email": "Email", + "progress": "Progress", + "noLearners": "No learners found" + }, + "settings": { + "title": "Settings", + "appearance": "Appearance", + "language": "Language", + "theme": "Theme", + "light": "Light", + "dark": "Dark", + "system": "System", + "save": "Save" + }, + "common": { + "loading": "Loading...", + "error": "An error occurred", + "save": "Save", + "cancel": "Cancel", + "delete": "Delete", + "edit": "Edit", + "search": "Search", + "filter": "Filter", + "noResults": "No results found", + "active": "Active", + "user": "User" + }, + "errors": { + "sessionExpired": "Session expired!", + "internalServerError": "Internal Server Error!" + } +} diff --git a/Itenium.SkillForge/frontend/src/i18n/locales/nl.json b/Itenium.SkillForge/frontend/src/i18n/locales/nl.json new file mode 100644 index 0000000..a3d8fb5 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/i18n/locales/nl.json @@ -0,0 +1,112 @@ +{ + "app": { + "title": "Itenium SkillForge", + "backoffice": "BackOffice", + "teams": "Teams", + "learner": "Leerling" + }, + "nav": { + "navigation": "Navigatie", + "dashboard": "Dashboard", + "courses": "Cursussen", + "learners": "Leerlingen", + "settings": "Instellingen", + "signOut": "Uitloggen", + "admin": "Admin", + "users": "Gebruikers", + "teams": "Teams", + "operations": "Operaties", + "enrollments": "Inschrijvingen", + "progress": "Voortgang", + "certificates": "Certificaten", + "myLearning": "Mijn Leertraject", + "myCourses": "Mijn Cursussen", + "myProgress": "Mijn Voortgang", + "myCertificates": "Mijn Certificaten", + "catalogSection": "Catalogus", + "catalog": "Cursussen Bekijken", + "team": "Team", + "teamMembers": "Leden", + "teamProgress": "Voortgang", + "assignments": "Toewijzingen", + "coursesSection": "Cursussen", + "administration": "Administratie", + "reports": "Rapporten", + "usage": "Gebruik", + "completion": "Voltooiing", + "feedback": "Feedback", + "settingsSection": "Instellingen", + "certificateTemplates": "Certificaat Sjablonen" + }, + "auth": { + "signIn": "Inloggen", + "signOut": "Uitloggen", + "username": "Gebruikersnaam", + "password": "Wachtwoord", + "forgotPassword": "Wachtwoord vergeten?", + "enterUsername": "Voer uw gebruikersnaam in", + "enterPassword": "Voer uw wachtwoord in", + "welcome": "Welkom", + "signInDescription": "Voer uw gegevens in om in te loggen", + "invalidCredentials": "Ongeldige gebruikersnaam of wachtwoord", + "usernameRequired": "Gebruikersnaam is verplicht", + "passwordRequired": "Wachtwoord is verplicht", + "loginBackground": "Login achtergrond" + }, + "dashboard": { + "title": "Dashboard", + "welcome": "Welkom bij Itenium SkillForge", + "totalCourses": "Totaal Cursussen", + "activeLearners": "Actieve Leerlingen", + "completedCourses": "Voltooide Cursussen" + }, + "courses": { + "title": "Cursussen", + "name": "Naam", + "description": "Beschrijving", + "category": "Categorie", + "level": "Niveau", + "status": "Status", + "active": "Actief", + "inactive": "Inactief", + "actions": "Acties", + "addCourse": "Cursus Toevoegen", + "editCourse": "Cursus Bewerken", + "deleteCourse": "Cursus Verwijderen", + "noCourses": "Geen cursussen gevonden" + }, + "learners": { + "title": "Leerlingen", + "name": "Naam", + "email": "E-mail", + "progress": "Voortgang", + "noLearners": "Geen leerlingen gevonden" + }, + "settings": { + "title": "Instellingen", + "appearance": "Uiterlijk", + "language": "Taal", + "theme": "Thema", + "light": "Licht", + "dark": "Donker", + "system": "Systeem", + "save": "Opslaan" + }, + "common": { + "loading": "Laden...", + "error": "Er is een fout opgetreden", + "save": "Opslaan", + "cancel": "Annuleren", + "delete": "Verwijderen", + "edit": "Bewerken", + "search": "Zoeken", + "filter": "Filteren", + "noResults": "Geen resultaten gevonden", + "active": "Actief", + "user": "Gebruiker" + }, + "errors": { + "sessionExpired": "Sessie verlopen!", + "internalServerError": "Interne serverfout!" + } +} diff --git a/Itenium.SkillForge/frontend/src/main.tsx b/Itenium.SkillForge/frontend/src/main.tsx new file mode 100644 index 0000000..1656ffb --- /dev/null +++ b/Itenium.SkillForge/frontend/src/main.tsx @@ -0,0 +1,79 @@ +import { StrictMode } from 'react'; +import ReactDOM from 'react-dom/client'; +import { AxiosError } from 'axios'; +import { QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { RouterProvider, createRouter } from '@tanstack/react-router'; +import { toast } from 'sonner'; +import { routeTree } from './routeTree.gen'; +import './styles.css'; +import i18n from './i18n'; +import { useAuthStore } from '@/stores'; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: (failureCount, error) => { + // eslint-disable-next-line no-console + if (import.meta.env.DEV) console.log({ failureCount, error }); + + if (failureCount >= 0 && import.meta.env.DEV) return false; + if (failureCount > 3 && import.meta.env.PROD) return false; + + return !(error instanceof AxiosError && [401, 403].includes(error.response?.status ?? 0)); + }, + refetchOnWindowFocus: import.meta.env.PROD, + staleTime: 10 * 1000, // 10s + }, + mutations: { + onError: (error) => { + if (error instanceof AxiosError) { + const message = error.response?.data?.error_description || error.response?.data?.message || error.message; + toast.error(message); + } + }, + }, + }, + queryCache: new QueryCache({ + onError: (error) => { + if (error instanceof AxiosError) { + if (error.response?.status === 401) { + toast.error(i18n.t('errors.sessionExpired')); + useAuthStore.getState().logout(); + router.navigate({ to: '/sign-in', search: { redirect: router.history.location.href } }); + } + if (error.response?.status === 500) { + toast.error(i18n.t('errors.internalServerError')); + } + } + }, + }), +}); + +// Create a new router instance +const router = createRouter({ + routeTree, + context: { queryClient }, + defaultPreload: 'intent', + defaultPreloadStaleTime: 0, +}); + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: typeof router; + } +} + +// Render the app +// eslint-disable-next-line @typescript-eslint/no-non-null-assertion +const rootElement = document.getElementById('root')!; +if (!rootElement.innerHTML) { + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + , + ); +} diff --git a/Itenium.SkillForge/frontend/src/pages/Courses.tsx b/Itenium.SkillForge/frontend/src/pages/Courses.tsx new file mode 100644 index 0000000..3ad3216 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/pages/Courses.tsx @@ -0,0 +1,54 @@ +import { useTranslation } from 'react-i18next'; +import { useQuery } from '@tanstack/react-query'; +import { fetchCourses } from '@/api/client'; + +export function Courses() { + const { t } = useTranslation(); + + const { data: courses, isLoading } = useQuery({ + queryKey: ['courses'], + queryFn: fetchCourses, + }); + + if (isLoading) { + return
{t('common.loading')}
; + } + + return ( +
+
+

{t('courses.title')}

+
+ +
+ + + + + + + + + + + {courses?.map((course) => ( + + + + + + + ))} + {courses?.length === 0 && ( + + + + )} + +
{t('courses.name')}{t('courses.description')}{t('courses.category')}{t('courses.level')}
{course.name}{course.description || '-'}{course.category || '-'}{course.level || '-'}
+ {t('courses.noCourses')} +
+
+
+ ); +} diff --git a/Itenium.SkillForge/frontend/src/pages/Dashboard.tsx b/Itenium.SkillForge/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..9f4479a --- /dev/null +++ b/Itenium.SkillForge/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next'; +import { BookOpen, Users, Award } from 'lucide-react'; +import { Card, CardHeader, CardTitle, CardContent } from '@itenium-forge/ui'; +import { useTeamStore } from '@/stores'; + +export function Dashboard() { + const { t } = useTranslation(); + const { mode, selectedTeam } = useTeamStore(); + + return ( +
+
+

{t('dashboard.title')}

+

+ {t('dashboard.welcome')} + {mode === 'manager' && selectedTeam && ` - ${selectedTeam.name}`} +

+
+ +
+ + + {t('dashboard.totalCourses')} + + + +
24
+

+3 from last month

+
+
+ + + + {t('dashboard.activeLearners')} + + + +
156
+

Active this month

+
+
+ + + + {t('dashboard.completedCourses')} + + + +
89
+

Certificates issued

+
+
+
+
+ ); +} diff --git a/Itenium.SkillForge/frontend/src/pages/Settings.tsx b/Itenium.SkillForge/frontend/src/pages/Settings.tsx new file mode 100644 index 0000000..5253bcb --- /dev/null +++ b/Itenium.SkillForge/frontend/src/pages/Settings.tsx @@ -0,0 +1,14 @@ +import { useTranslation } from 'react-i18next'; + +export function Settings() { + const { t } = useTranslation(); + + return ( +
+
+

{t('settings.title')}

+
+

Settings page coming soon...

+
+ ); +} diff --git a/Itenium.SkillForge/frontend/src/pages/SignIn.tsx b/Itenium.SkillForge/frontend/src/pages/SignIn.tsx new file mode 100644 index 0000000..ff77c11 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/pages/SignIn.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { useRouter, useSearch } from '@tanstack/react-router'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { LogIn, Loader2 } from 'lucide-react'; +import { + Button, + Input, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, + CardFooter, +} from '@itenium-forge/ui'; +import { useAuthStore } from '@/stores'; +import { loginApi } from '@/api/client'; + +const createFormSchema = (t: (key: string) => string) => + z.object({ + username: z.string().min(1, t('auth.usernameRequired')), + password: z.string().min(1, t('auth.passwordRequired')), + }); + +type FormData = z.infer>; + +const testUsers = [ + { username: 'backoffice', password: 'AdminPassword123!' }, + { username: 'java', password: 'UserPassword123!' }, + { username: 'dotnet', password: 'UserPassword123!' }, + { username: 'multi', password: 'UserPassword123!' }, + { username: 'learner', password: 'UserPassword123!' }, +]; + +export function SignIn() { + const { t } = useTranslation(); + const formSchema = createFormSchema(t); + const router = useRouter(); + const search = useSearch({ from: '/(auth)/sign-in' }); + const { setToken } = useAuthStore(); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const form = useForm({ + resolver: zodResolver(formSchema), + defaultValues: { + username: '', + password: '', + }, + }); + + const onSubmit = async (data: FormData) => { + setIsLoading(true); + setError(null); + try { + const response = await loginApi(data.username, data.password); + setToken(response.access_token); + + // Navigate to redirect URL or home + const redirectTo = search.redirect || '/'; + router.navigate({ to: redirectTo }); + } catch (err: unknown) { + if (err && typeof err === 'object' && 'response' in err) { + const axiosError = err as { response?: { data?: { error_description?: string } } }; + setError(axiosError.response?.data?.error_description || t('auth.invalidCredentials')); + } else { + setError(t('auth.invalidCredentials')); + } + } finally { + setIsLoading(false); + } + }; + + const fillTestUser = (username: string, password: string) => { + form.setValue('username', username); + form.setValue('password', password); + }; + + return ( +
+ {/* Left side - Image panel */} +
+ {t('auth.loginBackground')} +
+
+ {t('app.title')} + {t('app.title')} +
+
+
+

+ + "Empower your team with continuous learning. Track progress, manage courses, and build skills together." + +

+
Steven Robijns
+
+
+
+ + {/* Right side - Login form */} +
+ + {/* Mobile logo */} +
+ {t('app.title')} + {t('app.title')} +
+ + + {t('auth.welcome')} + {t('auth.signInDescription')} + + + +
+ + {error &&
{error}
} + ( + + {t('auth.username')} + + + + + + )} + /> + ( + + {t('auth.password')} + + + + + + )} + /> + + + +
+ + +
Test users:
+
+ {testUsers.map((user) => ( + + ))} +
+
Passwords: AdminPassword123! (backoffice) / UserPassword123! (others)
+
+
+
+
+ ); +} diff --git a/Itenium.SkillForge/frontend/src/routeTree.gen.ts b/Itenium.SkillForge/frontend/src/routeTree.gen.ts new file mode 100644 index 0000000..a3d06d6 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routeTree.gen.ts @@ -0,0 +1,143 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +import { Route as rootRouteImport } from './routes/__root' +import { Route as AuthenticatedRouteRouteImport } from './routes/_authenticated/route' +import { Route as AuthenticatedIndexRouteImport } from './routes/_authenticated/index' +import { Route as AuthenticatedSettingsRouteImport } from './routes/_authenticated/settings' +import { Route as AuthenticatedCoursesRouteImport } from './routes/_authenticated/courses' +import { Route as authSignInRouteImport } from './routes/(auth)/sign-in' + +const AuthenticatedRouteRoute = AuthenticatedRouteRouteImport.update({ + id: '/_authenticated', + getParentRoute: () => rootRouteImport, +} as any) +const AuthenticatedIndexRoute = AuthenticatedIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => AuthenticatedRouteRoute, +} as any) +const AuthenticatedSettingsRoute = AuthenticatedSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AuthenticatedRouteRoute, +} as any) +const AuthenticatedCoursesRoute = AuthenticatedCoursesRouteImport.update({ + id: '/courses', + path: '/courses', + getParentRoute: () => AuthenticatedRouteRoute, +} as any) +const authSignInRoute = authSignInRouteImport.update({ + id: '/(auth)/sign-in', + path: '/sign-in', + getParentRoute: () => rootRouteImport, +} as any) + +export interface FileRoutesByFullPath { + '/sign-in': typeof authSignInRoute + '/courses': typeof AuthenticatedCoursesRoute + '/settings': typeof AuthenticatedSettingsRoute + '/': typeof AuthenticatedIndexRoute +} +export interface FileRoutesByTo { + '/sign-in': typeof authSignInRoute + '/courses': typeof AuthenticatedCoursesRoute + '/settings': typeof AuthenticatedSettingsRoute + '/': typeof AuthenticatedIndexRoute +} +export interface FileRoutesById { + __root__: typeof rootRouteImport + '/_authenticated': typeof AuthenticatedRouteRouteWithChildren + '/(auth)/sign-in': typeof authSignInRoute + '/_authenticated/courses': typeof AuthenticatedCoursesRoute + '/_authenticated/settings': typeof AuthenticatedSettingsRoute + '/_authenticated/': typeof AuthenticatedIndexRoute +} +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/sign-in' | '/courses' | '/settings' | '/' + fileRoutesByTo: FileRoutesByTo + to: '/sign-in' | '/courses' | '/settings' | '/' + id: + | '__root__' + | '/_authenticated' + | '/(auth)/sign-in' + | '/_authenticated/courses' + | '/_authenticated/settings' + | '/_authenticated/' + fileRoutesById: FileRoutesById +} +export interface RootRouteChildren { + AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren + authSignInRoute: typeof authSignInRoute +} + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/_authenticated': { + id: '/_authenticated' + path: '' + fullPath: '' + preLoaderRoute: typeof AuthenticatedRouteRouteImport + parentRoute: typeof rootRouteImport + } + '/_authenticated/': { + id: '/_authenticated/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof AuthenticatedIndexRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } + '/_authenticated/settings': { + id: '/_authenticated/settings' + path: '/settings' + fullPath: '/settings' + preLoaderRoute: typeof AuthenticatedSettingsRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } + '/_authenticated/courses': { + id: '/_authenticated/courses' + path: '/courses' + fullPath: '/courses' + preLoaderRoute: typeof AuthenticatedCoursesRouteImport + parentRoute: typeof AuthenticatedRouteRoute + } + '/(auth)/sign-in': { + id: '/(auth)/sign-in' + path: '/sign-in' + fullPath: '/sign-in' + preLoaderRoute: typeof authSignInRouteImport + parentRoute: typeof rootRouteImport + } + } +} + +interface AuthenticatedRouteRouteChildren { + AuthenticatedCoursesRoute: typeof AuthenticatedCoursesRoute + AuthenticatedSettingsRoute: typeof AuthenticatedSettingsRoute + AuthenticatedIndexRoute: typeof AuthenticatedIndexRoute +} + +const AuthenticatedRouteRouteChildren: AuthenticatedRouteRouteChildren = { + AuthenticatedCoursesRoute: AuthenticatedCoursesRoute, + AuthenticatedSettingsRoute: AuthenticatedSettingsRoute, + AuthenticatedIndexRoute: AuthenticatedIndexRoute, +} + +const AuthenticatedRouteRouteWithChildren = + AuthenticatedRouteRoute._addFileChildren(AuthenticatedRouteRouteChildren) + +const rootRouteChildren: RootRouteChildren = { + AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren, + authSignInRoute: authSignInRoute, +} +export const routeTree = rootRouteImport + ._addFileChildren(rootRouteChildren) + ._addFileTypes() diff --git a/Itenium.SkillForge/frontend/src/routes/(auth)/sign-in.tsx b/Itenium.SkillForge/frontend/src/routes/(auth)/sign-in.tsx new file mode 100644 index 0000000..885c022 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/(auth)/sign-in.tsx @@ -0,0 +1,19 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { z } from 'zod'; +import { SignIn } from '@/pages/SignIn'; +import { useAuthStore } from '@/stores'; + +const searchSchema = z.object({ + redirect: z.string().optional(), +}); + +export const Route = createFileRoute('/(auth)/sign-in')({ + component: SignIn, + validateSearch: searchSchema, + beforeLoad: () => { + const { isAuthenticated } = useAuthStore.getState(); + if (isAuthenticated) { + throw redirect({ to: '/' }); + } + }, +}); diff --git a/Itenium.SkillForge/frontend/src/routes/__root.tsx b/Itenium.SkillForge/frontend/src/routes/__root.tsx new file mode 100644 index 0000000..c2aecab --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/__root.tsx @@ -0,0 +1,16 @@ +import { type QueryClient } from '@tanstack/react-query'; +import { createRootRouteWithContext, Outlet } from '@tanstack/react-router'; +import { Toaster } from 'sonner'; + +export const Route = createRootRouteWithContext<{ + queryClient: QueryClient; +}>()({ + component: () => { + return ( + <> + + + + ); + }, +}); diff --git a/Itenium.SkillForge/frontend/src/routes/_authenticated/courses.tsx b/Itenium.SkillForge/frontend/src/routes/_authenticated/courses.tsx new file mode 100644 index 0000000..85de92d --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/_authenticated/courses.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { Courses } from '@/pages/Courses'; + +export const Route = createFileRoute('/_authenticated/courses')({ + component: Courses, +}); diff --git a/Itenium.SkillForge/frontend/src/routes/_authenticated/index.tsx b/Itenium.SkillForge/frontend/src/routes/_authenticated/index.tsx new file mode 100644 index 0000000..537afc9 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/_authenticated/index.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { Dashboard } from '@/pages/Dashboard'; + +export const Route = createFileRoute('/_authenticated/')({ + component: Dashboard, +}); diff --git a/Itenium.SkillForge/frontend/src/routes/_authenticated/route.tsx b/Itenium.SkillForge/frontend/src/routes/_authenticated/route.tsx new file mode 100644 index 0000000..965ac97 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/_authenticated/route.tsx @@ -0,0 +1,18 @@ +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { Layout } from '@/components/Layout'; +import { useAuthStore } from '@/stores'; + +export const Route = createFileRoute('/_authenticated')({ + component: Layout, + beforeLoad: ({ location }) => { + const { isAuthenticated } = useAuthStore.getState(); + if (!isAuthenticated) { + throw redirect({ + to: '/sign-in', + search: { + redirect: location.href, + }, + }); + } + }, +}); diff --git a/Itenium.SkillForge/frontend/src/routes/_authenticated/settings.tsx b/Itenium.SkillForge/frontend/src/routes/_authenticated/settings.tsx new file mode 100644 index 0000000..e770d23 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/routes/_authenticated/settings.tsx @@ -0,0 +1,6 @@ +import { createFileRoute } from '@tanstack/react-router'; +import { Settings } from '@/pages/Settings'; + +export const Route = createFileRoute('/_authenticated/settings')({ + component: Settings, +}); diff --git a/Itenium.SkillForge/frontend/src/stores/__tests__/authStore.test.ts b/Itenium.SkillForge/frontend/src/stores/__tests__/authStore.test.ts new file mode 100644 index 0000000..1ca484d --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/__tests__/authStore.test.ts @@ -0,0 +1,132 @@ +import { useAuthStore } from '../authStore'; + +/** Create a JWT with the given payload (signature is not verified by jwt-decode) */ +function createToken(payload: Record): string { + const header = btoa(JSON.stringify({ alg: 'none', typ: 'JWT' })); + const body = btoa(JSON.stringify(payload)); + return `${header}.${body}.sig`; +} + +function resetStore() { + useAuthStore.setState({ + accessToken: null, + user: null, + isAuthenticated: false, + }); + localStorage.clear(); +} + +beforeEach(() => { + resetStore(); +}); + +describe('useAuthStore', () => { + describe('setToken', () => { + it('sets user from a token with all fields', () => { + const token = createToken({ + sub: 'user-123', + name: 'Alice', + email: 'alice@example.com', + role: 'backoffice', + exp: Math.floor(Date.now() / 1000) + 3600, + }); + + useAuthStore.getState().setToken(token); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(true); + expect(state.accessToken).toBe(token); + expect(state.user).toEqual({ + id: 'user-123', + name: 'Alice', + email: 'alice@example.com', + isBackOffice: true, + }); + }); + + it('handles role as an array', () => { + const token = createToken({ + sub: 'user-456', + name: 'Bob', + email: 'bob@example.com', + role: ['manager', 'backoffice'], + }); + + useAuthStore.getState().setToken(token); + + expect(useAuthStore.getState().user?.isBackOffice).toBe(true); + }); + + it('sets isBackOffice to false when role does not include backoffice', () => { + const token = createToken({ + sub: 'user-789', + name: 'Charlie', + email: 'charlie@example.com', + role: 'manager', + }); + + useAuthStore.getState().setToken(token); + + expect(useAuthStore.getState().user?.isBackOffice).toBe(false); + }); + + it('sets isBackOffice to false when role is missing', () => { + const token = createToken({ + sub: 'user-000', + name: 'Dave', + email: 'dave@example.com', + }); + + useAuthStore.getState().setToken(token); + + expect(useAuthStore.getState().user?.isBackOffice).toBe(false); + }); + + it('falls back to preferred_username for email and name', () => { + const token = createToken({ + sub: 'user-111', + preferred_username: 'jane.doe', + }); + + useAuthStore.getState().setToken(token); + + const user = useAuthStore.getState().user; + expect(user?.email).toBe('jane.doe'); + expect(user?.name).toBe('jane.doe'); + }); + + it('falls back to defaults when no name or username', () => { + const token = createToken({ + sub: 'user-222', + }); + + useAuthStore.getState().setToken(token); + + const user = useAuthStore.getState().user; + expect(user?.email).toBe(''); + expect(user?.name).toBe('User'); + }); + }); + + describe('logout', () => { + it('clears auth state', () => { + const token = createToken({ sub: 'user-1', name: 'Test', email: 'test@test.com' }); + useAuthStore.getState().setToken(token); + + useAuthStore.getState().logout(); + + const state = useAuthStore.getState(); + expect(state.isAuthenticated).toBe(false); + expect(state.accessToken).toBeNull(); + expect(state.user).toBeNull(); + }); + + it('removes team-storage from localStorage', () => { + localStorage.setItem('team-storage', JSON.stringify({ mode: 'manager' })); + + useAuthStore.getState().logout(); + + expect(localStorage.getItem('team-storage')).toBeNull(); + }); + }); +}); diff --git a/Itenium.SkillForge/frontend/src/stores/__tests__/teamStore.test.ts b/Itenium.SkillForge/frontend/src/stores/__tests__/teamStore.test.ts new file mode 100644 index 0000000..2d5dbee --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/__tests__/teamStore.test.ts @@ -0,0 +1,106 @@ +import { useTeamStore, type Team } from '../teamStore'; + +const teamA: Team = { id: 1, name: 'Team Alpha' }; +const teamB: Team = { id: 2, name: 'Team Beta' }; +const teamC: Team = { id: 3, name: 'Team Charlie' }; + +function resetStore() { + useTeamStore.setState({ + mode: 'backoffice', + selectedTeam: null, + teams: [], + }); + localStorage.clear(); +} + +beforeEach(() => { + resetStore(); +}); + +describe('useTeamStore', () => { + describe('setMode', () => { + it('switches mode', () => { + useTeamStore.getState().setMode('manager'); + expect(useTeamStore.getState().mode).toBe('manager'); + + useTeamStore.getState().setMode('backoffice'); + expect(useTeamStore.getState().mode).toBe('backoffice'); + }); + }); + + describe('setSelectedTeam', () => { + it('sets the selected team', () => { + useTeamStore.getState().setSelectedTeam(teamA); + expect(useTeamStore.getState().selectedTeam).toEqual(teamA); + }); + + it('clears the selected team with null', () => { + useTeamStore.getState().setSelectedTeam(teamA); + useTeamStore.getState().setSelectedTeam(null); + expect(useTeamStore.getState().selectedTeam).toBeNull(); + }); + }); + + describe('setTeams (backoffice user)', () => { + it('sets teams without changing mode or selectedTeam', () => { + useTeamStore.getState().setTeams([teamA, teamB], true); + + const state = useTeamStore.getState(); + expect(state.teams).toEqual([teamA, teamB]); + expect(state.mode).toBe('backoffice'); + expect(state.selectedTeam).toBeNull(); + }); + }); + + describe('setTeams (non-backoffice user)', () => { + it('switches to manager mode and selects the first team', () => { + useTeamStore.getState().setTeams([teamA, teamB], false); + + const state = useTeamStore.getState(); + expect(state.mode).toBe('manager'); + expect(state.selectedTeam).toEqual(teamA); + expect(state.teams).toEqual([teamA, teamB]); + }); + + it('keeps the previously selected team if it still exists in the list', () => { + useTeamStore.setState({ selectedTeam: teamB }); + + useTeamStore.getState().setTeams([teamA, teamB, teamC], false); + + expect(useTeamStore.getState().selectedTeam).toEqual(teamB); + }); + + it('falls back to first team when previously selected team is no longer in the list', () => { + useTeamStore.setState({ selectedTeam: teamC }); + + useTeamStore.getState().setTeams([teamA, teamB], false); + + expect(useTeamStore.getState().selectedTeam).toEqual(teamA); + }); + + it('sets selectedTeam to null when teams list is empty', () => { + useTeamStore.getState().setTeams([], false); + + const state = useTeamStore.getState(); + expect(state.mode).toBe('manager'); + expect(state.selectedTeam).toBeNull(); + }); + }); + + describe('reset', () => { + it('resets to initial state', () => { + useTeamStore.setState({ + mode: 'manager', + selectedTeam: teamA, + teams: [teamA, teamB], + }); + + useTeamStore.getState().reset(); + + const state = useTeamStore.getState(); + expect(state.mode).toBe('backoffice'); + expect(state.selectedTeam).toBeNull(); + expect(state.teams).toEqual([]); + }); + }); +}); diff --git a/Itenium.SkillForge/frontend/src/stores/authStore.ts b/Itenium.SkillForge/frontend/src/stores/authStore.ts new file mode 100644 index 0000000..5bac6fb --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/authStore.ts @@ -0,0 +1,86 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { jwtDecode } from 'jwt-decode'; + +interface JwtPayload { + sub: string; + name?: string; + preferred_username?: string; + email?: string; + role?: string | string[]; + exp?: number; +} + +interface User { + id: string; + email: string; + name: string; + isBackOffice: boolean; +} + +interface AuthState { + accessToken: string | null; + user: User | null; + isAuthenticated: boolean; + setToken: (token: string) => void; + logout: () => void; +} + +function parseUserFromToken(token: string): User { + const decoded = jwtDecode(token); + const roles = Array.isArray(decoded.role) ? decoded.role : decoded.role ? [decoded.role] : []; + return { + id: decoded.sub, + email: decoded.email || decoded.preferred_username || '', + name: decoded.name || decoded.preferred_username || 'User', + isBackOffice: roles.includes('backoffice'), + }; +} + +function isTokenExpired(token: string): boolean { + try { + const decoded = jwtDecode(token); + if (!decoded.exp) return false; + return decoded.exp * 1000 < Date.now(); + } catch { + return true; + } +} + +export const useAuthStore = create()( + persist( + (set) => ({ + accessToken: null, + user: null, + isAuthenticated: false, + + setToken: (token: string) => { + const user = parseUserFromToken(token); + set({ + accessToken: token, + user, + isAuthenticated: true, + }); + }, + + logout: () => { + set({ + accessToken: null, + user: null, + isAuthenticated: false, + }); + // Clear team store on logout + localStorage.removeItem('team-storage'); + }, + }), + { + name: 'auth-storage', + onRehydrateStorage: () => (state) => { + // Check if token is expired on rehydration + if (state?.accessToken && isTokenExpired(state.accessToken)) { + state.logout(); + } + }, + }, + ), +); diff --git a/Itenium.SkillForge/frontend/src/stores/index.ts b/Itenium.SkillForge/frontend/src/stores/index.ts new file mode 100644 index 0000000..f5cab02 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/index.ts @@ -0,0 +1,3 @@ +export { useAuthStore } from './authStore'; +export { useTeamStore, type Team } from './teamStore'; +export { useThemeStore } from './themeStore'; diff --git a/Itenium.SkillForge/frontend/src/stores/teamStore.ts b/Itenium.SkillForge/frontend/src/stores/teamStore.ts new file mode 100644 index 0000000..91de98d --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/teamStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +export interface Team { + id: number; + name: string; +} + +type Mode = 'backoffice' | 'manager'; + +interface TeamState { + mode: Mode; + selectedTeam: Team | null; + teams: Team[]; + setMode: (mode: Mode) => void; + setSelectedTeam: (team: Team | null) => void; + setTeams: (teams: Team[], isBackOffice: boolean) => void; + reset: () => void; +} + +export const useTeamStore = create()( + persist( + (set, get) => ({ + mode: 'backoffice', + selectedTeam: null, + teams: [], + + setMode: (mode: Mode) => set({ mode }), + + setSelectedTeam: (team: Team | null) => set({ selectedTeam: team }), + + setTeams: (teams: Team[], isBackOffice: boolean) => { + const currentState = get(); + + // If user is not backoffice, automatically switch to local mode + if (!isBackOffice) { + const selectedTeam = + currentState.selectedTeam && teams.some((t) => t.id === currentState.selectedTeam?.id) + ? currentState.selectedTeam + : teams[0] || null; + + set({ + teams, + mode: 'manager', + selectedTeam, + }); + } else { + set({ teams }); + } + }, + + reset: () => { + set({ + mode: 'backoffice', + selectedTeam: null, + teams: [], + }); + }, + }), + { + name: 'team-storage', + partialize: (state) => ({ + mode: state.mode, + selectedTeam: state.selectedTeam, + }), + }, + ), +); diff --git a/Itenium.SkillForge/frontend/src/stores/themeStore.ts b/Itenium.SkillForge/frontend/src/stores/themeStore.ts new file mode 100644 index 0000000..ce9d040 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/stores/themeStore.ts @@ -0,0 +1,65 @@ +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; + +type Theme = 'light' | 'dark' | 'system'; + +interface ThemeState { + theme: Theme; + resolvedTheme: 'light' | 'dark'; + setTheme: (theme: Theme) => void; +} + +function getSystemTheme(): 'light' | 'dark' { + if (typeof window === 'undefined') return 'light'; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +function resolveTheme(theme: Theme): 'light' | 'dark' { + if (theme === 'system') { + return getSystemTheme(); + } + return theme; +} + +function applyTheme(resolvedTheme: 'light' | 'dark') { + const root = window.document.documentElement; + root.classList.remove('light', 'dark'); + root.classList.add(resolvedTheme); +} + +export const useThemeStore = create()( + persist( + (set) => ({ + theme: 'system', + resolvedTheme: getSystemTheme(), + + setTheme: (theme: Theme) => { + const resolvedTheme = resolveTheme(theme); + applyTheme(resolvedTheme); + set({ theme, resolvedTheme }); + }, + }), + { + name: 'theme-storage', + onRehydrateStorage: () => (state) => { + if (state) { + const resolvedTheme = resolveTheme(state.theme); + applyTheme(resolvedTheme); + state.resolvedTheme = resolvedTheme; + } + }, + }, + ), +); + +// Listen for system theme changes +if (typeof window !== 'undefined') { + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { + const state = useThemeStore.getState(); + if (state.theme === 'system') { + const resolvedTheme = getSystemTheme(); + applyTheme(resolvedTheme); + useThemeStore.setState({ resolvedTheme }); + } + }); +} diff --git a/Itenium.SkillForge/frontend/src/styles.css b/Itenium.SkillForge/frontend/src/styles.css new file mode 100644 index 0000000..83c75d5 --- /dev/null +++ b/Itenium.SkillForge/frontend/src/styles.css @@ -0,0 +1,111 @@ +@import 'tailwindcss'; + +@source "../node_modules/@itenium-forge/ui"; + +@custom-variant dark (&:is(.dark *)); + +:root { + --radius: 0.625rem; + + /* Itenium Brand Colors - Light Mode */ + --background: #fffaf8; /* Soap - warm white */ + --foreground: #2d2a28; /* Dune - dark brown */ + --card: #ffffff; + --card-foreground: #2d2a28; + --popover: #ffffff; + --popover-foreground: #2d2a28; + --primary: #e78200; /* Gold/Rust - main brand orange */ + --primary-foreground: #ffffff; + --secondary: #f3f3f3; /* Neutrals 30 */ + --secondary-foreground: #2d2a28; + --muted: #f3f3f3; /* Neutrals 30 */ + --muted-foreground: #707070; /* Neutrals 70 */ + --accent: #2e8f6b; /* Leaf Dark - teal green */ + --accent-foreground: #ffffff; + --destructive: #dc2626; + --border: #eaeaea; /* Neutrals 40 */ + --input: #eaeaea; + --ring: #e78200; /* Gold/Rust */ + + --sidebar: #2d2a28; /* Dune - dark sidebar */ + --sidebar-foreground: #fffaf8; /* Soap */ + --sidebar-primary: #e78200; /* Gold/Rust */ + --sidebar-primary-foreground: #ffffff; + --sidebar-accent: #494949; /* Neutrals 80 */ + --sidebar-accent-foreground: #fffaf8; + --sidebar-border: #494949; /* Neutrals 80 */ + --sidebar-ring: #e78200; +} + +.dark { + /* Itenium Brand Colors - Dark Mode */ + --background: #2d2a28; /* Dune */ + --foreground: #fffaf8; /* Soap */ + --card: #3d3a38; /* Slightly lighter than Dune */ + --card-foreground: #fffaf8; + --popover: #3d3a38; + --popover-foreground: #fffaf8; + --primary: #f09749; /* Jaffa - lighter orange for dark mode */ + --primary-foreground: #2d2a28; + --secondary: #494949; /* Neutrals 80 */ + --secondary-foreground: #fffaf8; + --muted: #494949; + --muted-foreground: #a7a7a7; /* Neutrals 60 */ + --accent: #6ebca5; /* Leaf Medium */ + --accent-foreground: #2d2a28; + --destructive: #ef4444; + --border: rgba(255, 255, 255, 0.1); + --input: rgba(255, 255, 255, 0.15); + --ring: #f09749; +} + +@theme inline { + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); +} + +@layer base { + * { + @apply border-border outline-ring/50; + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + } + html { + @apply overflow-x-hidden; + } + body { + @apply min-h-svh w-full bg-background text-foreground has-[div[data-variant='inset']]:bg-sidebar; + } + button:not(:disabled), + [role='button']:not(:disabled) { + cursor: pointer; + } +} diff --git a/Itenium.SkillForge/frontend/src/test-setup.ts b/Itenium.SkillForge/frontend/src/test-setup.ts new file mode 100644 index 0000000..f13cc6c --- /dev/null +++ b/Itenium.SkillForge/frontend/src/test-setup.ts @@ -0,0 +1,14 @@ +// jsdom doesn't implement window.matchMedia +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: (query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: () => undefined, + removeListener: () => undefined, + addEventListener: () => undefined, + removeEventListener: () => undefined, + dispatchEvent: () => false, + }), +}); diff --git a/Itenium.SkillForge/frontend/tsconfig.json b/Itenium.SkillForge/frontend/tsconfig.json new file mode 100644 index 0000000..04627e9 --- /dev/null +++ b/Itenium.SkillForge/frontend/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "types": ["vite/client", "vitest/globals"], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src", "e2e"] +} diff --git a/Itenium.SkillForge/frontend/vite.config.ts b/Itenium.SkillForge/frontend/vite.config.ts new file mode 100644 index 0000000..2900bd4 --- /dev/null +++ b/Itenium.SkillForge/frontend/vite.config.ts @@ -0,0 +1,37 @@ +/// +import path from 'path'; +import fs from 'fs'; +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react-swc'; +import tailwindcss from '@tailwindcss/vite'; +import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; + +const localUiPath = path.resolve(__dirname, '../../../itenium-ui/libs/ui/src'); +const useLocalUi = fs.existsSync(localUiPath); + +export default defineConfig({ + plugins: [ + TanStackRouterVite({ + target: 'react', + autoCodeSplitting: true, + }), + react(), + tailwindcss(), + ], + resolve: { + alias: { + ...(useLocalUi && { '@itenium-forge/ui': localUiPath }), + '@': path.resolve(__dirname, './src'), + }, + dedupe: ['react', 'react-dom'], + }, + server: { + port: 5173, + }, + test: { + environment: 'jsdom', + globals: true, + exclude: ['e2e/**', 'node_modules/**'], + setupFiles: ['./src/test-setup.ts'], + }, +}); diff --git a/Itenium.SkillForge/scripts/setup-db.sh b/Itenium.SkillForge/scripts/setup-db.sh new file mode 100644 index 0000000..32ffd8d --- /dev/null +++ b/Itenium.SkillForge/scripts/setup-db.sh @@ -0,0 +1,7 @@ +#!/bin/bash +set -e + +export PGPASSWORD="$POSTGRES_PASSWORD" +psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL + CREATE SCHEMA skillforge; +EOSQL diff --git a/Prep.md b/Prep.md new file mode 100644 index 0000000..6d9575c --- /dev/null +++ b/Prep.md @@ -0,0 +1,46 @@ +Preparation +=========== + +Tryout +------ + +With Olivier, follow all instructions step by step +and check that +- Can start local dev +- Can run tests locally +- Can add backend migration +- Can push something and have it built on CI + + + +BackOffice +---------- + +- Zoveel mogelijk grote schermen & HDMI kabels +- Extra laptop om te sharen op groot scherm +- Stekkerblokken +- Zaal: Wifi op voorhand bevragen + + +Consultants +----------- + +- Doorgeven van Github username (push rechten op de git repository) +- Meebrengen + - Eigen scherm +- Installatie Docker + - Docker pull van de nodige images + - `docker compose up -d` +- Installatie Claude Code + - Ook de plugins +- Installatie Git, nvm en bun + - Clone van deze repository + - `bun install` & `dotnet build` + +### Optioneel + +- Installatie Visual Studio 2026 & Visual Studio Code + - Or .NET 10 SDK +- Visual Studio Code: Claude Plugin & Postgres Plugin +- Postgres Desktop UI (of voorzien we een Docker image daarvoor?) +- Visual Studio Plugins nodig? diff --git a/README.md b/README.md index a69e4fb..b3c8b26 100644 --- a/README.md +++ b/README.md @@ -1,38 +1,70 @@ Bootcamp AI =========== -Issues ------- - -- User & Access Management - - As a user, I want to sign in with SSO or login/pwd - - As an admin, I want to manage user roles (learner, manager, admin) so access is controlled. - - As a manager, I want to see my team members so I can track their learning. -- Learning Catalog +Creating the **SkillForge** + +Backlog +------- + +### Team 1: The Gatekeepers +User & Access Management +Focus: Backoffice administration + +- As a user, I want to sign in with SSO or login/pwd. +- As backoffice, I want to manage user roles (learner, team manager, backoffice) so access is controlled. +- As backoffice, I want to manage the different teams. +- As backoffice, I want to assign learners to teams. +- As team manager, I want to see my team members (learners) so I can track their learning. +- As backoffice, I want to see user activity (login history, last active). +- As backoffice, I want to deactivate users (soft delete) without losing their history. +- As a user, I want to reset my password via email. + + +### Team 2: The Pathmakers +Course Catalog & Content +Focus: Course creation and browsing + +- Course Catalog - As a learner, I want to browse published courses so I can choose what to learn. - As a learner, I want to search and filter courses by topic, level, and status (mandatory/optional). - - As an admin, I want to create, edit, publish and archive courses. - - As a manager, I want to assign mandatory/optional courses to my team and to individual members + - As team manager, I want to create, edit, publish and archive courses. + - As team manager, I want to assign mandatory/optional courses to my team and to individual members. +- Content Management + - As team manager, I want to add learning content (text, images, video, PDF, links, embedded youtube). + - As team manager, I want to structure courses into modules (a "path" to a goal, multiple courses are one module). + - As team manager, I want to structure courses into lessons (multiple lessons are one course). + - As team manager, I want to update content without affecting completed learners. +- Course Visualization + - As a learner I want to see the modules and my completion rate of the courses therein. + - As a learner I want to see the lessons inside a course and my completion rate therein. + + +### Team 3: The Trailblazers +Enrollment & Learning Experience +Focus: Learner journey + - Learning Experience - As a learner, I want to enroll in a course so I can start learning. - As a learner, I want to resume where I left off so I don’t lose progress. - As a learner, I want to mark lessons as new / done / later. -- Content Management - - As an admin, I want to add learning content (video, PDF, links). - - As an admin, I want to structure courses into modules and lessons. - - As an admin, I want to update content without affecting completed learners. - Progress & Tracking - As a learner, I want to see completed courses so I know what I’ve finished. - - As a manager, I want to see my team’s learning progress. - - As an admin, I want reporting on course usage and completion rates. -- Assessments - - As a learner, I want to complete quizzes so I can validate my knowledge. - - As an admin, I want to create quizzes with multiple question types. - - As an admin, I want to configure pass/fail criteria (score, attempts, time limit). + - As team manager, I want to see my team’s learning progress. + - As backoffice, I want reporting on course usage and completion rates. - Feedback - - As a learner, I want to receive feedback after completing an assessment. - - As a learner, I want to provide course feedback, so content can improve. - - As an admin, I want to review learner feedback per course. -- Certification - - As a learner, I want to receive a certificate when I complete a course. - - As an admin, I want to define certificate templates. + - As a learner, I want to provide lesson and course feedback, so content can improve. + - As backoffice, I want to review learner feedback per course. + - A a learner, I want to suggest additional learning content. + - As team manager, I want to approve suggested learning content. + - As a learner, I want to add context (personal experience, content rating, ...) to a lesson for other learners. + + +### Team 4: The Crucible +Assessments & Quizzes +Focus: Knowledge validation + +- As a learner, I want to complete quizzes so I can validate my knowledge. +- As team manager, I want to create quizzes with multiple question types. +- As team manager, I want to configure pass/fail criteria (score, attempts, time limit). +- As a learner, I want to receive feedback after completing an assessment. +- As team manager, I want to see quiz analytics (most missed questions, average scores). diff --git a/Teams/Team1-TheGatekeepers.png b/Teams/Team1-TheGatekeepers.png new file mode 100644 index 0000000..f7034ad Binary files /dev/null and b/Teams/Team1-TheGatekeepers.png differ diff --git a/Teams/Team2-ThePathmakers.png b/Teams/Team2-ThePathmakers.png new file mode 100644 index 0000000..c33eaf9 Binary files /dev/null and b/Teams/Team2-ThePathmakers.png differ diff --git a/Teams/Team3-TheTrailblazers.png b/Teams/Team3-TheTrailblazers.png new file mode 100644 index 0000000..7d2aad4 Binary files /dev/null and b/Teams/Team3-TheTrailblazers.png differ diff --git a/Teams/Team4-TheCrucible.png b/Teams/Team4-TheCrucible.png new file mode 100644 index 0000000..ffe0c19 Binary files /dev/null and b/Teams/Team4-TheCrucible.png differ diff --git a/Teams/Teams.docx b/Teams/Teams.docx new file mode 100644 index 0000000..7169796 Binary files /dev/null and b/Teams/Teams.docx differ diff --git a/_bmad-output/bootcamp-prep-checklist.md b/_bmad-output/bootcamp-prep-checklist.md new file mode 100644 index 0000000..1458ac4 --- /dev/null +++ b/_bmad-output/bootcamp-prep-checklist.md @@ -0,0 +1,96 @@ +# AI Bootcamp Prep Checklist + +**Event:** AI Bootcamp +**Date:** March 13, 2026 +**Participants:** ~36 people, 5-6 teams +**Product:** SkillForge + +--- + +## BEFORE BOOTCAMP (Days/Weeks Ahead) + +### Subscriptions & Tech +- [ ] Provision Max 5x Claude subscriptions for all 36 participants +- [ ] Verify everyone completed Prep.md setup (Docker, .NET 10, Bun, etc.) +- [ ] Pre-pull Docker images to avoid day-of network issues +- [ ] Create 5-6 team repos from template codebase +- [ ] Set up hidden `_backup-epics` branch in each repo + +### Content & Docs +- [ ] Finalize PRD document +- [ ] Finalize Architecture document +- [ ] Prepare Epics on hidden branch (escape hatch) +- [ ] Complete slides 20-21 (Managing Context, Agent Orchestration) if needed +- [ ] Create "While Claude Thinks" task list (review PR, write test, sketch next feature) + +### Team Composition +- [ ] Pre-assign 36 people into 5-6 teams +- [ ] Balance each team: mix of junior/senior, stack experts/outsiders +- [ ] Identify potential "AI coaches" (enthusiasts who can teach) +- [ ] Identify potential "quality gates" (seniors/skeptics who review) + +### Communication +- [ ] Send prep reminder 1 week before (verify setup!) +- [ ] Share team assignments 2-3 days before +- [ ] Set expectations: learning + bonding + competing, not just shipping + +--- + +## DAY-OF SETUP (9h00) + +### Kickoff Additions +- [ ] Announce judging criteria clearly (quality counts, not just features) +- [ ] Announce escape hatch: "Hidden epics branch exists if you need it" +- [ ] Announce timebox: "First commit by 10h30" +- [ ] Remind: "13h demo can be ONE button that works — that's fine" + +### Team Roles Prompt +- [ ] Suggest teams do 5-min "team contract" at 9h15: + - Who's the git wrangler? + - Who's the AI coach (if enthusiast present)? + - Who's the quality reviewer? + - Who's the demo presenter? + - Juniors: claim your first task explicitly + +--- + +## FALLBACK PLANS + +| Risk | Trigger | Action | +|------|---------|--------| +| Rate limit hit | Claude errors/slowdowns | Pair up with teammate | +| Team paralyzed | No commits by 10h30 | PO check-in, point to hidden epics | +| Merge conflicts | Pre-demo panic | Git wrangler + senior help | +| Laptop dies | Hardware failure | Pair programming | +| Docker issues | Should be pre-mitigated | Shared cloud Postgres backup? | +| Not having fun | Visible frustration/disengagement | PO/facilitator check-in, reassign role | + +--- + +## PERSONA-SPECIFIC PREP + +| Persona | Prep Action | +|---------|-------------| +| Juniors | Tag "good first issue" stories in backlog | +| Seniors | Brief them on reviewer/architect role option | +| AI Enthusiasts | Ask them to coach, not dominate | +| Skeptics | Frame as "QA role — your doubt is valuable" | +| Introverts | Ensure pair option, not forced mob | +| Stack Outsiders | Assign domain expert / PO tasks | +| Socializers | Morale + demo presenter role is valid | + +--- + +## KEY DECISIONS MADE + +| Decision | Choice | +|----------|--------| +| Claude subscription | Max 5x ($100/mo), itenium expenses | +| Prep level | PRD + Architecture visible, Epics on hidden branch | +| Team repos | Per-team (not shared mono-repo) | +| Team composition | Pre-assigned, balanced | +| BMAD usage | Optional (team decides) | + +--- + +*Generated from brainstorming session 2026-02-06, continued 2026-02-25* diff --git a/_bmad-output/brainstorming/brainstorming-session-2026-02-06.md b/_bmad-output/brainstorming/brainstorming-session-2026-02-06.md new file mode 100644 index 0000000..cb34f96 --- /dev/null +++ b/_bmad-output/brainstorming/brainstorming-session-2026-02-06.md @@ -0,0 +1,233 @@ +--- +stepsCompleted: [1, 2, 3] +inputDocuments: ['Bootcamp-AI.pptx'] +session_topic: 'AI Bootcamp Preparation - Logistics, Experience & Risk Mitigation' +session_goals: 'Ensure a successful bootcamp day: smooth logistics, engaged participants, and contingency plans for what could go wrong' +selected_approach: 'ai-recommended' +techniques_used: ['Reverse Brainstorming', 'Role Playing', 'Constraint Mapping'] +ideas_generated: ['Risk #1-10', 'Persona insights x8', 'Constraint map', 'Action checklist'] +context_file: '' +session_continued: true +continuation_date: '2026-02-25' +session_status: 'complete' +--- + +# Brainstorming Session Results + +**Facilitator:** Wouter +**Date:** 2026-02-06 + +## Session Overview + +**Topic:** AI-assisted development quality process -- strategies, guardrails, and workflows to ensure AI-generated code in SkillForge meets quality standards +**Goals:** Brainstorm approaches beyond linting and testing to maintain code quality when AI is doing the heavy lifting during bootcamp development + +### Session Setup + +_Focus narrowed from full SkillForge product ideation to the AI code quality process dimension. Existing infrastructure includes .NET backend with layered architecture (Entities, Services, Data, WebApi + tests) and React frontend with established tooling. Linting and testing already in place as first line of defense._ + +## Technique Selection + +**Approach:** AI-Recommended Techniques +**Analysis Context:** AI-assisted development quality process with focus on practical guardrails and workflows + +**Recommended Techniques:** + +- **Five Whys:** Root cause analysis of why AI-generated code fails quality standards -- uncover the underlying drivers before designing solutions +- **Chaos Engineering:** Stress-test the quality process by imagining deliberate failures -- find blind spots in existing linting+testing safety net +- **SCAMPER Method:** Systematically improve existing quality guardrails through 7 lenses (Substitute, Combine, Adapt, Modify, Put to other uses, Eliminate, Reverse) + +**AI Rationale:** This sequence progresses from understanding (why does AI code fail?) through stress-testing (where are the gaps?) to systematic improvement (how do we upgrade the process?). Mixes deep analytical and structured approaches for comprehensive coverage. + +--- + +## Session Pivot (2026-02-25) + +**New Focus:** AI Bootcamp Preparation — Logistics, Participant Experience & Risk Mitigation + +**Context Loaded:** Bootcamp-AI.pptx (22 slides) + +**Bootcamp Summary:** +- **Date:** March 13, 2026 +- **Schedule:** 9h start → 9h30 dev → 12h dinner → 13h demos → 13h30 dev → 16h final demos → 16h45 winners → 17h drinks +- **Product:** SkillForge (itenium knowledge matrix / CC growth paths) +- **Tech:** .NET 10 + React, BMAD optional, Docker/Postgres +- **Teams:** Self-organizing, own codebase + backlog, POs: Olivier/Michael/Bert +- **Winning:** Decided by POs, likely deployed as real itenium SkillForge + +**Updated Techniques:** +1. **Reverse Brainstorming** — "How could we make this bootcamp fail?" → surfaces hidden risks +2. **Role Playing** — Embody different participant types to stress-test experience +3. **Constraint Mapping** — Map all logistics constraints and find pathways + +--- + +## Technique 1: Reverse Brainstorming + +**Prompt:** "How could we make this AI Bootcamp fail spectacularly?" + +### Risks Identified + +**[Risk #1] Claude token/rate limits exhausted** +- Individual or group hits rate limits mid-sprint +- Parallel worktrees = 2-3x token burn +- **Mitigation:** Max subscriptions ($100-200/mo), fallback pairing + +**[Risk #2] Team paralysis — don't know where to start** +- Analysis paralysis on BMAD vs YOLO +- No one takes initiative +- Junior-heavy team intimidated + +**[Risk #3] Self-organization fails** +- Everyone vibe-codes same feature +- No ownership emerges +- One voice dominates, others disengage + +**[Risk #4] Merge conflict hell** +- No branching strategy, git chaos at demo time +- Force-push destroys work +- "I can't push!" at 12h55 + +**[Risk #5] Laptop failure** +- **Mitigated:** Pair programming fallback + +**[Risk #6] Docker/infra gremlins** +- Port conflicts, WSL memory, disk space, old versions +- **Mitigated:** Prep.md verified days before bootcamp + +**[Risk #7] People aren't having fun** +- Frustration loops fighting AI hallucinations +- Boredom waiting for generation +- Impostor syndrome ("everyone else is shipping") +- Social isolation (headphones, no interaction) +- Competition anxiety kills experimentation + +**[Risk #8] Waiting for AI — dead time** +- Git worktree helps but requires context-switch +- **Ideas:** Parallel Claude windows, pair rotation, smaller prompts, background agents +- **Skill idea:** `/worktree` scaffold skill + +**[Risk #9] BMAD prep level wrong** +- Too little = chaos; too much = no discovery +- **Decision:** PRD + Architecture visible, Epics on hidden branch as escape hatch +- Time-boxed discovery: first commit by 10h30 + +**[Risk #10] Discovery takes too long** +- Team debates until 11h30, demos nothing +- **Mitigations:** Hard timebox, PO check-in at 10h, "one button = valid demo" + +--- + +## Technique 2: Role Playing + +**Prompt:** Embody different participant types. What does the bootcamp feel like for each persona? + +### Persona Walkthrough + +**The Junior (1-2 yrs)** +- Risk: Passenger syndrome, impostor spiral +- Needs: Explicit first task, pair with senior, permission to ask, early small win + +**The Senior Architect (15 yrs)** +- Risk: Quality frustration, bites tongue on AI slop +- Needs: Reviewer/architect role, permission to NOT vibe-code, "ship clean" challenge + +**The AI Enthusiast** +- Risk: Alienates team by hogging keyboard +- Needs: Coach role (teach, don't show off), reminder that bonding > shipping + +**The AI Skeptic** +- Risk: Disengaged cynic, "told you so" mode +- Needs: QA/breaker role, valued doubt, let them be right sometimes + +**The Introvert** +- Risk: Invisible contributor, unrecognized work +- Needs: Async contribution paths, quiet space, pair > mob + +**The Competitor** +- Risk: Cuts corners, no tests, toxic if loses +- Needs: Clear judging criteria upfront, quality counts for win + +**The Stack Outsider (Java/PO)** +- Risk: Lost tourist, can't debug +- Needs: Domain expert role, pair with stack expert, non-code tasks + +**The Socializer** +- Risk: Low technical output +- Needs: Morale/glue role is valid, demo presenter, snack coordinator + +### Summary Table + +| Persona | Biggest Risk | Key Mitigation | +|---------|--------------|----------------| +| Junior | Passenger syndrome | Assigned first task, pair up | +| Senior | Quality frustration | Reviewer role, architecture ownership | +| Enthusiast | Alienates team | Coach role, teach not show | +| Skeptic | Disengaged cynic | QA/breaker role, valued doubt | +| Introvert | Invisible | Async paths, quiet wins | +| Competitor | Cuts corners | Clear criteria, quality counts | +| Outsider | Lost tourist | Domain expert role, pair up | +| Socializer | Low output | Morale role, that's valid | + +--- + +## Technique 3: Constraint Mapping + +**Prompt:** Map all constraints and find pathways through or around them. + +### Constraints Mapped + +**Fixed Walls:** +| Category | Constraint | Value | +|----------|------------|-------| +| Time | Start | 9h00 | +| Time | Lunch | 12h00 | +| Time | Demo 1 | 13h00 | +| Time | Demo 2 | 16h00 | +| Time | End | 17h00 | +| Space | Venue | Decided, teams sit together | +| Space | Demo setup | Central screen, facilitator laptop | +| People | Total | ~36 participants | +| People | Teams | 5-6 teams (~6-7 per team) | +| People | Composition | Pre-assigned | +| People | Remote | None | +| People | POs | Olivier, Michael, Bert | +| Tech | Stack | .NET 10 + React + Docker/Postgres | +| Tech | Codebase | Per-team repos | +| Prep | PRD | Ready | +| Prep | Architecture | Ready | + +**Flexible Doors:** +| Element | Flexibility | +|---------|-------------| +| Dev blocks / breaks | Team decides | +| BMAD vs YOLO | Team choice | +| Roles within team | Self-organize | +| Hidden epics branch | Escape hatch available | + +**Decision Made:** +| Constraint | Decision | +|------------|----------| +| Claude subscription | Max 5x ($100/mo per person) | +| Paid by | itenium | +| Total cost | ~$3,600 for 36 participants | + +--- + +## Session Outputs + +**Artifacts generated:** +- 10 risks identified with mitigations +- 8 personas stress-tested +- Full constraint map +- Action checklist: `_bmad-output/bootcamp-prep-checklist.md` + +**Key decisions:** +- PRD + Architecture visible, Epics on hidden branch +- Max 5x Claude subscriptions, itenium expenses +- Pre-assigned teams, balanced composition +- Per-team repos + +--- + +*Session complete. Continued from 2026-02-06, finalized 2026-02-25.* diff --git a/_bmad-output/brainstorming/brainstorming-session-2026-02-09.md b/_bmad-output/brainstorming/brainstorming-session-2026-02-09.md new file mode 100644 index 0000000..bd8d90c --- /dev/null +++ b/_bmad-output/brainstorming/brainstorming-session-2026-02-09.md @@ -0,0 +1,273 @@ +--- +stepsCompleted: [1, 2, 3] +inputDocuments: ['docs/skillmatrices/Skill_Matrix_Itenium.xlsx', 'docs/skillmatrices/Developer_Skill_Experience_Matrix.xlsx'] +session_topic: 'SkillForge Competency Framework - Functional Requirements' +session_goals: 'Flesh out, challenge, and expand functional requirements for profiles, skills, employee tracking, learning material, reviews, and skill matrix visualization' +selected_approach: 'ai-recommended' +techniques_used: ['Morphological Analysis', 'Role Playing', 'Reverse Brainstorming'] +ideas_generated: ['Profiles #1-10', 'Skills #1-5', 'Coach #1-3', 'Visualization #1-3', 'Model #1-3', 'RP-A1', 'RP-A2', 'RP-B1', 'RP-B2', 'RP-B3', 'RP-C1', 'RP-D1', 'RB-1 through RB-8'] +context_file: '' +session_status: 'complete' +completion_date: '2026-02-27' +--- + +# Brainstorming Session Results + +**Facilitator:** Wouter +**Date:** 2026-02-09 + +## Session Overview + +**Topic:** SkillForge Competency Framework — Functional Requirements for a consultant L&D platform + +**Goals:** Generate innovative and comprehensive functional requirements covering profiles (with seniority tiers), skills, employee skill tracking, learning material management, reviews & comments, and skill matrix visualization with gap analysis. + +### Context Guidance + +_Project is an active LMS (SkillForge) built with .NET 10 + React, with 4 dev teams. Current backlog covers user management, course catalog, enrollment/learning experience, and assessments. This session focuses on the competency/skill framework layer._ + +### Session Setup + +- **Approach:** AI-Recommended Techniques +- **Domain:** Functional requirements for a competency-based L&D platform +- **Key entities:** Profiles, Skills, Seniority Tiers, Learning Material, Reviews, Skill Matrix +- **Inspiration:** roadmap.sh-style visual skill paths for consultants + +## Technique Selection + +**Approach:** AI-Recommended Techniques +**Analysis Context:** SkillForge Competency Framework with focus on comprehensive functional requirements + +**Recommended Techniques:** + +- **Morphological Analysis:** Systematically map all entity combinations (Profiles x Skills x Tiers x Materials x Reviews x Visualization) to uncover hidden requirement intersections +- **Role Playing:** Embody key stakeholders (junior consultant, senior analyst, team manager, backoffice) to stress-test requirements from real user perspectives +- **Reverse Brainstorming:** Flip the script — "How could we make this platform useless?" — to surface edge cases, assumptions, and failure modes + +**AI Rationale:** Multi-entity domain with complex relationships benefits from systematic mapping first (Morphological), then human-centered validation (Role Playing), then adversarial stress-testing (Reverse Brainstorming). This sequence builds from structure through empathy to resilience. + +## Technique Execution — Phase 1: Morphological Analysis (In Progress) + +### Morphological Grid Dimensions + +| Dimension | Values | +|-----------|--------| +| **Profiles** | .NET Developer, Java Developer, Functional Analyst, Product Owner, Business Architect, Integration Architect | +| **Career Paths (Tech)** | Full Stack, Backend, Frontend, DevOps/Cloud, Tech Lead, Team Lead, Architect | +| **Seniority Tiers** | Junior, Medior, Senior (BA/IA: limited tiers, already senior-level roles) | +| **Growth Models** | I-Shape (deep specialization), T-Shape (broad + depth) | +| **Skills** | Technical skills, Soft skills, Methodologies, Tools | +| **Learning Material** | PDF, Video, Books, Course links, Blog posts, Conference talks, YouTube | +| **User Roles** | Learner/Consultant, Team Manager, Backoffice, Competence Coach | +| **Actions** | View, Create, Assign, Track, Review, Visualize, Coach | + +### Ideas Generated + +#### Profiles + +**[Profiles #1]**: Career Path Branching +_Concept_: A profile isn't just a single role — it's a career path with branches. A Junior .NET Developer might fork toward Full-Stack, Backend Specialist, or Tech Lead. The platform needs to model these branching paths, not just linear ladders. +_Novelty_: Most competency platforms model flat role lists. Branching paths let consultants visualize multiple futures and the skills that differentiate them. + +**[Profiles #2]**: Cross-Path Skill Overlap +_Concept_: A .NET Backend Developer and a Java Backend Developer likely share many skills (design patterns, CI/CD, SQL, REST APIs). The platform needs to recognize shared skill pools across profiles so consultants switching paths get credit for what they already know. +_Novelty_: Prevents the frustration of "starting over" when exploring a related career path. + +**[Profiles #3]**: T-Shape vs. I-Shape Growth Models +_Concept_: The platform supports two growth philosophies: I-shape (deep specialization along a career path) and T-shape (broad competence across many areas with depth in one or two). A consultant chooses their growth model, and the platform adapts its recommendations, gap analysis, and visualization accordingly. +_Novelty_: Most platforms assume everyone wants to climb a ladder. T-shape support validates the "craftsperson" who wants to master their craft broadly without title progression. + +**[Profiles #4]**: Horizontal Growth Tracking +_Concept_: For T-shape consultants, "progress" isn't about moving up a tier — it's about widening the bar. The skill matrix visualization needs a different metaphor: not a ladder to climb, but a radar chart or skill web that expands outward. +_Novelty_: Redefines what "growth" means in the visualization layer, preventing T-shape people from feeling like they're "not progressing." + +**[Profiles #5]**: Decoupled Skills from Career Ambition +_Concept_: Skills exist independently from career paths. A career path is a curated collection of skills with a recommended sequence — but a consultant can acquire any skill regardless of their chosen path. The path is a guide, not a gate. +_Novelty_: Prevents the platform from feeling restrictive. A backend developer who picks up frontend skills shouldn't have to "switch paths." + +**[Profiles #6]**: Role vs. Ambition Separation +_Concept_: Separate "current role" from "growth direction." A Medior .NET Developer's current role is fixed, but their growth direction could be: deeper in backend, broader as T-shape, or pivoting toward Tech Lead. These are profile overlays, not profile changes. +_Novelty_: Avoids forcing consultants into a single identity. One person can explore multiple growth directions without commitment. + +**[Profiles #7]**: Multi-Path Subscription +_Concept_: A consultant can "subscribe" to multiple career paths simultaneously. Their skill matrix becomes a union of all subscribed paths, with visual indicators showing which skills belong to which path (and which overlap). +_Novelty_: Eliminates the false choice between paths. Exploring a direction doesn't mean committing to it. + +**[Profiles #8]**: Competence Coach Role +_Concept_: A new platform role — the Competence Coach — who collaborates with consultants on their growth direction. Unlike a Team Manager (who tracks progress), the coach is a career sparring partner who co-curates skill goals, suggests paths, and reviews growth periodically. The coach's role is to aide the consultant in personal development; the platform is the tool to assist both coach and consultant. +_Novelty_: Adds a human-guided dimension alongside algorithmic suggestions. The coach sees the consultant's full matrix and can make personalized recommendations. + +**[Profiles #9]**: Peer-Powered Skill Suggestions ("Consultants Like You") +_Concept_: For T-shape consultants without a fixed path, the platform analyzes skill profiles of similar consultants and suggests skills that those peers have acquired. "Consultants with your .NET + SQL + Docker profile also learned: Kubernetes, Azure DevOps, Terraform." +_Novelty_: Collaborative filtering applied to career development. Surfaces organic learning patterns from the consultant population. + +**[Profiles #10]**: Coach-Consultant Growth Plan +_Concept_: The competence coach and consultant co-create a growth plan — a time-bound selection of target skills with linked learning material. This plan lives on the platform, is trackable, and can be reviewed/adjusted periodically. +_Novelty_: Bridges the gap between "here's your skill gap" and "here's what to do about it." Makes the coach relationship actionable and visible. + +#### Coach & Personal Development + +**[Coach #1]**: Personal Development Plan (PDP) as First-Class Entity +_Concept_: A PDP is a collaborative document created by coach + consultant, containing target skills, timeline, selected learning materials, and milestones. It lives on the platform as a trackable, versioned artifact — not a Word doc in someone's mailbox. The coach and consultant can both edit it, and progress auto-updates as skills are checked off. +_Novelty_: Turns a traditionally offline HR process into a living, measurable platform feature. The PDP becomes the central navigation tool for the consultant's growth. + +**[Coach #2]**: AI Transcription → PDP Generation +_Concept_: After a coaching interview, an AI-transcribed conversation is uploaded. The system parses the transcript to extract mentioned skills, goals, strengths, and gaps, then generates a draft PDP. The coach reviews, adjusts, and finalizes. +_Novelty_: Bridges the gap between unstructured human conversation and structured platform data. Coaching sessions become direct input to the system instead of lost context. + +**[Coach #3]**: Coaching Session Log +_Concept_: Each coaching session (transcript or summary) is stored as a session log linked to the consultant's profile and PDP. Over time, this creates a longitudinal record: what was discussed, what goals were set, what changed. +_Novelty_: Creates institutional memory for coaching relationships. When a coach changes, the history transfers seamlessly. + +#### Skills + +**[Skills #1]**: Skill Granularity Levels +_Concept_: Not all skills are binary (have it / don't have it). Some skills have proficiency levels — e.g., "Docker: Awareness / Working Knowledge / Proficient / Expert." The platform needs to support both binary checkoff skills AND graduated proficiency scales. +_Novelty_: Prevents oversimplification. "Knows C#" means very different things at Junior vs. Senior level. + +**[Skills #2]**: Skill Dependencies / Prerequisites +_Concept_: Some skills have natural prerequisites — you can't meaningfully learn Kubernetes without understanding containers. The platform could model skill dependency chains that guide learning order and prevent consultants from jumping to advanced topics prematurely. +_Novelty_: Creates natural learning sequences. The roadmap.sh inspiration comes alive here — visual dependency trees. + +**[Skills #3]**: Skill Decay / Freshness +_Concept_: Skills aren't permanent. A consultant who used Angular 3 years ago but hasn't touched it since has a decaying skill. The platform could flag skills that haven't been "refreshed" within a configurable timeframe. +_Novelty_: Keeps the skill matrix honest and current. Prevents a false sense of competence based on outdated experience. + +**[Skills #4]**: Skills vs. Courses — The Missing Link +_Concept_: The platform needs a many-to-many relationship: Learning Material <-> Skills. A single course might cover 5 skills. A single skill might have 10 different learning materials. This is fundamentally different from the existing Course -> Module -> Lesson hierarchy. +_Novelty_: Courses aren't the only learning material. The skill becomes the organizing principle, not the course. + +**[Skills #5]**: Skill Evidence / Proof of Competence +_Concept_: Checking off a skill isn't just self-declaration. The platform could support multiple evidence types: self-assessment, quiz completion (linking to Team 4's work), manager validation, peer endorsement, certificate upload, or project experience. Different evidence types carry different weight. +_Novelty_: Adds credibility to the skill matrix. Integrates naturally with Team 4's assessment work. + +#### Visualization + +**[Visualization #1]**: Roadmap.sh-Style Skill Tree +_Concept_: Each career path renders as an interactive skill tree — a visual dependency graph where nodes are skills, edges show prerequisites, and color-coding shows the consultant's status (completed / in-progress / not started / decaying). Click a node to see linked learning materials. +_Novelty_: The core roadmap.sh inspiration brought to life. The tree adapts based on subscribed paths and seniority tier. + +**[Visualization #2]**: Gap Heat Map +_Concept_: A team-level visualization where the manager or coach sees a heat map of skill gaps across their team. Rows = team members, Columns = required skills. Red = missing, Yellow = in progress, Green = completed. Instantly shows collective team weaknesses. +_Novelty_: Turns individual skill matrices into a strategic team planning tool. + +**[Visualization #3]**: T-Shape Radar Chart +_Concept_: For T-shape consultants, a radar/spider chart showing breadth across skill categories with depth indicated by distance from center. The consultant sees their shape and can compare it to the "ideal T-shape" for their level. +_Novelty_: Gives T-shape consultants a visualization that celebrates breadth instead of penalizing lack of specialization. + +--- + +--- + +## Phase 2 — Role Playing (completed 2026-02-27) + +### Key Decisions Confirmed +- Consultant signals readiness (flag), coach validates and moves the skill level +- Skills are never locked — prerequisite warnings shown, never gates +- Learning material is global, linked to skills and level transitions, never consultant-specific +- One global skill set: Layer 1 (Itenium Skills, universal) + Layer 2 (profile-specific, per competence center) +- Coach is the lead curator of personal matrices, not the consultant +- SMART goals per skill target (specific, measurable, time-bound) +- Coaching session = structured event with typed outcomes; coach dashboard is always-on + +### Ideas Generated + +**[RP-A1] Readiness Flag** +Consultant raises a "I think I'm ready" flag on a skill goal. Notifies coach as a soft ping. One active flag per goal to prevent spam. Feels like a considered action, not a button. + +**[RP-A2] Live Session Mode** +When a coaching session is opened, platform enters a minimal focused view: pending validations, level control, notes field. Two-tap validation. Everything else hidden during the session. + +**[RP-B1] Pre-Session Talking Points** +Auto-generated from activity data before each coaching session: resources completed, readiness flags raised, goals with no activity. Context-setting, not prescriptive. + +**[RP-B2] Skill Proposal Queue** +Coach adds a skill not in the global set → immediately usable as a local skill → goes into a pending queue → coach voting promotes it to global (e.g., 3 independent additions auto-promotes). + +**[RP-B3] Cohort Learning Trigger** +When a systemic gap is detected (>50% of a profile group below minimum level on a skill), the platform surfaces a suggestion for a group learning intervention. + +**[RP-C1] Share-from-Anywhere — Slack Bot (nice-to-have)** +A Slack bot that intercepts resource links shared in Slack and offers a one-confirm flow to add them to the SkillForge resource library, attributed to the sharer. + +**[RP-D1] Visual Profile Builder** +HR/coaches get a canvas to drag skill nodes from the global catalogue into a profile, set minimum niveau requirements per skill with a slider, draw dependency edges, and preview the resulting roadmap as a consultant would see it. Changes are versioned. + +--- + +## Phase 3 — Reverse Brainstorming (completed 2026-02-27) + +### Design Constraints Derived + +| Failure Mode | Design Constraint | +|---|---| +| Validation bottleneck kills motivation | Coach dashboard is always-on; readiness flags have aging indicators | +| Platform becomes salary data gathering | Growth framing first; HR/sales exports are background outputs, never in main flows | +| Resource library becomes a dump | Quality surfaced by usage + ratings; coaches pin "recommended" per level transition | +| Group training inflates levels | Attendance = evidence only, never auto-validates; coach decides at next review | +| Admin kills coaching conversation | Live session mode is 2-tap minimal; post-session detail added separately | +| Profile inconsistency across competence centers | Each competence center owns their profiles; Itenium Skills (Layer 1) are global and universal | +| Junior overwhelm on day one | Simplified onboarding view until first coaching session unlocks full roadmap | +| Sales report staleness | Data is inherently current (slow-changing, coach-validated); report shows validated level + date | +| Readiness flag spam | One active flag per goal; UI makes it a considered action | +| Cross-competence events invisible | LearningEvents support targetProfiles: ALL or multiple; any coach adds attendees for their consultants | + +### Additional Model Refinements + +**Resource quality lifecycle:** +- Status: `active | flagged | stale | deprecated` +- Any user can raise a "possibly outdated" flag (low friction) +- Accumulated negative reviews auto-flag as stale +- Coach explicitly marks stale or deprecated +- Author can update content → resets to active +- Deprecated resources hidden by default + +**LearningEvent as multi-skill resource container:** +- Attendance = evidence on relevant skills, never a level-up trigger +- Session content (slides, recording, exercises) added to global resource library +- `skillCoverage: [{ skillId, fromLevel, toLevel }]` — one event covers multiple skills +- `targetProfiles: [profileId] | ALL | [multiple profiles]` +- Cross-competence events visible to all coaches; each coach manages attendance for their own consultants + +**Progressive disclosure roadmap:** +- Default view: current niveau nodes (anchors) + immediate next tier (active targets) +- Nodes beyond collapsed into "future skills" summary +- Full tree opt-in via "Show full roadmap" +- Consultant with 45 nodes sees 8-12 at any time + +**Two-layer skill architecture:** +- Layer 1: Itenium Skills — universal, HR-owned, inherited by every profile automatically +- Layer 2: Profile Skills — competence-center owned, no duplication across centers + +--- + +## Complete Idea Register + +| # | Idea | Phase | +|---|---|---| +| Profiles #1-10 | Career path branching, T/I-shape models, multi-path subscriptions, coach role, peer suggestions, PDP | Morphological | +| Coach #1-3 | PDP as first-class entity, AI transcript → PDP draft, coaching session log | Morphological | +| Skills #1-5 | Variable granularity levels, skill dependencies, skill decay/freshness, resource linkage, evidence types | Morphological | +| Visualization #1-3 | Roadmap.sh-style skill tree, gap heatmap, T-shape radar chart | Morphological | +| Model #1 | Variable-depth skills (levelCount:1 = checkbox, levelCount:N = progression) | Model proposition | +| Model #2 | Seniority as computed threshold (set of minLevel per skill), not a manually assigned label | Model proposition | +| Model #3 | Template fork model (consultant matrix inherits from versioned profile template) | Model proposition | +| RP-A1 | Readiness flag — considered action, one per goal, aging indicator for coach | Role Playing | +| RP-A2 | Live session mode — minimal 2-tap UI during coaching sessions | Role Playing | +| RP-B1 | Pre-session talking points — auto-generated from activity data | Role Playing | +| RP-B2 | Skill proposal queue — coach adds, vote promotes to global | Role Playing | +| RP-B3 | Cohort learning trigger — systemic gap surfaces group intervention suggestion | Role Playing | +| RP-C1 | Slack bot for resource sharing — nice-to-have, in spec | Role Playing | +| RP-D1 | Visual profile builder — drag-and-drop canvas, versioned | Role Playing | +| RB-1 | Coach dashboard always-on with aging indicators and activity signals | Reverse Brainstorm | +| RB-2 | Growth framing first; HR/sales as background exports | Reverse Brainstorm | +| RB-3 | Resource quality lifecycle (flagged → stale → deprecated, multi-signal) | Reverse Brainstorm | +| RB-4 | LearningEvent = multi-skill resource container; attendance ≠ level-up | Reverse Brainstorm | +| RB-5 | Readiness flag as considered action (one active per goal) | Reverse Brainstorm | +| RB-6 | Junior onboarding: simplified view until first coaching session | Reverse Brainstorm | +| RB-7 | Progressive disclosure roadmap (next steps only, full tree opt-in) | Reverse Brainstorm | +| RB-8 | Cross-competence LearningEvents global; targetProfiles: ALL or multiple | Reverse Brainstorm | + +--- + +*Session complete. Resumed 2026-02-27, finalized 2026-02-27.* diff --git a/_bmad-output/planning-artifacts/prd.md b/_bmad-output/planning-artifacts/prd.md new file mode 100644 index 0000000..f6c9a7e --- /dev/null +++ b/_bmad-output/planning-artifacts/prd.md @@ -0,0 +1,407 @@ +--- +stepsCompleted: [step-01-init, step-02-discovery, step-02b-vision, step-02c-executive-summary, step-03-success, step-04-journeys, step-05-domain, step-06-innovation, step-07-project-type, step-08-scoping, step-09-functional, step-10-nonfunctional, step-11-polish] +inputDocuments: + - _bmad-output/planning-artifacts/product-brief-Bootcamp-AI-2026-02-27.md + - _bmad-output/brainstorming/brainstorming-session-2026-02-06.md + - _bmad-output/brainstorming/brainstorming-session-2026-02-09.md + - Itenium.SkillForge/README.md +workflowType: 'prd' +classification: + projectType: saas_b2b + domain: hrtech + complexity: medium + projectContext: brownfield +--- + +# Product Requirements Document - Bootcamp-AI (SkillForge) + +**Author:** Olivier +**Date:** 2026-02-27 + +## Executive Summary + +SkillForge is an internal consultant growth platform for itenium. It solves two connected problems: consultants without visible growth paths disengage and leave; consultants without validated skill profiles get placed in missions that don't fit — undervalued or overstretched. Both outcomes cost itenium in attrition and client trust. + +SkillForge makes growth visible and actionable by giving every consultant a co-maintained skill roadmap — current state, prioritised next steps, and linked learning resources — co-owned with their competence coach. For coaches, it replaces informal spreadsheets and memory with a shared, always-current record of every consultant's progress, goals, and validated skill levels. For sales and management, validated skill snapshots are a byproduct of the coaching process, not a separate data collection exercise. + +### What Makes This Special + +The coach relationship is the moat. Self-reported skill tracking (LinkedIn), generic course catalogues (Pluralsight), and static spreadsheets (itenium's current state) all fail the same way: they remove the human. SkillForge makes the coaching relationship ten times more effective by giving it a shared language (the skill matrix), a shared record (the consultant roadmap), and a shared knowledge base (the community resource library). Every skill validation reflects a real human expert's judgement — not a checkbox or a course completion badge. + +## Project Classification + +- **Type:** Internal B2B SaaS platform — web application, role-based access, team-scoped data +- **Domain:** HRTech / internal talent platform — no regulatory compliance obligations; complexity lives in the domain model (skill dependency graph, variable-depth levels, seniority threshold computation) +- **Complexity:** Medium — non-trivial data model and coaching workflow state machine; standard web security and access control +- **Context:** Brownfield — existing .NET 10 + React scaffold with auth (OpenIddict/JWT), role model (backoffice/manager/learner), team structure (Java/.NET/PO&Analysis/QA), and PostgreSQL already operational + +## Success Criteria + +### User Success + +**Consultant — Active Engagement** +A consultant is genuinely engaged when all three occur within any 3-month window: +- ≥1 resource completed linked to an active goal +- ≥1 readiness flag raised on a skill goal +- ≥1 skill level validated by their coach + +Single signals are passive or coincidental. All three together confirm a complete growth cycle. + +**Coach — Running Their Team** +A coach is actively using the platform when, per consultant per quarter: +- Every consultant has ≥1 active goal set by the coach +- ≥1 coaching session recorded +- ≥1 skill level validation made + +**Bootcamp Demo Criterion** +Both flows demonstrated end-to-end in one session: +1. Consultant: logs in → sees personalised roadmap → finds linked resource → marks complete → raises readiness flag +2. Coach: sees flag on dashboard → opens live session mode → validates skill level → sets new goal + +### Business Success + +| Metric | Target | Timeframe | +|---|---|---| +| Consultants with active goals | ≥80% of total | 3 months post-launch | +| Active engagement cycles completed | ≥1 per consultant | Per quarter | +| Coaching sessions recorded | ≥1 per consultant | Per quarter | +| Client missions staffed via SkillForge | ≥1 | 12 months | +| Resources contributed by non-coaches | ≥10 | 12 months | + +### Technical Success + +See Non-Functional Requirements for measurable targets. Summary: <2s page load, <500ms API reads, zero data loss on coaching records, Chrome/Edge/Firefox support. + +### Failure Signal + +**The platform is considered a failure if it is not used.** Specific failure indicators: +- Coaches do not set goals for their consultants within 30 days of launch +- Fewer than 50% of consultants log in within the first month +- No coaching sessions recorded after the bootcamp demo + +Adoption is the only metric that matters at launch. A technically perfect product that nobody uses has failed. + +## Product Scope + +### MVP — Minimum Viable Product (Bootcamp, March 13, 2026) + +Enables the complete consultant–coach growth loop end-to-end for the bootcamp demo. + +1. **Authentication & Role-Based Access** — Role-aware login for Consultant (`learner`), Coach (`manager`), Admin (`backoffice`). Extends existing OpenIddict auth infrastructure with SkillForge-specific role mapping. +2. **Global Skill Catalogue — Seeded** — Two-layer architecture seeded from itenium Excel matrices. Each skill node: name, category, description, levelCount (1=checkbox, 2–7=progression), level descriptors, prerequisite links. +3. **Skill Dependency Warnings** — Visible warning when prerequisites unmet. Skills are never locked — warned only. +4. **Skill Profile Assignment** — Coach assigns consultant to competence centre profile (.NET, Java, FA/BA, QA). Roadmap filtered to relevant skills only. +5. **Consultant Roadmap — Progressive Disclosure** — Default: current anchors + immediate next-tier skills (8–12 nodes). Full roadmap via "Show all." +6. **Active Goals View** — Coach-assigned goals on roadmap: skill, current niveau, target niveau, deadline, linked resources. First login shows pre-populated goals — not an empty screen. +7. **Resource Library — Browse, Link & Complete** — Add resource (title, URL, type, skill link, fromLevel→toLevel). Mark as completed (adds evidence). Rating: thumbs up/down. +8. **Readiness Flag** — Consultant signals "I think I'm ready." One active flag per goal. Dashboard indicator on coach view. Aging indicator shown. *(Push/email delivery: Post-MVP)* +9. **Coach Dashboard — Team Overview** — All consultants in one view: readiness flags (with age), inactive consultants (3+ weeks), active goals per consultant, quick entry to any profile. +10. **Live Session Mode — Skill Validation** — Focused view during coaching session: pending validations + active goals only. 2-tap validation (current→new niveau), inline notes, SMART goal setting. +11. **Seniority Threshold Ruleset** — Data model and computation for seniority thresholds (Junior/Medior/Senior) per profile: `{skillId, minLevel}` pairs. Consultant view shows progress: "You meet 14/18 Medior requirements." + +### Growth Features (Post-MVP) + +- Visual Profile Builder — drag-and-drop canvas for coaches to build/edit competence centre profiles +- Seniority Threshold Dashboard — visual gap view per consultant toward next seniority level +- Pre-session Talking Points — auto-generated context from activity data before coaching sessions +- Cohort Gap Heatmap — team-level skill gap visualisation for coaches +- Sales Snapshot & Profile Card Export — matchable, validated skill snapshot for client proposals +- HR Aggregate Reporting — salary review input; cohort-level skill distribution +- LearningEvents — group training sessions with multi-skill coverage and attendance tracking +- Resource Quality Lifecycle — flagged → stale → deprecated curation flow +- Skill Proposal Queue — coach-voting mechanism for promoting skills to global catalogue +- **Notifications** — push/email delivery for readiness flags and onboarding triggers + +### Vision (Future) + +- Cross-company skill benchmarking +- AI-assisted coaching suggestions +- Integration with external platforms (LinkedIn, certification bodies) +- Expansion to other competence centres or external clients + +## User Journeys + +### Journey 1: Lea — Consultant, First Login and First Growth Cycle (Success Path) + +Lea is a .NET developer, 18 months into her second role at itenium. She's good at her job but has no idea where she stands relative to Medior. Her last coaching session was two months ago and she can't remember what was discussed. + +**Opening Scene — First Login:** +Lea opens SkillForge for the first time. Instead of an empty dashboard asking her to fill something in, she sees a roadmap that already has her name on it. Three goals, set by her coach: "Clean Code niveau 3," "Entity Framework niveau 2," "REST API Design niveau 2." Linked resources are already attached to each one. She thinks: *"Someone has already thought about this for me."* + +**Rising Action — Active Use:** +Over the next three weeks, Lea browses the resources linked to her Clean Code goal. She marks one as complete — a book chapter her coach recommended. She notices the skill node showing her current niveau (1) and target (3). She can see she's not there yet, but the path is clear. Her goal progress indicator updates — visible progress without needing her coach to be present. + +**Climax — Readiness Signal:** +After completing two more resources and practicing in her current mission, Lea feels ready. She raises a readiness flag on "Clean Code niveau 3." The flag appears on her coach's dashboard immediately. The flag shows: *"Raised 0 days ago."* + +**Resolution — Growth Confirmed:** +In their coaching session, her coach validates Clean Code niveau 2 (not 3 yet — but genuine progress). A new goal is set. Lea's roadmap updates in real time. The node turns green. She's 15/18 toward Medior. *"I know exactly where I am and what's next."* + +**Capabilities revealed:** Personalised first-login experience (pre-set goals), progressive roadmap view, goal progress indicator, resource completion tracking, readiness flag (dashboard indicator to coach), coach validation flow, seniority progress indicator. + +--- + +### Journey 2: Nathalie — Coach, Between Sessions to Session Close (Success Path) + +Nathalie coaches 12 .NET consultants. It's Monday morning. She opens SkillForge and sees two readiness flags raised over the weekend. + +**Opening Scene — Dashboard Scan:** +Nathalie's dashboard shows: Lea raised a readiness flag 2 days ago. Thomas has had no activity in 23 days. Two consultants have overdue goals. In 30 seconds, Nathalie knows who needs attention this week. The dashboard tells her what to do. + +**Rising Action — Pre-Session Preparation:** +Before Lea's session, Nathalie opens Lea's profile. She sees everything since last time: resources completed, flag raised, current skill states. No archaeology through emails or Slack. She walks into the session prepared. + +**Climax — Live Session Mode:** +During the coaching session, Nathalie taps "Start Session." The UI collapses to essentials: pending validations and active goals only. Two taps: Clean Code niveau 1 → niveau 2. She adds a short note: "Strong grasp of naming and functions. Not yet applying at architectural level." She sets a new SMART goal. + +**Resolution — Session Closed:** +She exits live session mode. The session is recorded, the validation is timestamped, and the new goal is already visible on Lea's roadmap. Nathalie moves to the next consultant. *"I walked out with everything validated and recorded in under 5 minutes."* + +**Capabilities revealed:** Coach dashboard with readiness flags and inactivity alerts, consultant profile with activity history, live session mode, 2-tap skill validation, inline session notes, SMART goal setting. + +--- + +### Journey 3: Lea — Dependency Warning Edge Case + +Lea is exploring her roadmap using "Show all." She sees an advanced skill: "Domain-Driven Design niveau 3." It looks interesting. She clicks it. + +A visible warning appears: *"Clean Code niveau 3 not yet met — you can explore this skill, but your coach may ask you to address prerequisites first."* + +The skill is not locked. Lea reads the description and level descriptors. She decides to focus on her current goals first but bookmarks the skill mentally. The warning has done its job: she's informed, not blocked. + +**Capabilities revealed:** Prerequisite dependency graph, non-blocking dependency warning UI, full roadmap "show all" view. + +--- + +### Journey 4: BackOffice Admin — Onboarding a New Consultant + +itenium hires a new Java developer, Sander. The platform admin creates his account, assigns `learner` role and Java team claim. Two minutes of work. + +Coach Java is notified via the dashboard. She opens Sander's profile, assigns the Java competence centre profile, and sets 3 onboarding goals before his first login. + +Sander logs in. His opening screen: *"Welcome, Sander. Your coach has set 3 goals for your first 6 weeks."* Not an empty screen — a starting point with intent. + +**Capabilities revealed:** User management (create, role + team assignment), competence centre profile assignment, pre-populated first-login experience. + +--- + +### Journey Requirements Summary + +| Capability Area | Journey | MVP/Post-MVP | +|---|---|---| +| Personalised first-login (pre-set goals) | 1, 4 | MVP | +| Progressive roadmap with current + next-tier nodes | 1 | MVP | +| Goal progress indicator (mid-cycle visibility) | 1 | MVP | +| Resource completion tracking as evidence | 1 | MVP | +| Readiness flag — dashboard indicator to coach | 1, 2 | MVP | +| Readiness flag — push/email notification to coach | 1, 2 | Post-MVP | +| Aging indicator on readiness flag | 1, 2 | MVP | +| Seniority progress indicator | 1 | MVP | +| Coach dashboard: flags, inactivity alerts, overdue goals | 2 | MVP | +| Consultant profile: full activity history for coach | 2 | MVP | +| Live session mode: focused 2-tap validation | 2 | MVP | +| Inline session notes + SMART goal setting | 2 | MVP | +| Skill dependency warning (non-blocking) | 3 | MVP | +| Full roadmap "show all" view | 3 | MVP | +| User management: role + team assignment | 4 | MVP | +| Competence centre profile assignment | 4 | MVP | +| Onboarding trigger — pre-populated first login | 4 | MVP | +| Onboarding trigger — push/email notification | 4 | Post-MVP | +| Retention signal (mid-cycle re-engagement) | — | Post-MVP | + +## Domain-Specific Requirements + +**Domain:** HRTech — Internal Talent & Coaching Platform +**Regulatory complexity:** Low (no external obligations) +**Technical complexity:** Medium (data model integrity, access scoping, lifecycle management) + +### Compliance & Regulatory + +- No external regulatory obligations. Data handling is governed by the itenium employment agreement — no GDPR data subject rights workflow, no data processing register, no DPA required. +- Access restrictions are organisational policy, not legal mandate, but enforced technically to maintain trust. + +### Access Scoping + +Role and team scoping enforced at API/repository layer, not UI only. See RBAC Matrix in SaaS B2B Requirements for full role definitions. + +Key constraints: +- `learner` — own data only; cannot self-validate skill levels +- `manager` — own team only; cannot access other coaches' consultants +- `backoffice` — aggregate/administrative views; excluded from individual coaching session notes + +### Validation Integrity + +Coach-only validation is a business-critical constraint, not a UX preference: +- `POST /validations` restricted to `manager` role, enforced server-side +- Every validation records `validatedBy` (coach user ID) + `validatedAt` timestamp — immutable once written + +### Data Lifecycle + +**Consultant departure:** +- Account archived (not hard-deleted) on leaving employment +- Archived state: login disabled, invisible to active users, removed from coach dashboards +- All coaching history (session notes, validations, goal records) preserved for institutional knowledge +- Archived accounts recoverable by `backoffice` (re-hire scenario) + +**Coach departure:** +- All coaching relationships remain intact, attributed to original coach +- Orphaned consultants (no active coach) remain visible to `backoffice` for reassignment + +### Domain Risk Mitigations + +| Risk | Mitigation | +|---|---| +| Coach validates wrong skill level | Immutable audit trail; `backoffice` admin override post-MVP | +| Consultant data visible cross-team | Team-scoped queries via `ISkillForgeUser.Teams` claim at repository layer | +| Coaching notes exposed to HR aggregate views | `backoffice` endpoints return summary statistics only, never raw notes | +| Archived user data leaks into active views | Archived filter applied at repository layer, not controller | + +## SaaS B2B Specific Requirements + +### Deployment Model + +Single-tenant — one instance, one organisation (itenium), all data co-located. No billing, subscriptions, tenant isolation, or multi-org partitioning. Future multi-tenancy is Vision-tier only. + +### RBAC Matrix + +| Role | Label | Scope | Key Permissions | +|---|---|---|---| +| `backoffice` | Admin / HR | Platform-wide | User management, team assignment, aggregate reporting, archived account recovery | +| `manager` | Coach | Own team only | Skill validation (write), goal setting, session notes, consultant profile access | +| `learner` | Consultant | Own profile only | Roadmap view, resource library, readiness flag, goal progress tracking | + +**Enforcement:** All role checks at API middleware level. Team scoping via `ISkillForgeUser.Teams` claim at repository/query layer. Validation writes restricted to `manager` server-side. + +### Integrations + +None at MVP. Only external dependency: existing OpenIddict/JWT auth infrastructure in the brownfield codebase. + +**Potential future integrations (Vision, not committed):** +- External IdP federation (Azure AD / Google SSO) +- HR system sync for employee onboarding/offboarding +- Export to sales CRM for consultant profile cards + +## Project Scoping & Phased Development + +### MVP Strategy + +**Approach:** Experience MVP — complete, demonstrable end-to-end growth loop (consultant → readiness → coach validation → new goal) in one bootcamp session on March 13, 2026. +**Team:** 4 AI-assisted developers, ~2-week sprint. +**Success:** Both demo flows run without workarounds. Platform usable by real coaches and consultants on demo day. + +The 11 MVP capabilities are defined in the Product Scope section above. + +**Key scope decision — Notifications deferred from MVP:** +- Readiness flag → coach: dashboard indicator only (no push/email) +- Onboarding trigger → consultant: pre-populated first-login (no notification delivery) +- Push/email notification delivery moves to Phase 2 (Growth) + +### Risk Mitigation + +| Risk | Mitigation | +|---|---| +| Skill dependency graph complexity | Simple prerequisite ID list per skill; warn-only, no recursive computation | +| Seniority threshold computation | Static ruleset per profile (`{skillId, minLevel}` pairs); computed at read time, no background jobs | +| Seeding Excel matrices | One-time import script; can be done manually if automation slips | +| Demo instability | Seed data covers full demo script; pre-populated state for demo users on day 1 | + +## Functional Requirements + +### Identity & Access Management + +- FR1: A Consultant can authenticate and access only their own profile, roadmap, and goals +- FR2: A Coach can authenticate and access all consultants assigned to their team +- FR3: An Admin can authenticate and access platform-wide management views +- FR4: The system restricts skill validation writes to Coach role only +- FR5: An Admin can create user accounts and assign role and team membership + +### Skill Catalogue Management + +- FR6: The system maintains a global skill catalogue with skills organised by category +- FR7: Each skill has a name, description, variable level count (1=checkbox, 2–7=progression), level descriptors per niveau, and prerequisite links to other skills +- FR8: The system displays a non-blocking warning when a Consultant views a skill whose prerequisites are not yet met at the required niveau +- FR9: The system supports seeding the skill catalogue from imported data +- FR10: The system supports a two-layer skill architecture: universal itenium skills and competence-centre-specific profiles that filter the relevant subset + +### Consultant Profile & Roadmap + +- FR11: A Coach can assign a Consultant to a competence centre profile (Java, .NET, PO&Analysis, QA) +- FR12: A Consultant can view a personalised roadmap filtered to their assigned competence centre profile +- FR13: A Consultant's roadmap defaults to showing current skill anchors plus immediate next-tier skills (8–12 nodes) +- FR14: A Consultant can expand their roadmap to view all skills in their profile +- FR15: A Consultant's first login presents a pre-populated roadmap with coach-assigned goals (not an empty screen) + +### Goal & Growth Management + +- FR16: A Coach can assign a goal to a Consultant, specifying skill, current niveau, target niveau, deadline, and linked resources +- FR17: A Consultant can view their active goals including skill, current niveau, target niveau, deadline, and linked resources +- FR18: A Consultant can raise a readiness flag on an active goal (maximum one active flag per goal) +- FR19: The system tracks the age (days elapsed since raised) of each readiness flag +- FR20: A Coach can view all readiness flags across their team with the age of each flag displayed + +### Resource Library + +- FR21: Any authenticated user can browse the resource library +- FR22: Any authenticated user can contribute a resource to the library, specifying title, URL, type, linked skill, and applicable niveau range +- FR23: A Consultant can mark a resource as completed, recording it as evidence against a goal +- FR24: Any authenticated user can rate a resource + +### Coach Dashboard & Team Management + +- FR25: A Coach can view all consultants on their team in a single overview +- FR26: The Coach dashboard surfaces consultants with active readiness flags, with flag age visible +- FR27: The Coach dashboard surfaces consultants with no activity for 3 or more weeks +- FR28: The Coach dashboard shows the active goal count per consultant +- FR29: A Coach can navigate directly from the dashboard to any consultant's profile +- FR30: A Coach can view a consultant's full activity history (completed resources, goals set, validations received, readiness flags raised) + +### Live Session & Skill Validation + +- FR31: A Coach can enter a focused live session view for a specific consultant +- FR32: In live session mode, the Coach sees only pending validations and active goals for that consultant +- FR33: A Coach can validate a skill niveau for a Consultant in live session mode +- FR34: A Coach can add session notes during a live session +- FR35: A Coach can create a new goal for a Consultant during or after a live session +- FR36: The system records each skill validation with the validating coach's identity and a timestamp +- FR37: A completed live session is recorded and visible in the consultant's activity history + +### Seniority & Progress Tracking + +- FR38: The system maintains seniority threshold rulesets per competence centre profile, defined as skill + minimum niveau pairs +- FR39: A Consultant can view their progress toward their next seniority level as a count of met versus required criteria + +### User & Account Lifecycle (Admin) + +- FR40: An Admin can create a user account and assign role and team membership +- FR41: An Admin can archive a user account, disabling login while preserving all historical coaching data +- FR42: An Admin can restore an archived user account +- FR43: An Admin can view all consultants not currently assigned to an active coach + +## Non-Functional Requirements + +### Performance + +- Page load (initial render): <2s on standard office network +- API response time: <500ms for read operations under normal load +- Write operations (validations, goal saves, session notes): <1s +- Concurrent user baseline: ~50–150 users; no load spike scenarios expected + +### Security + +- HTTPS required in all environments except localhost development +- JWT token validated on every API request; no unauthenticated endpoints except login +- Role and team scope enforced at API/repository layer, not UI only +- Validation records immutable once written (`validatedBy` + `validatedAt` fields non-updatable) +- Session timeout: inactive sessions expire after 1 hour (nice-to-have; does not block MVP demo) + +### Reliability & Data Integrity + +- Zero data loss tolerance for: coaching session notes, skill validations, goal records +- Soft-delete / archive model only — no hard deletion of user or coaching data +- Browser support: current stable versions of Chrome, Edge, and Firefox + +### Out of Scope + +- Accessibility (WCAG compliance) — not required for this internal tool +- Scalability beyond itenium team size — single-tenant, fixed user base +- External integration reliability — no third-party systems at MVP diff --git a/_bmad-output/planning-artifacts/product-brief-Bootcamp-AI-2026-02-27.md b/_bmad-output/planning-artifacts/product-brief-Bootcamp-AI-2026-02-27.md new file mode 100644 index 0000000..a4f68f1 --- /dev/null +++ b/_bmad-output/planning-artifacts/product-brief-Bootcamp-AI-2026-02-27.md @@ -0,0 +1,497 @@ +--- +stepsCompleted: [1, 2, 3, 4, 5, 6] +inputDocuments: + - _bmad-output/brainstorming/brainstorming-session-2026-02-06.md + - _bmad-output/brainstorming/brainstorming-session-2026-02-09.md +date: 2026-02-27 +author: Olivier +--- + +# Product Brief: Bootcamp-AI (SkillForge) + +## Executive Summary + +SkillForge is a consultant growth platform for itenium — a shared, living +map that gives every consultant and their coach a common language for where +they are, where they're going, and what to do next. + +It is not a performance measurement tool. It is a career companion: one +that centralizes the coaching relationship, captures collective knowledge, +and makes the path forward visible and actionable for every consultant at +itenium. + +For coaches, it amplifies their effectiveness across a team of consultants +they know personally. For sales and management, it provides a live, +validated skill snapshot — a byproduct of growth, not the purpose of it. + +--- + +## Core Vision + +### Problem Statement + +itenium consultants today have no structured, visible way to understand +their current skill level relative to their role and career aspirations. +Competence coaches manage growth through informal conversations and personal +spreadsheets — with no shared tool to track progress, record decisions, or +capture the learning material they recommend. The collective knowledge of +coaches and senior consultants is scattered across emails, Slack messages, +and individual memory — never captured, never scored, never reusable. + +### Problem Impact + +- Consultants lack career clarity, leading to disengagement and missed + growth opportunities +- Coaches spend disproportionate time on administrative recall rather than + meaningful coaching conversations +- Valuable institutional knowledge is lost when coaches or consultants leave +- Sales proposals rely on outdated or self-reported skill data +- Salary and promotion decisions lack objective, structured input + +### Why Existing Solutions Fall Short + +Generic LMS platforms provide course catalogues but no skill tracking tied +to a consultant's role profile. LinkedIn Skills provides self-reported +badges without coaching validation or growth path guidance. itenium's +existing Excel matrices capture skill intent but are static, disconnected +from learning material, and impossible to maintain at scale. No existing +tool makes the coaching relationship itself the unit of value. + +### Proposed Solution + +SkillForge gives every stakeholder what they need: + +- **Consultants:** A progressive roadmap — current state, prioritized next + steps, and exactly what to learn to get there — co-maintained with their + competence coach. Always answers: "what do I do next, and why?" +- **Coaches:** An always-on dashboard to guide and validate consultant + growth, with a shared, community-rated resource library that captures + institutional knowledge +- **Sales & Management:** A live, coach-validated skill snapshot — a + byproduct of growth, not a primary data collection exercise + +### Key Differentiators + +**The coach relationship is the moat.** LinkedIn can track self-reported +skills. Pluralsight can serve courses. A spreadsheet can store a matrix. +None of them can replicate a real human who knows you, sets your goals, and +validates your progress. SkillForge makes that relationship ten times more +effective — and captures everything it produces. + +Supporting this: +- The skill matrix is a shared language between coach and consultant, + not a performance report card +- Progressive disclosure: the roadmap shows what's next, not everything + at once +- Knowledge economy: resources contributed and rated by everyone — + self-curating institutional memory +- Two-layer architecture: universal Itenium Skills + competence-center + owned profiles — no duplication, clear ownership + +--- + +## Target Users + +> **MVP Focus:** The consultant + coach pair is the core user relationship. +> All other user groups are supported but can be deprioritised in early +> epics without losing the product's core value. +> +> **Scale note:** Designed for itenium's current ~40 consultants and 4 +> coaches. Built to scale to 200+ without architectural shortcuts. + +--- + +### Primary Users + +--- + +#### The Consultant + +**Who they are:** +itenium consultants span all seniority levels — from a .NET developer +6 months into their first role to a senior Java architect with 12 years +of experience. They belong to one of four competence centers (.NET, Java, +Functional/Business Analysis, QA) and work at client sites. + +**How they experience the problem today:** +No structured view of their career trajectory. Growth conversations happen +in infrequent, unrecorded coaching sessions. Between sessions, there is +no shared artifact to refer back to. A junior doesn't know what to learn +this month. A senior doesn't know how close they are to the next level. + +**Engagement pattern:** +Weekly for engaged consultants; monthly for those less invested. The +platform rewards regular check-ins without penalising lower frequency. + +**First login — moment zero:** +The consultant's first experience is already personalised — the coach +sets their profile and first goals before they log in for the first time. +Their opening screen reads: *"Welcome, [Name]. Your coach has set 3 goals +for your first 6 weeks."* Not a task list. A starting point with intent. + +**What success looks like:** +*"I open SkillForge on Monday and I know exactly what to focus on this +week and why my coach thinks it matters for my growth."* + +**Their journey:** +1. **Onboarding:** Coach assigns profile and sets first 3 goals before + first login — consultant arrives to a personalised roadmap +2. **Regular use:** Checks active goals, browses linked resources, + marks resources as completed +3. **Readiness signal:** Raises a readiness flag when they feel ready + for a skill to be validated +4. **Coaching session:** Reviews progress with coach live; skill levels + updated, new goals set +5. **Long-term:** Roadmap evolves — nodes turning green, next steps + expanding, seniority threshold getting closer + +--- + +#### The Competence Coach + +**Who they are:** +A dedicated role at itenium — currently 4 coaches, each owning a +competence center: +- **Coach .NET** — guides .NET developers across all seniority levels +- **Coach Java** — guides Java developers +- **Coach FA/BA** — guides Functional and Business Analysts +- **Coach QA / Other** — guides QA engineers and smaller role profiles + +Each coach manages roughly 8–15 consultants. They are domain experts +and bring both technical depth and personal investment in growth. + +**How they experience the problem today:** +Consultants' progress lives in their heads and personal spreadsheets. +Before a coaching session they reconstruct context from memory and +scattered notes. Recommended resources live in emails and Slack — +never captured, never reusable. When a consultant changes coach, +all context is lost. + +**What success looks like:** +*"I walked into every coaching session prepared — I already knew what +had happened since last time. And I walked out with everything validated +and recorded in under 5 minutes."* + +**Their journey:** +1. **Between sessions:** Dashboard scan — readiness flags raised, who + has had no activity in 3+ weeks, which goals are overdue +2. **Pre-session:** Auto-generated talking points surface changes + since last session +3. **During session (live mode):** Minimal UI — validates skill levels + with 2 taps, adds notes, adjusts goals in real time +4. **Post-session:** Finalises session record; sets new SMART goals +5. **Ongoing:** Adds resources to shared library, monitors cohort gaps, + proposes new skills to the global catalogue + +--- + +### Secondary Users + +--- + +#### Sales / Account Management + +**Who they are:** +The people responsible for matching consultants to client missions. +They think in terms of skill requirements and availability — not growth +trajectories. + +**What they need:** +Fast, reliable answers to: *"Do we have a Senior Java developer with +Kubernetes experience available in March?"* They need matchable, +trustworthy snapshots — not coaching notes or resource lists. + +**Critical trust requirement:** +The snapshot must expose *quality of signal*, not just presence: +- Validated skill niveau (1–7), not just "has skill" +- Validation date — how recent is this data? +- Evidence type — self-assessed or coach-validated? + +A sales person who gets one client mismatch from stale data stops +trusting the platform. Freshness and specificity are non-negotiable. + +**Engagement pattern:** +Low frequency, high intent. Logs in when a mission needs staffing. +Experience must be frictionless: search → filter → snapshot → export. + +**What success looks like:** +*"I found the right consultant for this Java + Kubernetes mission in +2 minutes and sent the client a credible profile."* + +--- + +#### HR / Backoffice + +**Who they are:** +Responsible for platform governance, the global Itenium Skills +catalogue, and cross-company reporting. Consumers of skill data for +salary review cycles (which happen outside the platform). + +**What they need:** +Aggregate views: skill distribution by profile, seniority threshold +attainment rates, cohort-level gaps. Salary review report exportable +per coach's consultant group. + +**What success looks like:** +*"I pulled the salary review report in 10 minutes — validated skill +snapshots for every consultant, grouped by coach."* + +--- + +#### Platform Admin + +**Who they are:** +Full system access — user management, role assignment, global +configuration. May be an HR lead or designated itenium ops role. + +**What they need:** +User lifecycle management (onboarding, coach assignment, offboarding) +and system-level configuration across all competence centers. + +--- + +### The Core Growth Loop + +The heartbeat of SkillForge — every other feature exists to support this: + +``` +Coach sets goals + ↓ +Consultant works, completes resources, signals readiness + ↓ +Coach validates skill level + ↓ +Goals updated, new goals set + ↓ +Repeat +``` + +--- + +## Success Metrics + +### User Success Metrics + +**Consultant — Active Engagement** +A consultant is considered genuinely engaged when all three of the +following occur within any 3-month window: +- At least 1 resource completed that is linked to an active goal +- At least 1 readiness flag raised on a skill goal +- At least 1 skill level validated by their coach + +One signal alone can be passive or coincidental. All three together +indicate a real growth cycle has completed. + +**Coach — Running Their Team** +A coach is considered actively using the platform when, per consultant +per quarter: +- Every consultant on their team has at least 1 active goal set by + the coach +- At least 1 coaching session has been recorded +- At least 1 skill level validation has been made + +--- + +### Business Objectives + +**3-Month Adoption Target** +80%+ of itenium consultants have logged in and have at least 1 active +goal assigned by their coach. Coach adoption is implicit in this number +— if consultants have active goals, coaches have done their onboarding +work. + +**12-Month Platform Health Indicators** +Two signals that SkillForge has become part of how itenium works: + +1. **Sales trust:** The platform has been used to staff at least one + client mission — a sales person found a consultant match through + SkillForge and used the profile card in a proposal. + +2. **Knowledge economy alive:** The resource library has grown + organically — resources have been added by contributors other than + the 4 coaches, indicating consultants are internalising the + sharing culture. + +--- + +### Key Performance Indicators + +| KPI | Target | Timeframe | Measured by | +|---|---|---|---| +| Consultants with active goals | ≥ 80% of total | 3 months post-launch | Platform data | +| Active engagement cycles completed | ≥ 1 per consultant | Per quarter | Platform data | +| Coaching sessions recorded | ≥ 1 per consultant | Per quarter | Platform data | +| Sales missions staffed via SkillForge | ≥ 1 | 12 months | Sales confirmation | +| Resources added by non-coaches | ≥ 10 | 12 months | Platform data | + +--- + +### Bootcamp Demo Success Criterion + +**The winning demo shows both sides of the handshake:** + +1. **Consultant view:** Logs in → sees personalised roadmap with active + goals → finds a linked resource → marks it complete → raises a + readiness flag +2. **Coach view:** Sees the flag on their dashboard → opens live session + mode → validates the skill level with 2 taps → sets a new goal + +Both flows, one demo, one story: *"I know what to do next — and my coach +just confirmed it."* + +A team that can show this loop end-to-end has built the product. + +--- + +## MVP Scope + +> **Bootcamp context:** Teams have approximately 6 hours of development +> time on March 13, 2026. MVP scope is calibrated to what enables the +> core demo: the consultant-coach growth loop, end-to-end. + +--- + +### Core Features (Must Have) + +**1. Authentication & Role-Based Access** +Role-aware login for Consultant, Coach, and Admin. Auth infrastructure +already exists in the SkillForge repo — extend with the new roles. +Roles determine which views and actions are available. + +**2. Global Skill Catalogue — Seeded** +The two-layer skill architecture seeded from the provided itenium Excel +matrices (Skill_Matrix_Itenium.xlsx and Developer_Skill_Experience_Matrix +.xlsx). Each skill node has: +- Name, category, description +- Variable level depth (levelCount: 1 = checkbox, up to 7 = progression) +- Level descriptors per niveau +- Prerequisite links to other skills (dependency graph) + +**3. Skill Dependency Warnings** +When a consultant views a skill whose prerequisites are not yet met, +a visible warning is shown: *"Clean Code niveau 2 not yet met — you can +explore this skill, but your coach may ask you to address prerequisites +first."* Skills are never locked — only warned. + +**4. Skill Profile Assignment** +Coaches can assign a consultant to a competence center profile (.NET, +Java, FA/BA, QA). The consultant's roadmap is filtered to show only +skills relevant to their profile + universal Itenium Skills. + +**5. Consultant Roadmap — Progressive Disclosure View** +Default view shows: current skill states (anchors) + immediate next-tier +skills (active targets). Full roadmap available via "Show all." Goal is +8–12 relevant nodes visible at any time, not 45. + +**6. Active Goals View** +Consultant sees their coach-assigned goals highlighted on the roadmap: +skill node, current niveau, target niveau, deadline, linked resources. +First login experience: personalised view with coach-set goals already +in place — not an empty screen. + +**7. Resource Library — Browse, Link & Complete** +- Any user can add a learning resource (title, URL, type, skill link, + level transition: fromLevel → toLevel) +- Resources are linked to specific skill nodes and level transitions +- Consultants can mark a resource as completed (adds as evidence on + the skill state) +- Simple rating: thumbs up/down or 1–5 star score on completion + +**8. Readiness Flag** +Consultant raises a "I think I'm ready" flag on an active skill goal. +One active flag per goal. Notifies the coach as a soft ping on their +dashboard. Visible aging indicator (raised X days ago). + +**9. Coach Dashboard — Team Overview** +Coach sees all their consultants in a single view: +- Who has raised a readiness flag (and how long ago) +- Who has had no activity in 3+ weeks +- Active goals per consultant with status +- Quick-tap entry into any consultant's profile + +**10. Live Session Mode — Skill Validation** +When a coach opens a consultant profile for a session: +- Focused view: pending validations and active goals only +- 2-tap skill level validation (current → new niveau) +- Add session notes inline +- Set or adjust SMART goals +- Minimal UI — designed for use during a live conversation + +**11. Seniority Threshold Ruleset** +The data model and logic for seniority thresholds (Junior, Medior, +Senior) per profile: a named set of { skillId, minLevel } pairs. +The consultant view shows progress toward the next threshold: +*"You meet 14/18 Medior requirements."* The full threshold management +dashboard is a stretch goal, but the ruleset must be seeded and +computed at MVP. + +--- + +### Nice-to-Have (Stretch Goals — Bootcamp) + +These features add demo polish and real value but are not required for +the core loop. Teams should only attempt these if the MVP core is solid. + +| Feature | Value | +|---|---| +| Visual Profile Builder | Coaches build/edit profiles via drag-and-drop canvas | +| Seniority Threshold Dashboard | Visual gap view per consultant toward next level | +| Pre-session Talking Points | Auto-generated context before coaching sessions | +| Cohort Gap Heatmap | Team-level skill gap visualisation for coaches | + +--- + +### Out of Scope for Bootcamp MVP + +Explicitly deferred — valuable for post-bootcamp v1, not needed for +the demo or core loop. + +| Feature | Reason for deferral | +|---|---| +| Sales snapshot / export | Secondary user — loop works without it | +| HR salary report | Background output — not core to growth loop | +| LearningEvents (group sessions) | Real value, but complex model; defer | +| Skill proposal queue (coach voting) | Governance feature — not demo-critical | +| Slack bot integration | Nice-to-have, separate integration surface | +| Resource quality lifecycle (stale/deprecated flags) | Post-MVP curation feature | + +--- + +### MVP Success Criteria + +The MVP is considered successful when a demo can show, end-to-end: + +1. A **consultant** logs in → sees their personalised roadmap with + active goals → finds a linked resource → marks it complete → + raises a readiness flag on their skill goal +2. A **coach** sees the flag on their dashboard → opens live session + mode → validates the skill level → sets a new goal for the + next cycle + +Both flows in one demo. The handshake is the product. + +--- + +### Future Vision (Post-Bootcamp) + +If SkillForge is adopted post-bootcamp, the roadmap builds on the +MVP foundation: + +**v1 — Consolidation (months 1–3 post-bootcamp)** +- Sales snapshot & profile card export +- HR aggregate reporting (salary review input) +- LearningEvents with multi-skill coverage and attendance tracking +- Resource quality lifecycle (flagged → stale → deprecated) +- Skill proposal queue with coach voting → global promotion + +**v2 — Intelligence (months 4–9)** +- Cohort gap heatmap for strategic team planning +- Pre-session talking points (auto-generated from activity data) +- Contribution gamification (impact feed, contributor recognition) +- Visual profile builder with versioning + +**v3 — Scale (year 2)** +- Cross-company skill benchmarking +- AI-assisted coaching suggestions +- Integration with external platforms (LinkedIn, certification bodies) +- Expansion to other competence centers or external clients diff --git a/_bmad-output/planning-artifacts/ux-design-directions.html b/_bmad-output/planning-artifacts/ux-design-directions.html new file mode 100644 index 0000000..348579a --- /dev/null +++ b/_bmad-output/planning-artifacts/ux-design-directions.html @@ -0,0 +1,1147 @@ + + + + + + SkillForge — Design Direction Explorations + + + + + + + +
+ SkillForge — Design Directions + + + + + + Showing: Consultant Roadmap view · Lea (learner role) +
+ + +
+
+
+ Direction 1 — Sidebar + Card Grid + Fixed 240px sidebar · Card grid roadmap · Ambient seniority bar · Closest to Linear/GitHub feel + ★ Recommended +
+
+ +
+ +
+
+ 🗺 My Roadmap +
+
+ 🎯 Goals +
+
+ 📚 Resources +
+
Activity
+
+ Recent +
+
+
+
LD
+ +
+
+ + +
+
+
+ Lea's Roadmap + · + Medior track +
+
+ +
+
+ +
+ +
+
+
Current seniority
+
Medior
+
+
+
+
+
14 / 18
+
skills validated
+
+ + +
+ Active goals · set by your coach + Show all 24 skills → +
+ +
+ +
+
+
+
React Patterns
+
Junior → Medior
+
+ In progress +
+
📄 3 resources linked
+ +
+ + +
+
+
+
Testing Practices
+
Junior → Medior
+
+ Flag raised +
+
📄 2 resources linked
+ +
+ + +
+
+
+
TypeScript
+
Validated at Medior
+
+ Validated +
+
✓ All resources completed
+ +
+ + +
+
+
+
Code Review
+
Junior → Medior
+
+ In progress +
+
📄 1 resource linked
+ +
+ + +
+
+
+
Architecture Design
+
Medior → Senior
+
+ Locked +
+
+ You can explore this — prerequisite not yet met +
+ +
+ + +
+
+
+
Git & CI/CD
+
Validated at Medior
+
+ Validated +
+
✓ All resources completed
+ +
+
+
+
+
+
+
+ + +
+
+
+ Direction 2 — Sidebar + Skill Tree + Detail Panel + VS Code Explorer feel · Skill dependency tree on left · Selected skill detail on right · Good for viewing full skill graph +
+
+ +
+ + + +
+ + +
+
+ Lea's Roadmap +
+
+
Medior
+
+
+
+
14/18
+
+
+
+ +
+ +
+
+
Frontend
+
+ +
TypeScript
Validated Medior
+ +
+
+ 🔵 +
React Patterns
In progress · 🚩 ready?
+ +
+
+ +
Git & CI/CD
Validated Medior
+ +
+
+
+
Quality
+
+ 🟡 +
Testing Practices
Flag raised 3 days ago
+ 🚩 +
+
+ 🔵 +
Code Review
In progress
+ +
+
+ 🔒 +
Architecture Design
Locked · needs Code Review
+ 🔒 +
+
+
+ + +
+
React Patterns
+
+ Junior → Medior + · + Set by Nathalie · 12 days ago + · + In progress +
+ +
+ +
+ 📖 +
Patterns.dev — Modern React
Article · ~45 min
+ Done +
+
+ 🎥 +
React Advanced Patterns (talk)
Video · 32 min
+ In progress +
+
+ 📖 +
Internal practice exercise
Exercise · itenium
+ Not started +
+
+ +
+ + +
+
+
+
+
+
+
+ + +
+
+
+ Direction 3 — Top Navigation + Full Width Content + Horizontal role tabs · More editorial/document feel · Max-width centered content · Notion-influenced +
+
+
+ +
+
My Roadmap
+
Goals
+
Resources
+
Activity
+
+
+
LD
+ Lea Dubois +
+
+ +
+
+
Lea's Roadmap
+
Your coach Nathalie has set 3 active goals for you
+
+ +
+
+
Seniority
+
Medior
+
+
+
+
+
14 / 18 skills validated
+ +
+ +
Active goals
+ +
+
+
+
React Patterns
Junior → Medior
+ In progress +
+
📄 3 resources linked
+ +
+
+
+
Testing Practices
Junior → Medior
+ Flag raised +
+
📄 2 resources linked
+ +
+
+
+
TypeScript
Validated at Medior
+ Validated +
+
✓ All resources completed
+ +
+
+
+
+
+
+ + +
+
+
+ Direction 4 — VS Code Activity Bar Layout + 48px icon strip · 240px secondary panel · Main content · Status bar · Supports Live Session Mode naturally +
+
+ +
+ + + + +
+ +
LD
+
+ + +
+
+ ROADMAP +
+
Active Goals
+
🔵 React Patterns
+
🟡 Testing Practices
+
🔵 Code Review
+
Validated
+
✅ TypeScript
+
✅ Git & CI/CD
+
Locked
+
🔒 Architecture Design
+
+ + +
+
+
React Patterns
+
Testing Practices
+
+ +
+
+
React Patterns
+
+ Junior → Medior + · + Set by Nathalie · 12 days ago + · + In progress +
+
+ +
+
Resources
+
📖
Patterns.dev — Modern React
Article · ~45 min
Done
+
🎥
React Advanced Patterns (talk)
Video · 32 min
In progress
+
📖
Internal practice exercise
Exercise
Not started
+
+ +
+ + +
+
+ + +
+
⬆ Lea Dubois
+
🎯 Medior track
+
▓▓▓▓▓▓▓▓▓░ 14/18
+
3 active goals
+
+
+
+
+
+ + +
+
+
+ Direction 5 — Minimal Single Column + Simple top bar · Centered max-width content · One goal per row · Most focused / least information density +
+
+
+ +
+
Roadmap
+
Goals
+
Resources
+
+
+
LD
+ Lea Dubois +
+
+ +
+
+
+
Lea's Roadmap
+
Nathalie has set 3 goals for you · Medior track
+
+ +
+
+
Seniority
+
Medior
+
+
+
+
+
14 / 18
+ Show all → +
+ +
Active goals
+ +
+
🔵
+
+
React Patterns
+
Junior → Medior · Set by Nathalie · 3 resources
+
+
+ In progress + +
+
+ +
+
🟡
+
+
Testing Practices
+
Junior → Medior · Flag raised 3 days ago · Awaiting Nathalie
+
+
+ Flag raised +
+
+ +
+
+
+
TypeScript
+
Validated at Medior · Validated by Nathalie · Mar 12
+
+
+ Validated +
+
+ +
+
🔵
+
+
Code Review
+
Junior → Medior · Set by Nathalie · 1 resource
+
+
+ In progress + +
+
+
+
+
+
+
+ + + + diff --git a/_bmad-output/planning-artifacts/ux-design-specification.md b/_bmad-output/planning-artifacts/ux-design-specification.md new file mode 100644 index 0000000..b3b8325 --- /dev/null +++ b/_bmad-output/planning-artifacts/ux-design-specification.md @@ -0,0 +1,862 @@ +--- +stepsCompleted: [step-01-init, step-02-discovery, step-03-core-experience, step-04-emotional-response, step-05-inspiration, step-06-design-system, step-07-defining-experience, step-08-visual-foundation, step-09-design-directions, step-10-user-journeys, step-11-component-strategy] +inputDocuments: + - _bmad-output/planning-artifacts/prd.md + - _bmad-output/planning-artifacts/product-brief-Bootcamp-AI-2026-02-27.md +--- + +# UX Design Specification - Bootcamp-AI (SkillForge) + +**Author:** Olivier +**Date:** 2026-02-27 + +--- + + + +## Executive Summary + +### Project Vision + +SkillForge replaces the invisible coaching relationship with a shared, always-current record of consultant growth. The central UX proposition: a consultant who logs in for the first time should never see an empty screen — their coach has already thought about them, and the platform makes that care visible and actionable. + +### Target Users + +| User | Role | Context | Primary UX Goal | +|---|---|---|---| +| Consultant (e.g. Lea) | `learner` | Async, self-directed, checks in between missions | "Where am I and what's next?" | +| Coach (e.g. Nathalie) | `manager` | Intermittent overview + focused live sessions | "Who needs me right now, and what happened since last time?" | +| Admin | `backoffice` | Occasional — onboarding and account management | "Get a new consultant set up in 2 minutes." | + +**Device & environment:** Desktop-first (office network). Chrome, Edge, Firefox. No mobile requirement for MVP. + +### Key Design Challenges + +1. **Dual-context coach UX** — Coaches use the platform in two modes with different UI density needs: async dashboard scanning (relaxed, overview) and live coaching session (time-pressured, minimal). These must coexist in one coherent application. +2. **Progressive roadmap disclosure** — Default view of 8–12 skill nodes must feel focused without making consultants feel they're missing the bigger picture. The "Show all" expansion must be inviting, not intimidating. +3. **First-login empty-state prevention** — The system must guarantee consultants see pre-populated goals on first login. The edge case where a coach hasn't set goals yet requires a deliberate design decision. +4. **Role divergence within one application** — Three roles with fundamentally different primary actions, dashboards, and navigation needs — without feeling like three different products. + +### Design Opportunities + +1. **The "someone thought about me" moment** — The pre-populated first-login roadmap is the product's handshake with every new consultant. It is a high-emotion design opportunity that shapes long-term adoption. +2. **Live session mode as product signature** — The focused, collapsed UI during coaching is a novel pattern. Done well, it becomes the feature coaches love most and advocate for. +3. **Aging indicators as gentle accountability** — Readiness flag age and inactivity alerts can create urgency without being naggy. Design tone here is critical for coach trust and consultant comfort. + +## Core User Experience + +### Defining Experience + +The core growth loop is: **see roadmap → work → signal readiness → get validated → receive new goal → repeat.** Within this loop, the single most important interaction to nail is the **consultant's first login**. It is the moment SkillForge either earns trust or loses it. Every other design decision serves this moment or builds on it. + +The coach's hero moment — exiting a live session with everything validated and recorded in under 5 minutes — is the secondary defining experience and the one that drives coach advocacy and sustained adoption. + +### Platform Strategy + +- **Web application, desktop-first** — designed for office network, mouse and keyboard +- **No mobile layout for MVP** — all screen designs target 1280px+ viewports +- **Browser targets:** Current stable Chrome, Edge, Firefox +- **No offline functionality** — requires network connection; no local state needed beyond JWT session + +### Effortless Interactions + +These must require zero deliberate thought from the user: + +- **Raise readiness flag** — one clear action on a goal card; no multi-step confirmation +- **2-tap skill validation** — coach taps current niveau, taps new niveau; session note is optional, not blocking +- **Dashboard scan** — coach must understand team status in under 30 seconds without scrolling +- **Mark resource complete** — single action from the resource card; no form to fill +- **"Show all" roadmap expansion** — one click, no modal, no page reload + +### Critical Success Moments + +| Moment | User | What must happen | +|---|---|---| +| First login | Consultant | Sees named roadmap with 3 coach-set goals and linked resources — not an empty screen | +| First readiness flag | Consultant | Flag raised in one action; aging indicator visible immediately; feels like a meaningful statement | +| Dashboard open | Coach | Flags, inactive consultants, and goal counts visible without scrolling; actionable in 30 seconds | +| Session close | Coach | Validation timestamped, notes saved, new goal visible on consultant roadmap — before leaving the screen | +| Seniority progress | Consultant | After validation, counter updates visibly (e.g. 14→15/18 Medior) — growth is confirmed, not inferred | + +### Experience Principles + +1. **Never present an empty state** — pre-populate or communicate intent. If data isn't ready, explain why and set expectations. +2. **Role-aware by default** — the app knows who you are. Navigation, primary actions, and default views are role-specific from first render. +3. **Minimal friction on high-frequency actions** — raising flags, validating skills, and marking resources complete must be single interactions. +4. **Progressive disclosure, not information hiding** — show what matters now; reveal depth on demand. The roadmap default view is a curated starting point, not a restriction. +5. **Tone: warm and purposeful** — not gamified, not clinical. The platform reflects the coach relationship it supports. + +## Desired Emotional Response + +### Primary Emotional Goals + +**For Consultants:** +- **Seen** — first login, someone already prepared this for me +- **Clear** — I know exactly where I stand and what's next +- **Proud** — my validated growth is visible and real + +**For Coaches:** +- **In control** — I know my team without having to ask anyone +- **Accomplished** — the session is done, everything is captured +- **Trusted** — my judgement is the source of truth for validation + +### Emotional Journey Mapping + +| Stage | Consultant feeling | Coach feeling | +|---|---|---| +| First login | Wonder → confidence ("someone thought about me") | Satisfaction ("my preparation is visible") | +| Active use | Momentum ("I'm moving") | Awareness ("I know what's happening") | +| Readiness flag | Courage ("I'm asserting I'm ready") | Alert ("someone needs my attention") | +| Live session | Anticipation → relief | Focus → accomplishment | +| Post-validation | Pride + clarity ("I grew, here's what's next") | Efficiency ("done in 5 minutes, nothing missed") | + +### Micro-Emotions + +**To cultivate:** +- Confidence (not anxiety) when viewing dependency warnings — informed, not blocked +- Gentle urgency (not guilt) from aging readiness flags and inactivity indicators — factual, not shaming +- Assertion (not timidity) when raising a readiness flag — a meaningful self-declaration +- Trust (not skepticism) in the validation — a real human's judgement, not an algorithm + +**To avoid:** +- Overwhelm from too much data at once (coach dashboard) +- Anxiety from being evaluated (consultant roadmap) +- Shame from inactivity indicators — "23 days" not "OVERDUE" +- Friction-anxiety during live session — the UI must never be in the coach's way + +### Design Implications + +| Emotion target | UX design approach | +|---|---| +| "Seen" on first login | Personal greeting with consultant's name; coach's name visible on goal cards ("Set by Nathalie") | +| Confidence from dependency warnings | Soft amber tone, informational copy ("You can explore this — prerequisite not yet met") | +| Gentle urgency from aging flags | Neutral factual display ("Raised 3 days ago") — no red alerts, no exclamation marks | +| Assertion on readiness flag | Single prominent action with affirming label ("I'm ready") — no second-guessing confirmation modal | +| Accomplishment after session | Clear visual confirmation on exit ("Session recorded · 2 validations · 1 new goal set") | +| Pride after validation | Seniority counter increments visibly; skill node state changes clearly | + +### Emotional Design Register + +**Professionally warm** — clean, structured UI with personal language and selective expressive moments at emotional peaks. Not gamified (no badges, streaks, or points). Not clinical (not a spreadsheet with a login). Closest analogues: Linear's calm precision + Notion's human copy tone. + +**Warmth expressed through:** +- Personal language (names, not "User" or "Learner") +- Copy that acknowledges the human moment ("Your coach set these goals for you") +- Subtle visual confirmation at key moments (node state change, counter increment) +- Inactivity/urgency signals that are factual and neutral in tone, never alarming + +**Warmth NOT expressed through:** +- Illustrations or decorative graphics +- Animations beyond functional transitions +- Celebratory effects (confetti, badges) +- Motivational microcopy ("Keep it up! 🔥") + +## UX Pattern Analysis & Inspiration + +### Inspiring Products Analysis + +**GitHub — The attribution and async review model** + +itenium consultants live in GitHub. Its UX patterns carry zero learning curve for this audience. Key lessons: + +- **Attribution on everything** — "Committed by X", "Reviewed by Y". SkillForge mirrors this: "Validated by Nathalie · March 12" on every skill validation. Makes the coach's judgement visible and trusted. +- **PR review flow as mental model** — Author submits → reviewer reviews → approves or requests changes. Maps 1:1 to: Consultant raises readiness flag → Coach validates. The flow is already understood — no UX explanation needed. +- **Scannable status indicators** — GitHub's open/closed/merged labels are instantly parseable. Skill node states, goal statuses, and readiness flags follow the same "one glance = full understanding" principle. +- **Activity timeline** — The commit/event timeline shows what happened without archaeology. Consultant activity history for coaches should feel exactly like this: chronological, attributed, scannable. +- **"Waiting for review" queue** — GitHub's PR inbox (assigned to me, awaiting review) maps directly to the coach's readiness flag queue on the dashboard. + +**VS Code — Progressive disclosure and focus mode** + +VS Code is the reference for "complex tool, never feels complex." Key lessons: + +- **Zen mode / Focus mode** — Collapses to just the editor, removing all chrome. Direct inspiration for Live Session Mode: collapse the full coach UI to just pending validations and active goals. One button to enter, one to exit. +- **Expand/collapse file tree** — The mental model for the roadmap's progressive disclosure. Default: collapsed to what's immediately relevant. One click to expand. No page reload, no modal. +- **Status bar as ambient information** — Shows branch and errors without cluttering the canvas. Seniority progress ("14/18 Medior") lives in a persistent, non-intrusive location — ambient, not prominent. +- **Activity bar navigation** — Clean icon-based switching between Explorer / Source Control / Extensions maps to role-aware top-level navigation (Roadmap / Goals / Resources / Session). + +### Anti-Patterns to Avoid + +- **Jira-style form overload** — every action requiring multiple fields, dropdowns, and a save button. Live session validation must be 2 taps; session notes must be optional and inline, never a modal form. +- **Generic LMS empty states** — "Welcome! Start by adding a course." SkillForge's first login must never feel like a blank canvas waiting for the user to fill it in. +- **Spreadsheet-as-database UI** — rows, columns, and filters for skill tracking (itenium's current state). SkillForge should feel like a product, not a managed table. +- **Alert fatigue** — too many red badges, urgent colours, and "overdue" labels. GitHub's neutral tone for stale items ("opened 23 days ago") is the model — factual, not alarming. + +### Design Inspiration Strategy + +**Adopt directly:** +- GitHub's attribution pattern — every coach action carries name + timestamp, visible to the consultant +- GitHub's status label system — single-glance state for skill nodes and goals +- VS Code's focus/zen mode — the Live Session Mode interaction model +- VS Code's expand/collapse tree — the roadmap progressive disclosure mechanism + +**Adapt:** +- GitHub's PR inbox → Coach dashboard readiness flag queue (same mental model, different data) +- VS Code's status bar → Seniority progress indicator (ambient, persistent, non-intrusive) +- GitHub's activity timeline → Consultant activity history visible to coach pre-session + +**Avoid:** +- Jira's form-heavy action model (multi-field modals for simple operations) +- Generic LMS empty states and course-catalogue aesthetics +- Alarm-heavy status systems (red = urgent everywhere) + +## Design System Foundation + +### Design System Choice + +**Radix UI + TailwindCSS v4** — Themeable/headless system approach. + +The frontend stack already includes Radix UI (headless, accessible component primitives) and TailwindCSS v4 (utility-first CSS with design token support). This is the confirmed design system foundation — not a new adoption but a deliberate reaffirmation of the existing stack given project constraints. + +### Rationale for Selection + +- **No migration cost** — brownfield project already uses this stack; replacing it would consume sprint capacity needed for feature delivery before March 13. +- **Full visual control** — Radix UI is intentionally unstyled; every visual decision is owned by the product team, enabling the "professionally warm" aesthetic without fighting an opinionated system. +- **Accessibility built in** — Radix primitives handle focus management, keyboard navigation, and ARIA roles out of the box, matching the browser-target requirements (Chrome, Edge, Firefox). +- **AI-assisted development compatibility** — Radix UI + TailwindCSS is the combination AI coding tools know best, maximising the 4-developer team's output velocity. +- **Design inspiration alignment** — TailwindCSS v4's design token system enables the Linear-style calm precision that defines SkillForge's visual register. + +### Implementation Approach + +- **Radix UI primitives** for interactive components: Dialog, Popover, Select, Tooltip, DropdownMenu, Progress, Tabs — all headless, requiring deliberate styling. +- **TailwindCSS v4 utilities** for layout, spacing, and typography. +- **Semantic design tokens** defined in `tailwind.config` / CSS custom properties: + - Color palette: neutral base + 2–3 intentional accent colors (no alert-red in primary UI) + - Typography scale: clean sans-serif, consistent size steps + - Spacing scale: compact-to-comfortable range for dashboard density vs. roadmap whitespace + - Border radius: subtle (2–4px) — structured, not rounded/playful + +### Customization Strategy + +- **No third-party component library skin** — all visual styling is bespoke via Tailwind utilities, avoiding the "Material Design look" that conflicts with the professional register. +- **Component composition pattern** — build SkillForge-specific components (SkillNode, GoalCard, SessionPanel) by composing Radix primitives + Tailwind classes. +- **Role-aware theme tokens** — a single token set serves all three roles; role divergence handled via layout/navigation, not visual re-theming. +- **State-driven visual language** — skill node states (locked / in-progress / ready / validated) expressed through a consistent token-based color + icon system, not arbitrary per-component decisions. + +## Core User Experience — Defining Experience + +### 2.1 Defining Experience + +**"You log in. Someone already thought about your growth. Your path is clear."** + +The defining experience of SkillForge is the consultant's first login revealing a coach-prepared roadmap. This is the product's handshake with every new user: before they have done a single thing, their coach has already acted on their behalf. The platform makes that care visible and immediately actionable. + +This is SkillForge's equivalent of Spotify's "play any song instantly" — it delivers the core value promise in the first 10 seconds, before the user has learned anything about the tool. + +What makes this the defining experience rather than a feature: +- It is non-negotiable: failure here means the product fails its first impression +- It sets the emotional register for all future use ("this is a platform that knows me") +- It fundamentally differentiates SkillForge from every LMS that opens to an empty canvas + +### 2.2 User Mental Model + +**What consultants expect (the default assumption they arrive with):** +"Another tool that needs me to fill it in before it's useful." + +This expectation is earned by every LMS, HR platform, and skill tracker they have encountered before. The standard pattern is: empty state → "Get started!" → user education burden → slow adoption → abandonment. + +**What SkillForge delivers instead:** +The coach has already done the setup work. The consultant arrives to a prepared environment. Their mental model is immediately recalibrated: this tool works for me, not the other way around. + +**Current state at itenium:** +Growth conversations happen in informal sessions and via spreadsheets. Consultants have no persistent visibility into their growth path between coach meetings. The baseline expectation for any digital tool is therefore "spreadsheet with a login" — an extremely low bar that SkillForge must visibly, immediately surpass. + +**The critical recalibration moment:** +When a consultant sees "Set by Nathalie" on a goal card at first login, they understand in an instant: their coach is present in this tool, their growth is being actively managed, and this is not self-service. That attribution — a name, not a system message — is the moment the mental model shifts. + +### 2.3 Success Criteria + +The defining experience succeeds when, within 30 seconds of first login: + +- The consultant can **name what they are working on** — at least 3 goals are visible, labelled with skill names they recognise +- The consultant can **identify what to do next** — at least one goal has a linked resource they can open immediately +- The consultant can **see their coach's presence** — coach name is visible on goal cards ("Set by Nathalie"); this is not optional +- The consultant can **locate their seniority level** — current level and progress visible in an ambient, non-intrusive location +- The consultant feels **no pressure to configure anything** — zero empty states, zero "add your first item" prompts, zero setup friction + +Edge case success criterion: if a coach has not yet set goals, the holding state communicates coach intent ("Nathalie is preparing your roadmap — check back soon"), never an empty canvas. The product remains trustworthy even before it is ready. + +### 2.4 Novel vs. Established Patterns + +**Established pattern reused:** The roadmap itself — goal cards in a structured layout, progress indicators, resource links — follows familiar project/task board conventions (Trello, Linear, GitHub Projects). Users understand this visual container on sight. + +**Novel pattern introduced:** The *content origin*. In every established tool, the user populates their own workspace. SkillForge inverts this: the coach populates it before the user arrives. This inversion is the product's core differentiator. + +**Teaching the novel pattern:** +No explicit user education is needed — the attribution ("Set by Nathalie") teaches the mental model through the content itself. The first question users ask ("who set this up?") is answered before they ask it. + +**Familiar metaphor used:** A prepared brief or briefing document — the equivalent of arriving at a desk with a note from your manager laying out what you're working on this quarter. Professionals understand this metaphor natively. + +### 2.5 Experience Mechanics + +**1. Initiation** +- Coach or backoffice user creates the consultant account and sets 3 initial goals before the consultant's first login +- Consultant receives an email invitation (sent by backoffice; email delivery is post-MVP; for MVP, link is shared directly) +- Consultant authenticates via existing identity provider (OpenIddict/JWT) + +**2. Landing** +- After authentication, redirect is to the Roadmap view — not a generic dashboard +- The roadmap is pre-populated: 3 goal cards visible in the default 8–12 node view +- Page title includes the consultant's name: "Lea's Roadmap" +- Each goal card displays: skill name, current niveau, target niveau, coach name, linked resource count + +**3. Interaction** +- Consultant reads goal cards — recognition, not configuration +- Seniority progress counter is visible in ambient location (status-bar style): "14/18 Medior" +- Resources are accessible directly from the goal card: single click to open + +**4. Feedback** +- No loading spinners, no "loading your data" states — content is server-rendered and available on first paint +- Goal card state indicators are immediately legible (in-progress, no flag raised) +- Dependency warnings, if any, appear inline in soft amber — informational, not alarming + +**5. Completion** +- "Done" for first login is: consultant has found one goal to start working on +- No explicit onboarding flow, no tutorial modal, no guided tour +- The UI itself is the onboarding: the content makes the purpose self-evident + +## Visual Design Foundation + +### Color System + +**Philosophy:** Warm neutral base (itenium Soap / Dune) with Gold as the single primary action color. No red in the primary UI surface — urgency is expressed through factual copy, not alarming color. The dark sidebar grounds the layout while the warm Soap content area stays calm and approachable. + +**Source of truth:** `Itenium.SkillForge/frontend/src/styles.css` — the app ships a complete shadcn-style token set. All new components MUST reference these tokens, never raw hex values. + +**Existing tokens (light mode / dark mode):** + +| CSS Variable | Light | Dark | Usage | +|---|---|---|---| +| `--background` | `#fffaf8` Soap | `#2d2a28` Dune | Page background | +| `--foreground` | `#2d2a28` Dune | `#fffaf8` Soap | Primary text | +| `--card` | `#ffffff` | `#3d3a38` | Card surface | +| `--card-foreground` | `#2d2a28` | `#fffaf8` | Card text | +| `--primary` | `#e78200` Gold | `#f09749` Jaffa | Primary buttons, active states, focus ring | +| `--primary-foreground` | `#ffffff` | `#2d2a28` | Text on primary | +| `--secondary` | `#f3f3f3` | `#494949` | Secondary surfaces, ghost button bg | +| `--secondary-foreground` | `#2d2a28` | `#fffaf8` | Text on secondary | +| `--muted` | `#f3f3f3` | `#494949` | Subtle backgrounds | +| `--muted-foreground` | `#707070` | `#a7a7a7` | Attribution, timestamps, metadata | +| `--accent` | `#2e8f6b` Dark Leaf | `#6ebca5` Teal | Validated state, success accent | +| `--accent-foreground` | `#ffffff` | `#2d2a28` | Text on accent | +| `--destructive` | `#dc2626` | `#ef4444` | Delete confirmations only | +| `--border` | `#eaeaea` | `rgba(255,255,255,0.1)` | Card borders, dividers, inputs | +| `--ring` | `#e78200` Gold | `#f09749` Jaffa | Keyboard focus ring | +| `--sidebar` | `#2d2a28` Dune | — | **Dark sidebar background** | +| `--sidebar-foreground` | `#fffaf8` Soap | — | Sidebar text | +| `--sidebar-primary` | `#e78200` Gold | — | Active nav item | +| `--sidebar-accent` | `#494949` | — | Sidebar hover state | +| `--sidebar-border` | `#494949` | — | Sidebar dividers | + +**SkillForge-specific state tokens — to be added to `styles.css`:** + +These four skill node states are not in the existing token set and must be extended: + +```css +/* Add to :root in styles.css */ +--skill-in-progress: #6ebca5; /* Teal — actively working */ +--skill-in-progress-bg: #e0f3ee; /* Teal tint — row/card bg */ +--skill-ready: #f29e81; /* Apricot — readiness flag raised */ +--skill-ready-bg: #fee9df; /* Apricot tint — row/card bg */ +--skill-validated: var(--accent); /* Dark Leaf — maps to existing token */ +--skill-validated-bg: #eaf5ea; /* Leaf tint — row/card bg */ +--skill-locked: var(--border); /* Neutral — prerequisite not met */ +--skill-dependency-bg: #fef0dc; /* Gold tint — dependency warning */ +--skill-dependency-text: #92400e; /* Warm brown — dependency warning text */ +``` + +Note: `--skill-validated` deliberately references `--accent` so it inherits dark mode correctly without additional overrides. + +Note: Red (`--destructive`) is reserved exclusively for irreversible destructive actions. It does not appear for overdue, inactive, or urgent states. + +### Typography System + +**Current state:** The app uses system/browser default fonts (no explicit `@font-face` or Google Fonts import in `styles.css`). Tailwind's default sans-serif stack applies. + +**Recommended addition:** itenium's brand specifies Rubik (headings) + Inter (body). Add to `styles.css`: +```css +@import url('https://fonts.googleapis.com/css2?family=Rubik:wght@400;500;600;700&family=Inter:wght@400;500;600&display=swap'); +``` +Then apply via Tailwind config or utility classes. This is a low-risk enhancement — zero layout impact, significant brand alignment. + +**Working typeface assumption for component design: Inter** (system fallback until Rubik is added) + +**Type scale:** + +| Role | Size / Line height | Weight | Usage | +|---|---|---|---| +| Display | 24px / 32px | 700 | Page titles, roadmap header ("Lea's Roadmap") | +| Heading | 18px / 24px | 600 | Section headers, card titles, modal headings | +| Body | 14px / 20px | 400 | Card content, descriptions, list items | +| Label | 13px / 16px | 500 | Form labels, metadata keys, nav items | +| Small | 12px / 16px | 400 | Timestamps, counts, attribution ("Set by Nathalie") | +| Mono | 12px / 16px | 400 | Skill node codes or IDs if needed (optional) | + +**Principles:** +- No font weight above 700 — no ultra-bold decorative headings +- Body text at 14px minimum — readable at office monitor distances +- Line height generous on body (1.5x) to support scan reading on coach dashboard +- Small text at 12px is floor — never smaller for attributions or metadata + +### Spacing & Layout Foundation + +**Base unit: 4px** +All spacing values are multiples of 4px (Tailwind's default scale). Consistent, predictable, easy for AI-assisted development. + +**Spatial rhythm:** + +| Context | Padding | Gap | Rationale | +|---|---|---|---| +| Page shell | 24px horizontal | — | Breathing room at viewport edge | +| Goal card (consultant roadmap) | 16px | 12px between cards | Comfortable for reading | +| Coach dashboard rows | 12px | 8px | Tighter for scan density | +| Session panel | 16px | 16px | Focused, uncluttered | +| Modal / dialog | 24px | 16px | Elevated, clear separation from page | + +**Layout structure:** + +- **Sidebar navigation**: 240px fixed-width left panel (role-aware content) +- **Main content area**: fluid, fills remaining viewport width +- **Max content width**: 1200px (prevents over-stretch on ultra-wide monitors) +- **Minimum viewport**: 1280px (desktop-first, no mobile breakpoints) +- **Grid**: 12-column grid in main content area for layout flexibility + +**Density modes:** +- **Standard (roadmap, resource library)**: generous spacing, focus on individual cards +- **Compact (coach dashboard, team overview)**: reduced padding for information density, enabling 30-second team status scan without scrolling + +### Accessibility Considerations + +Scope: No formal accessibility requirement for MVP. However, these baseline practices are built into the design system at zero marginal cost: + +- **Contrast**: All text meets WCAG AA (4.5:1 for body text, 3:1 for large text) — achieved naturally by using `--foreground` (`#2d2a28`) on `--background` (`#fffaf8`) and `--card` surfaces +- **Focus indicators**: `--ring` (Gold `#e78200`) provides visible keyboard focus state on all interactive elements — built into `@itenium-forge/ui` component primitives by default +- **Semantic HTML**: `@itenium-forge/ui` (Radix UI wrappers) output correct ARIA roles and attributes without additional configuration +- **No colour-only state communication**: every state (validated, pending, locked) uses colour + icon + label — never colour alone +- **Dark mode**: the existing token set supports light/dark switching transparently; SkillForge-specific state tokens must include dark-mode overrides in the `.dark` block of `styles.css` + +## Design Direction Decision + +### Design Directions Explored + +Five directions were generated and evaluated using the established itenium brand palette (Gold `#E78200`, Soap `#FFFAF8`, Dune `#2D2A28`, Leaf greens, Apricot) and itenium's typography (Rubik headings, Inter body). All directions were reviewed as interactive HTML mockups showing the consultant roadmap view. Reference file: `_bmad-output/planning-artifacts/ux-design-directions.html` + +| Direction | Concept | Evaluation | +|---|---|---| +| 1 · Sidebar + Card Grid | Fixed sidebar, 3-col card grid | Strong structure, slightly dense for consultant | +| 2 · Sidebar + Skill Tree | Explorer tree + detail panel | Good for dependency nav, adds complexity | +| 3 · Top Nav + Full Width | Horizontal nav, editorial content | Less structured for 3 distinct roles | +| 4 · VS Code Activity Bar | Icon strip + panels + status bar | Developer-oriented, higher learning curve | +| 5 · Minimal Single Column | Top bar, centered list | Cleanest, calmest — limited coach density | + +Directions 1 and 5 were preferred for their cleaner, calmer feel. Party review (Sally UX, John PM, Winston Architect) refined the synthesis into the chosen direction below. + +### Chosen Direction + +**D1 shell + D5 consultant content + deliberate density switching** + +A single navigation shell (Direction 1) with content layouts that adapt to user role and view context. The consultant roadmap adopts Direction 5's calm vertical list for active goals; the coach dashboard retains Direction 1's density for team scanning. + +### Design Rationale + +**Single sidebar shell (MVP):** +A fixed 240px sidebar serves all three roles with role-aware navigation content. One layout component — not three — fits the 4-developer, March 13 timeline. The sidebar's chrome cost is justified by the role-switching frequency of coaches and the clear orientation it provides on first login for consultants. + +**Active goals as a calm vertical list (D5 influence):** +Consultants open SkillForge to answer one question: "What do I work on today?" A vertical list answers faster than a grid — scanning is linear, not spatial. Maximum 3 active goals shown by default; "Show all skills" expands inline. Each goal row has full-width breathing room: skill name, niveau, coach attribution, resources count, and "I'm ready" action — nothing hidden, nothing crowded. + +**Validated skills as compact grid (D1 influence):** +The history/archive view (validated skills, completed goals) tolerates higher density — users are browsing, not deciding. A 2-column compact card layout serves this view. + +**Coach dashboard retains full density (D1 native):** +Coaches must scan their team in 30 seconds. The calm principle does not apply here — density is a feature. Compact rows, tight spacing, flag queue prominent at top. The density mode is a deliberate design decision, not a failure to apply the calm principle. + +**itenium brand grounded — all tokens already in `styles.css`:** +`--primary` Gold `#e78200` action · `--background` Soap `#fffaf8` content area · `--sidebar` Dune `#2d2a28` dark sidebar · `--accent` Dark Leaf `#2e8f6b` validated · `--muted-foreground` `#707070` metadata · plus 4 new `--skill-*` tokens to be added (see Visual Foundation) + +### Implementation Approach + +**Navigation shell (all roles) — aligns with existing `Layout.tsx`:** +- 240px collapsible left sidebar — `--sidebar` dark Dune `#2d2a28` background (already implemented) +- Active nav item: `--sidebar-primary` Gold bg tint, Gold text +- Hover state: `--sidebar-accent` `#494949` bg +- Role-aware sidebar content (matches existing Layout.tsx nav groups): + - `learner`: My Learning · Catalog · Dashboard + - `manager`: Team · Courses Management · Dashboard + - `backoffice`: Administration · Reports · Dashboard +- User avatar + dropdown at sidebar footer (already implemented) +- `SidebarTrigger` collapse button — already present in app shell + +**Consultant roadmap — calm list mode:** +- Page title: "Lea's Roadmap" (Rubik/Inter, `text-2xl font-bold text-foreground`) +- Seniority progress bar: ambient, below title, `text-muted-foreground` label +- Active goals section: vertical list, full-width rows, 20px gap +- Each row: left-coloured state border (`--skill-in-progress` / `--skill-ready` / `--skill-validated`) + skill name + niveau + `text-muted-foreground` "Set by [coach]" + "I'm ready" button +- `border-radius: var(--radius)` (10px) on all card/row surfaces — matches app default +- "Show all skills →" ghost link after active goals + +**Coach dashboard — density mode:** +- Compact team rows: 12px padding, avatar (`Avatar`/`AvatarFallback` from `@itenium-forge/ui`) + name + flag count + last activity +- Flag queue at top: sorted by oldest flag first +- Live Session Mode: sidebar collapses via `useSidebar()` hook (already available), view narrows to validation flow only + +## User Journey Flows + +### Journey 1 — Consultant First Login + +The first time a consultant opens SkillForge. The product's core promise is validated or broken here. + +```mermaid +flowchart TD + A([Email link opened]) --> B[Authentication — OpenIddict/JWT] + B --> C{Goals pre-set by coach?} + C -- Yes --> D[Redirect → Roadmap view] + C -- No --> E[Holding state:\n'Nathalie is preparing your roadmap —\ncheck back soon'] + D --> F[Roadmap renders:\npage title 'Lea's Roadmap'\nSeniority bar: '14/18 Medior'\n3 active goal rows visible] + F --> G[Consultant reads goal row:\nSkill · Niveau · 'Set by Nathalie' · Resources count] + G --> H[Click resource link] + H --> I[Resource opens — learning begins] + I --> J{Skill practiced enough?} + J -- Not yet --> H + J -- Ready --> K[Mark goal 'I'm ready' →\nflag raised · state changes to Apricot] + K --> L([Journey complete — loop continues]) + E --> M([Consultant bookmarks and returns later]) +``` + +**Key UX decisions:** +- Redirect target is Roadmap, never a generic dashboard +- Holding state uses coach name ("Nathalie"), never a system message +- Seniority bar is ambient — below title, never dominant +- No empty states, no setup prompts, no guided tours + +--- + +### Journey 2 — Consultant Raises Readiness Flag + +Consultant believes they are ready for a skill to be validated. They act; the coach is notified. + +```mermaid +flowchart TD + A([Consultant on Roadmap]) --> B[Active goal row visible:\nSkill · Niveau · Coach · Resources] + B --> C[Click 'I'm ready' button] + C --> D{Confirmation needed?} + D -- No --> E[Instant state change:\nApricot badge appears on row\n'Ready for validation — raised today'] + E --> F[No modal, no form — single action] + F --> G[Coach dashboard alert created] + G --> H[Aging indicator starts:\n'Raised 1 day ago' → '3 days ago'] + H --> I{Coach responds?} + I -- Yes → scheduled --> J[Session booked —\nconsultant notified] + I -- No response → ages --> K[Factual label: 'Raised 5 days ago'\nNeutral tone — no alarm] + J --> L([Session Journey begins]) + K --> M([Consultant checks status;\ncoach queue grows]) +``` + +**Key UX decisions:** +- Single-click flag — no confirmation dialog (low-stakes action, easy to raise again) +- Apricot `#F29E81` is the readiness colour — warm, pending, not urgent +- Aging copy is factual, never guilt-inducing ("5 days ago" not "overdue") +- No email notification for MVP; coach sees flag on next dashboard open + +--- + +### Journey 3 — Coach Dashboard Scan + +Coach opens SkillForge to review team status. Must complete in under 30 seconds for a typical 10-person team. + +```mermaid +flowchart TD + A([Coach opens dashboard]) --> B[Flag queue at top:\nsorted oldest flag first\nCompact rows: avatar · name · skill · days waiting] + B --> C{Anyone in queue?} + C -- No flags --> D[Team rows below:\nname · last activity · goals in progress] + C -- Flags present --> E[Coach reads oldest flag:\n'Lea — Backend Medior — Ready — 3 days'] + E --> F[Click consultant row] + F --> G[Consultant detail view:\nActivity timeline · Current goals · Flag history] + G --> H{Coach decision} + H -- Schedule session --> I[Book slot — send link] + H -- Open Live Session Mode --> J[Sidebar collapses\nSession flow activates] + H -- Not ready yet --> K[Leave flag — returns to queue] + I --> L([Consultant notified]) + J --> M([Session Journey begins]) + D --> N([Coach satisfied — exits or drills down]) +``` + +**Key UX decisions:** +- Flag queue sorted oldest-first — prevents flags from aging invisibly +- 30-second scan target: compact rows, no loading states, coach dashboard is server-rendered +- Coach can go from queue → session mode in 2 clicks (flag row → Live Session Mode) +- "Not ready yet" is a valid path — no friction to defer + +--- + +### Journey 4 — Live Session Validation + +Coach and consultant in a live session. Coach validates a skill niveau change in real time. + +```mermaid +flowchart TD + A([Live Session Mode activated]) --> B[Sidebar collapses to icon strip\nConsultant profile visible:\nName · Current niveau · Active goals] + B --> C[Coach selects skill to validate] + C --> D[Skill detail panel:\nCurrent niveau highlighted · Evidence shown] + D --> E[Coach taps current niveau badge] + E --> F[Niveau selector appears:\nAll levels shown · Current highlighted] + F --> G[Coach taps new niveau] + G --> H{Optional inline note?} + H -- Add note --> I[One-line text field:\n'Lea demonstrated X clearly'\nEnter to confirm] + H -- Skip --> J + I --> J[Set new goal?\nOptional: link next skill to work on] + J --> K[Confirm validation] + K --> L[State change committed:\nSkill moves to Validated · Dark Leaf #2E8F6B\nSeniority counter updates] + L --> M[Exit Live Session Mode] + M --> N[Confirmation banner:\n'Lea — Backend Medior validated'\nAuto-dismiss 4 seconds] + N --> O[Consultant Roadmap updates immediately:\nValidated skill moves to archive grid\nNew goal appears if set] + O --> P([Session complete]) +``` + +**Key UX decisions:** +- Sidebar collapse during session removes distraction — full focus on validation flow +- Niveau selection is tap-target optimised — large enough for touchscreen if coach is standing +- Inline note is optional — no friction for quick validations; available for coaches who prefer records +- Confirmation banner auto-dismisses (4s) — coach doesn't need to click "OK" +- Consultant roadmap updates in real time — if consultant is watching, they see the change + +--- + +### Journey Patterns + +Patterns observed across all four journeys that should be standardised across the entire product: + +**Single-action pattern:** +Every primary action is one click with no confirmation dialog. Raise readiness flag (one click), validate niveau (two taps: skill → niveau). Confirmations are reserved for destructive actions only (delete). This eliminates the "are you sure?" friction that slows repeated use. + +**State transparency pattern:** +Every state change is immediately visible and attributed. The consultant sees "Set by Nathalie" (attribution). The coach sees "3 days ago" (temporal fact). The system never hides who did what or when. No ambiguous "pending" states without a named owner. + +**Attribution everywhere:** +Every piece of content carries a human name. Goals show the coach who set them. Validated skills show the coach who validated them. Sessions show the date. The product is about human relationships — the UI reflects this by making every action traceable to a person, not a system. + +**Edge case communication pattern:** +When the system cannot fulfil its promise (coach hasn't set goals yet, session not scheduled), the communication is: +1. A human name (not "the system" or "SkillForge") +2. A present-tense action in progress ("is preparing") +3. A clear next step ("check back soon") +Never: "No data available", "Nothing here yet", empty states. + +**Density mode switching:** +Roadmap view (consultant) = generous spacing — deciding mode. +Dashboard view (coach) = compact rows — scanning mode. +Session view (coach) = collapsed chrome — focused mode. +The product has three UX registers and switches between them by role + view, not by user preference setting. + +--- + +### Flow Optimization Principles + +**1. Minimise steps to value** +Each journey completes its core action in ≤3 steps. First login: land → read → click resource (3 steps). Readiness flag: see goal → click "I'm ready" (2 steps). These step counts are hard constraints — any design that adds a step must justify it explicitly. + +**2. Reduce cognitive load at decision points** +At every decision point, present the minimum required information. Coach dashboard rows show exactly 4 data points: name, skill, readiness status, days waiting. Not 10 fields. The scan decision is made on 4 facts. + +**3. Provide clear feedback and progress** +Every state change produces an immediate visual response (colour change, badge appearance, counter update). No action goes unconfirmed. The seniority counter `14/18 Medior` gives progress at a glance without requiring navigation. + +**4. Create moments of accomplishment** +When a skill is validated, the Dark Leaf colour change + confirmation banner is a designed moment of accomplishment — both for the coach (work done) and the consultant (growth visible). These moments are deliberately satisfying, not merely functional. + +**5. Handle error and edge cases gracefully** +Every journey has a named path for when the expected state doesn't exist: holding state (no goals), deferral (flag not acted on), optional note (session with or without record). Edge cases communicate care, not system failure. + +## Component Strategy + +### Design System Components + +Foundation layer — `@itenium-forge/ui` (the app's existing component library, wrapping Radix UI with itenium tokens). Import from this package, not from raw Radix or shadcn. + +| Component | Import | Usage in SkillForge | +|---|---|---| +| `Button` | `@itenium-forge/ui` | "I'm ready", "Validate", nav actions | +| `Avatar`, `AvatarFallback` | `@itenium-forge/ui` | Coach/consultant avatars throughout | +| `Card`, `CardHeader`, `CardContent` | `@itenium-forge/ui` | Goal cards, metric tiles | +| `Dialog` | `@itenium-forge/ui` | Delete confirmations only | +| `DropdownMenu` | `@itenium-forge/ui` | User menu, team switcher | +| `ScrollArea` | `@itenium-forge/ui` | Sidebar nav, goal list on long roadmaps | +| `Input` | `@itenium-forge/ui` | Inline session note, admin forms | +| `Sidebar*` components | `@itenium-forge/ui` | Navigation shell (already in Layout.tsx) | +| Progress (Radix) | `@radix-ui/react-progress` | SeniorityBar — not yet in ui package | +| Popover (Radix) | `@radix-ui/react-popover` | Inline notes, resource previews | +| Tooltip (Radix) | `@radix-ui/react-tooltip` | Truncated text, "Set by Nathalie" hover | +| Tabs (Radix) | `@radix-ui/react-tabs` | Coach dashboard: Team / Sessions | +| Toast | **Sonner** (`sonner` package) | ValidationBanner, system feedback — NOT Radix Toast | + +### Custom Components + +#### GoalRow + +**Purpose:** The consultant's primary work item — the central component of SkillForge. Answers "what do I work on and why?" in a single row. + +**Anatomy:** +``` +[ state-border ] [ skill name ] [ niveau: Junior → Medior ] [ Set by Nathalie ] [ 3 resources ] [ I'm ready ] +``` + +**States:** +- `in-progress` — Teal `#6EBCA5` left border, default view +- `ready` — Apricot `#F29E81` left border + badge "Ready for validation — raised N days ago" +- `validated` — Dark Leaf `#2E8F6B` left border, muted text, moved to archive on next render +- `locked` — Dune-20 `#D4D0CE` border, "I'm ready" disabled, dependency warning inline in Amber + +**Variants:** `active` (full row, max 3 shown by default) · `archived` (compact, read-only, validated skills grid) + +**Accessibility:** `role="listitem"`, "I'm ready" has `aria-label="Mark [Skill Name] ready for validation"`, state communicated via `aria-label` on border not colour alone. + +**Interaction:** Single-click "I'm ready", no confirmation dialog. Instant optimistic update — row re-renders to `ready` state in place (no positional jump). + +--- + +#### SeniorityBar + +**Purpose:** Ambient progress indicator showing the consultant's position on the seniority scale. Factual, not a task to complete. + +**Anatomy:** `[ 14 / 18 Medior ██████████████░░░░░░ 78% ]` + +**States:** `loading` (skeleton, no layout shift) · `populated` · `level-up` (brief Gold flash when threshold crossed — subtle, no confetti) + +**Variants:** Standard (below roadmap title) · Compact (sidebar footer — abbreviated "Medior 78%") + +**Note:** Built on `@radix-ui/react-progress`. Custom wrapper adds label + level name display. + +--- + +#### FlagQueueRow + +**Purpose:** Coach's primary scanning unit. 30-second team scan target — 4 facts at 12px padding, no visual noise. + +**Anatomy:** +``` +[ Avatar ] [ Lea De Smedt ] [ Backend – Junior→Medior ] [ Apricot: Ready ] [ 3 days ago ] +``` + +**States:** `flagged` (Apricot accent, bold days label) · `active` (in-progress, muted) · `session-pending` (Gold accent) · `no-activity` (Dune-40) + +**Variants:** `compact` (coach dashboard default, 12px padding) · `expanded` (inline goal list + activity timeline on click — no page navigation) + +**Accessibility:** `role="row"` in `role="grid"` structure. Status column `aria-label="3 days since readiness flag raised"`. + +--- + +#### NiveauSelector + +**Purpose:** Coach's validation tool in Live Session Mode. Tap-target optimised (coach may be standing with tablet). + +**Anatomy:** +``` +┌──────────┬──────────┬──────────┬──────────┐ +│ Junior │ Medior │ Senior │ Expert │ +│ [curr] │ │ │ │ +└──────────┴──────────┴──────────┴──────────┘ +``` + +**States:** Current niveau (Gold bg `#FEF0DC`, Gold border) · Selectable (Dune-10, hover Soap-dark) · Selected pending (Dark Leaf border, Leaf bg) · Disabled (Dune-20) + +**Variants:** `4-level` (default) · `5-level` (if matrix requires intermediate levels) + +**Accessibility:** `role="radiogroup"`, each niveau `role="radio"`. Arrow keys navigate, Space selects. `aria-label="Select validated niveau for [Skill Name]"`. + +--- + +#### ValidationBanner + +**Purpose:** Post-validation confirmation that auto-dismisses. Designed moment of accomplishment for coach and consultant. + +**Anatomy:** `[ ✓ ] Lea De Smedt — Backend Medior validated [ × ]` + +**States:** `success` (Dark Leaf icon + Leaf background `#EAF5EA`) · `error` (Dune icon + error message, retry) + +**Behaviour:** Auto-dismiss at 4 seconds with visible countdown underline. Manual dismiss via ×. Built on `@radix-ui/react-toast`. + +**Accessibility:** `role="status"`, `aria-live="polite"`. Does not interrupt focus. + +--- + +#### SessionPanel + +**Purpose:** Content container for Live Session Mode — sidebar collapsed, full focus on validation flow. + +**Anatomy:** +``` +┌──────────────────────────────────┐ +│ [ ← ] Lea De Smedt · Medior 78% │ +│ ─────────────────────────────── │ +│ Active Goals (GoalRow compact) │ +│ ─────────────────────────────── │ +│ Validate Skill │ +│ [ NiveauSelector ] │ +│ [ Inline note input ] │ +│ [ Confirm Validation ] │ +└──────────────────────────────────┘ +``` + +**States:** `idle` (goals shown) · `validating` (NiveauSelector visible) · `confirmed` (ValidationBanner triggered, panel resets) + +**Accessibility:** On session mode activate, focus moves to panel heading. `aria-label="Live Session with Lea De Smedt"`. + +--- + +#### ResourceLink + +**Purpose:** Inline resource reference within a GoalRow. Single click to open; communicates type at a glance. + +**Anatomy:** `[ icon ] Resource Title ↗` (opens in new tab) + +**Variants:** `article` · `video` · `course` · `document` — different icon per type, same layout. + +--- + +### Component Implementation Strategy + +**Composition principle:** All custom components composed from Radix UI primitives + Tailwind classes. No custom CSS files — all styling via `cn()` (classnames utility) with Tailwind token references. + +**Token usage:** Components reference Tailwind semantic tokens (which map to CSS variables in `styles.css`), never raw hex values: +```ts +// ✓ Correct — Tailwind semantic tokens +className="bg-background text-foreground border-border" +className="bg-primary text-primary-foreground" +className="text-muted-foreground" + +// ✓ Correct — SkillForge custom state tokens (arbitrary CSS var) +className="bg-[--skill-in-progress-bg] border-l-4 border-l-[--skill-in-progress]" + +// ✗ Wrong — raw hex +className="bg-[#E0F3EE] border-[#6EBCA5] text-[#2D2A28]" +``` + +**State ownership:** Component state (in-progress, ready, validated, locked) is a prop, not internal state. Parent view owns state — components are pure display. + +**File structure:** +``` +src/components/ + skillforge/ ← Custom domain components (GoalRow, FlagQueueRow, NiveauSelector…) + layout/ ← SessionPanel (AppShell + SidebarNav already exist in Layout.tsx) +``` +Note: `ui/` directory is not needed — all primitives come from `@itenium-forge/ui`. Do not re-wrap Radix primitives that are already in the package. + +### Implementation Roadmap + +**Phase 1 — MVP Critical** (needed for 4 core journeys): + +1. `GoalRow` — First Login + Readiness Flag journeys +2. `SeniorityBar` — ambient on Roadmap, always visible after login +3. `FlagQueueRow` — Coach Dashboard Scan journey +4. `NiveauSelector` — Live Session Validation journey +5. `ValidationBanner` — post-validation confirmation (wraps **Sonner** `toast()`) +6. `ResourceLink` — inline in GoalRow (simple, fast to build) + +**Phase 2 — Supporting** (full session and layout experience): + +7. `SessionPanel` — layout container for Live Session Mode +8. Extend `Layout.tsx` nav groups for SkillForge-specific routes (AppShell already exists) + +**Phase 3 — Enhancement** (polish and edge cases): + +9. `SkillNode` — full skill graph/tree view (post-MVP) +10. `DensityModeToggle` — optional coach preference switch (if feedback requests it) +11. Holding state variant for `GoalRow` (no coach setup yet) — visual treatment, not a new component diff --git a/docs/skillmatrices/Developer_Skill_Experience_Matrix.xlsx b/docs/skillmatrices/Developer_Skill_Experience_Matrix.xlsx new file mode 100644 index 0000000..9da8fe0 Binary files /dev/null and b/docs/skillmatrices/Developer_Skill_Experience_Matrix.xlsx differ diff --git a/docs/skillmatrices/Skill_Matrix_Itenium.xlsx b/docs/skillmatrices/Skill_Matrix_Itenium.xlsx new file mode 100644 index 0000000..02fda55 Binary files /dev/null and b/docs/skillmatrices/Skill_Matrix_Itenium.xlsx differ diff --git a/memes/ai-assistant-be-like.png b/memes/ai-assistant-be-like.png new file mode 100644 index 0000000..c78d0cc Binary files /dev/null and b/memes/ai-assistant-be-like.png differ diff --git a/memes/ai-replaced.jpg b/memes/ai-replaced.jpg new file mode 100644 index 0000000..0957d55 Binary files /dev/null and b/memes/ai-replaced.jpg differ diff --git a/memes/before-after-chatgpt.jpg b/memes/before-after-chatgpt.jpg new file mode 100644 index 0000000..dc55190 Binary files /dev/null and b/memes/before-after-chatgpt.jpg differ diff --git a/memes/compilers.jpg b/memes/compilers.jpg new file mode 100644 index 0000000..39c5115 Binary files /dev/null and b/memes/compilers.jpg differ diff --git a/memes/great-code.png b/memes/great-code.png new file mode 100644 index 0000000..14696dd Binary files /dev/null and b/memes/great-code.png differ diff --git a/memes/layoffs.jpg b/memes/layoffs.jpg new file mode 100644 index 0000000..eee5fa0 Binary files /dev/null and b/memes/layoffs.jpg differ diff --git a/memes/learn-python.jpg b/memes/learn-python.jpg new file mode 100644 index 0000000..6627a7b Binary files /dev/null and b/memes/learn-python.jpg differ diff --git a/memes/levels-of-vibe.jfif b/memes/levels-of-vibe.jfif new file mode 100644 index 0000000..153ceb0 Binary files /dev/null and b/memes/levels-of-vibe.jfif differ diff --git a/memes/non-technical-vibing.png b/memes/non-technical-vibing.png new file mode 100644 index 0000000..c5b2893 Binary files /dev/null and b/memes/non-technical-vibing.png differ diff --git a/memes/starts-at-0.jpg b/memes/starts-at-0.jpg new file mode 100644 index 0000000..b9ee6f9 Binary files /dev/null and b/memes/starts-at-0.jpg differ diff --git a/memes/the-spec.webp b/memes/the-spec.webp new file mode 100644 index 0000000..89a70a9 Binary files /dev/null and b/memes/the-spec.webp differ diff --git a/memes/vibe-coding.png b/memes/vibe-coding.png new file mode 100644 index 0000000..0e9b51f Binary files /dev/null and b/memes/vibe-coding.png differ diff --git a/memes/vibe-coding2.png b/memes/vibe-coding2.png new file mode 100644 index 0000000..9ede80a Binary files /dev/null and b/memes/vibe-coding2.png differ diff --git a/memes/what-its-like.webp b/memes/what-its-like.webp new file mode 100644 index 0000000..2b5d6b6 Binary files /dev/null and b/memes/what-its-like.webp differ