diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8d3e36b..4802054 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,8 +33,8 @@ jobs: # converged while leaving the old binary in place. prime-module-proxy: true - api-publish: - name: Publish the API image and its manifests + publish: + name: Publish the API and web images and their manifests needs: release # The version is empty on a push that cut no release. if: needs.release.outputs.version != '' @@ -50,6 +50,7 @@ jobs: env: VERSION: ${{ needs.release.outputs.version }} IMAGE: ghcr.io/${{ github.repository }} + WEB_IMAGE: ghcr.io/${{ github.repository }}-web MANIFESTS: ghcr.io/${{ github.repository }}/manifests # A named bash runs with pipefail. defaults: @@ -71,7 +72,8 @@ jobs: # A version already on ghcr is never rebuilt, so one tag names one image, # and :latest moves only to the newest version. Only "not found" builds: # any other error fails the step, so a transient one never republishes a - # version under a new digest. + # version under a new digest. Each image is decided alone, so a re-run + # after one of the two pushed builds only the other. - name: Decide what to publish id: publish run: | @@ -82,20 +84,27 @@ jobs: else echo "latest=false" >>"$GITHUB_OUTPUT" fi - if answer=$(docker buildx imagetools inspect "$IMAGE:$VERSION" 2>&1 >/dev/null); then - echo "::notice::$IMAGE:$VERSION is already published" - echo "build=false" >>"$GITHUB_OUTPUT" - elif grep -q ': not found$' <<<"$answer"; then - echo "build=true" >>"$GITHUB_OUTPUT" - else + published() { + local answer + if answer=$(docker buildx imagetools inspect "$1" 2>&1 >/dev/null); then + echo "::notice::$1 is already published" + return 0 + elif grep -q ': not found$' <<<"$answer"; then + return 1 + fi echo "$answer" >&2 exit 1 - fi + } + build_api=true build_web=true + if published "$IMAGE:$VERSION"; then build_api=false; fi + if published "$WEB_IMAGE:$VERSION"; then build_web=false; fi + echo "api=$build_api" >>"$GITHUB_OUTPUT" + echo "web=$build_web" >>"$GITHUB_OUTPUT" # The version for a deployment to pin, :sha- for a person to # trace, and :latest for `docker run`. A manifest names the digest. - name: Resolve the tags - if: steps.publish.outputs.build == 'true' + if: steps.publish.outputs.api == 'true' id: meta uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: @@ -109,7 +118,7 @@ jobs: # The provenance and SBOM attestations ride in the image's index, so the # digest a manifest pins carries them. - name: Build and push - if: steps.publish.outputs.build == 'true' + if: steps.publish.outputs.api == 'true' id: build uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: @@ -124,19 +133,60 @@ jobs: cache-from: type=gha cache-to: type=gha,mode=max + # The web image is tagged as the API's is, under the same version. + - name: Resolve the web image's tags + if: steps.publish.outputs.web == 'true' + id: web-meta + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 + with: + images: ${{ env.WEB_IMAGE }} + flavor: latest=false + tags: | + type=raw,value=${{ env.VERSION }} + type=sha,format=short + type=raw,value=latest,enable=${{ steps.publish.outputs.latest == 'true' }} + + # Its own cache scope, because two builds writing the default scope + # replace each other's cache on every release. + - name: Build and push the web image + if: steps.publish.outputs.web == 'true' + id: web-build + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 + with: + context: web + platforms: linux/amd64 + push: true + tags: ${{ steps.web-meta.outputs.tags }} + labels: ${{ steps.web-meta.outputs.labels }} + provenance: mode=max + sbom: true + cache-from: type=gha,scope=web + cache-to: type=gha,mode=max,scope=web + # A re-run that built nothing reads the published version's digest. - - name: Name the image by its digest + - name: Name each image by its digest id: image env: BUILT: ${{ steps.build.outputs.digest }} + WEB_BUILT: ${{ steps.web-build.outputs.digest }} run: | - pinned=$BUILT - if [ -z "$pinned" ]; then - pinned=$(docker buildx imagetools inspect "$IMAGE:$VERSION" --format '{{json .Manifest}}' | jq -r .digest) - fi - [[ $pinned == sha256:* ]] || { echo "no digest for $IMAGE:$VERSION: '$pinned'" >&2; exit 1; } - echo "ref=$IMAGE@$pinned" >>"$GITHUB_OUTPUT" - echo "Image \`$IMAGE@$pinned\`" >>"$GITHUB_STEP_SUMMARY" + pin() { + local pinned=$2 + if [ -z "$pinned" ]; then + pinned=$(docker buildx imagetools inspect "$1:$VERSION" --format '{{json .Manifest}}' | jq -r .digest) + fi + [[ $pinned == sha256:* ]] || { echo "no digest for $1:$VERSION: '$pinned'" >&2; exit 1; } + echo "$1@$pinned" + } + api=$(pin "$IMAGE" "$BUILT") + web=$(pin "$WEB_IMAGE" "$WEB_BUILT") + echo "ref=$api" >>"$GITHUB_OUTPUT" + echo "web_ref=$web" >>"$GITHUB_OUTPUT" + { + echo "API image \`$api\`" + echo + echo "Web image \`$web\`" + } >>"$GITHUB_STEP_SUMMARY" - name: Install flux, kustomize and cosign run: | @@ -150,9 +200,10 @@ jobs: id: manifests env: IMAGE_REF: ${{ steps.image.outputs.ref }} + WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }} run: | work=$RUNNER_TEMP/manifests - release/pin-manifests --image "$IMAGE_REF" --version "$VERSION" --out "$work" + release/pin-manifests --image "$IMAGE_REF" --web-image "$WEB_IMAGE_REF" --version "$VERSION" --out "$work" pushed=$(flux push artifact "oci://$MANIFESTS:$VERSION" --path="$work" \ --source="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY" --revision="$VERSION@sha1:$GITHUB_SHA" \ --reproducible --output=json | jq -r .digest) @@ -162,25 +213,28 @@ jobs: # Keyless, so the signing certificate names this workflow on this ref and # no key exists to keep. A deployment verifies the artifact against that - # identity before applying it. The verify proves the pair the release - # notes print is the one a consumer can check. A re-run signs again, and + # identity before applying it. The verify proves the refs the release + # notes print are the ones a consumer can check. A re-run signs again, and # either signature verifies. - - name: Sign the image and the manifests + - name: Sign the images and the manifests env: IMAGE_REF: ${{ steps.image.outputs.ref }} + WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }} MANIFESTS_REF: ${{ steps.manifests.outputs.ref }} run: | cosign sign --yes "$IMAGE_REF" + cosign sign --yes "$WEB_IMAGE_REF" cosign sign --yes "$MANIFESTS_REF" cosign verify --certificate-identity "$GITHUB_SERVER_URL/$GITHUB_WORKFLOW_REF" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ - "$IMAGE_REF" "$MANIFESTS_REF" >/dev/null + "$IMAGE_REF" "$WEB_IMAGE_REF" "$MANIFESTS_REF" >/dev/null # A re-run finds the section already written, and leaves it. - name: Name what was published in the release notes env: GH_TOKEN: ${{ github.token }} IMAGE_REF: ${{ steps.image.outputs.ref }} + WEB_IMAGE_REF: ${{ steps.image.outputs.web_ref }} MANIFESTS_REF: ${{ steps.manifests.outputs.ref }} run: | notes=$RUNNER_TEMP/notes.md @@ -190,7 +244,8 @@ jobs: echo echo '## Published' echo - echo "- Image: \`$IMAGE_REF\`" + echo "- API image: \`$IMAGE_REF\`" + echo "- Web image: \`$WEB_IMAGE_REF\`" echo "- Manifests: \`$MANIFESTS_REF\`, with \`migrate/\` to apply before \`app/\`" } >>"$notes" gh release edit "v$VERSION" --notes-file "$notes" diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index e0ff056..c15819a 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -71,6 +71,61 @@ jobs: go install github.com/go-task/task/v3/cmd/task@v3.52.0 task build + vue-web: + runs-on: ubuntu-26.04 + defaults: + run: + working-directory: web + steps: + + - uses: actions/checkout@v7 + + # generated:vue-web + # Shared by the vue job and the hooks job, whose vue-eslint, vue-prettier + # and vue-typecheck hooks run npm scripts in the component. + - uses: actions/setup-node@v7 + with: + # The repo's own file, at the workspace root, because the Node major + # also lives in each Dockerfile's FROM line and a pin here would be a + # third copy. The ci die refuses to write this job without the file, + # since setup-node fails before installing anything. + node-version-file: .nvmrc + cache: npm + # A glob rather than web/package-lock.json, because the two + # layouts in use keep the lock in different places: one lock per + # component beside its package.json, or an npm workspace with a + # single lock at the repo root. setup-node fails outright when a + # named path does not resolve, so a workspace component with no lock + # of its own kills the job before npm ci. + # + # Action inputs resolve from the workspace root, not the job's + # working-directory, so this is not relative to the component. + cache-dependency-path: "**/package-lock.json" + + # An npm workspace has to install from the root: the lock covers every + # workspace, and npm ci inside one refuses to run without a lock beside + # it. Falling back keeps the per-component layout installing in place. + # + # The directory is named on the step because the hooks job has no + # working-directory of its own, and installs for every component. + - working-directory: "web" + run: | + if [ -f package-lock.json ]; then + npm ci + else + cd "$GITHUB_WORKSPACE" && npm ci + fi + + - run: npm run build + + # test:unit where the component has one, because test is where a + # frontend hangs its browser run or a second build; otherwise test. A + # component with neither has no suite, which is not a failure. + - name: Test + run: | + script=$(node -p "const s = require('./package.json').scripts ?? {}; s['test:unit'] ? 'test:unit' : s.test ? 'test' : ''") + if [ -n "$script" ]; then npm run "$script"; fi + hooks: runs-on: ubuntu-26.04 steps: @@ -88,6 +143,41 @@ jobs: cache: true cache-dependency-path: "./go.sum" + # Shared by the vue job and the hooks job, whose vue-eslint, vue-prettier + # and vue-typecheck hooks run npm scripts in the component. + - uses: actions/setup-node@v7 + with: + # The repo's own file, at the workspace root, because the Node major + # also lives in each Dockerfile's FROM line and a pin here would be a + # third copy. The ci die refuses to write this job without the file, + # since setup-node fails before installing anything. + node-version-file: .nvmrc + cache: npm + # A glob rather than web/package-lock.json, because the two + # layouts in use keep the lock in different places: one lock per + # component beside its package.json, or an npm workspace with a + # single lock at the repo root. setup-node fails outright when a + # named path does not resolve, so a workspace component with no lock + # of its own kills the job before npm ci. + # + # Action inputs resolve from the workspace root, not the job's + # working-directory, so this is not relative to the component. + cache-dependency-path: "**/package-lock.json" + + # An npm workspace has to install from the root: the lock covers every + # workspace, and npm ci inside one refuses to run without a lock beside + # it. Falling back keeps the per-component layout installing in place. + # + # The directory is named on the step because the hooks job has no + # working-directory of its own, and installs for every component. + - working-directory: "web" + run: | + if [ -f package-lock.json ]; then + npm ci + else + cd "$GITHUB_WORKSPACE" && npm ci + fi + # Every hook in the repo's committed pre-commit config. A push from a # checkout that never ran `pre-commit # install`, or a merge in the web UI, passes every hook by not running it, @@ -301,6 +391,9 @@ jobs: go-build-repo-mod go-mod-tidy-repo go-test-repo-mod + vue-eslint + vue-prettier + vue-typecheck hadolint jactionlint sqlfluff-lint @@ -317,7 +410,7 @@ jobs: done exit "$status" -# > custom:after:all - Kubernetes manifests +# > custom:after:all - Kubernetes manifests, the images, and the web smoke # # The release publishes deploy/ as the base a cluster applies, and calls this # workflow as its gate, so a base that fails here is never pushed. @@ -338,7 +431,7 @@ jobs: echo "$RUNNER_TEMP/bin" >>"$GITHUB_PATH" # release/pin-manifests pins and labels every path as the release does, - # with the image named by a digest of zeros. kube-linter refuses an image + # with each image named by a digest of zeros. kube-linter refuses an image # with no tag or digest, which the committed base carries until the # release pins it. # @@ -352,7 +445,8 @@ jobs: SCHEMAS: https://raw.githubusercontent.com/yannh/kubernetes-json-schema/f7d855e49c34b039c9ef2196444910275e31e09e run: | zeros=$(printf '0%.0s' {1..64}) - release/pin-manifests --image "ghcr.io/$GITHUB_REPOSITORY@sha256:$zeros" --version 0.0.0 \ + release/pin-manifests --image "ghcr.io/$GITHUB_REPOSITORY@sha256:$zeros" \ + --web-image "ghcr.io/$GITHUB_REPOSITORY-web@sha256:$zeros" --version 0.0.0 \ --out "$RUNNER_TEMP/manifests" >"$RUNNER_TEMP/paths" status=0 while IFS= read -r path; do @@ -376,3 +470,38 @@ jobs: - name: Build the image without pushing it run: docker build --build-arg PROJECTS_VERSION=0.0.0 . + + # Builds the web image as the release will, then drives one roundtrip through + # the stack it ships in: Caddy's routes, the browser login against a stand-in + # identity provider, and a project named with a slash reaching the API. The + # vue-web job's unit tests reach none of those. + web-stack: + name: Web image and its smoke + runs-on: ubuntu-26.04 + timeout-minutes: 20 + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: web/package-lock.json + + - run: npm ci + + - name: Build and start the stack + working-directory: . + run: docker compose -f docker-compose.dev.yml up --build --wait + + - run: npx playwright install --with-deps chromium + + - run: npx playwright test + + - name: Print the stack's logs + if: failure() + working-directory: . + run: docker compose -f docker-compose.dev.yml logs --no-color diff --git a/.gitignore b/.gitignore index b42e4fe..8af13af 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,13 @@ vendor/ # Task .task/ +# The web app's dependencies, build and Playwright output +node_modules/ +dist/ +*.tsbuildinfo +test-results/ +playwright-report/ + # SQLite *.db *.db-journal diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 31b4312..661d85d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -294,6 +294,35 @@ repos: pass_filenames: false stages: [pre-commit] +# generated:vue - Vue/Frontend + # Every hook runs from the component directory, so filenames are never passed: + # pre-commit hands them out repo-root-relative, which resolves to the wrong + # place the moment the command has cd'd. files: still gates whether it runs. + - repo: local + hooks: + - id: vue-eslint + name: eslint (vue) + entry: bash -c 'cd web && npm run lint:fix' + language: system + pass_filenames: false + files: '^web/.*\.(vue|js|ts|jsx|tsx)$' + + - id: vue-prettier + name: prettier (vue) + entry: bash -c 'cd web && npx prettier --write .' + language: system + pass_filenames: false + files: '^web/.*\.(vue|js|ts|jsx|tsx|css|scss|json)$' + + - id: vue-typecheck + name: typecheck (vue) + # --if-present: a plain-JS Vue app has nothing to typecheck, and that is + # a true answer rather than a gap. Every other hook here stays strict. + entry: bash -c 'cd web && npm run --if-present typecheck' + language: system + pass_filenames: false + files: '^web/.*\.(vue|ts|tsx)$' + # generated:docker - Docker # # hadolint-py installs hadolint's own release binary through pip and checks diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..7b8dbb0 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,25 @@ +# forge-managed +# +# Generated files. Prettier must not rewrite them. +# +# The vue hook runs `cd {{dir}} && npx prettier --write .` with +# pass_filenames: false, so `files:` decides whether the hook runs, not what it +# touches — prettier formats the whole of {{dir}}. Where a repo keeps its Vue +# app in a subdirectory that is harmless. Where the component sits at the repo +# root, prettier reaches the generated files: it reindents every `# generated:` +# marker in .pre-commit-config.yaml and requotes node-version in the CI +# workflow, and the generator rewrites them back on its next run. Two writers on +# one file have no fixed point, and the repo drifts on every run of either tool. +# +# Ownership decides, not formatting preference: these files are generated, and +# the generator is the only thing entitled to their shape. +# +# The generator adopts a deployed copy only while its entries match the +# template, so an entry swap lands only once every copy carries the marker +# above. +.pre-commit-config.yaml +.github/workflows/validate.yml +.markdownlint.json +.editorconfig +.golangci.yml +.sqlfluff diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000..59169c2 --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,25 @@ +# forge-managed +# +# Deployed as .prettierrc.yaml. +# +# YAML rather than JSON so the file can carry the marker on its first line. JSON +# has no comment syntax, and an unknown key is not a substitute here: prettier +# honors the rest of the config but prints `Ignored unknown option` to stderr on +# every run, in every repo that has one. +# +# prettier prefers .prettierrc.json when both are present, so a repo still +# holding the JSON spelling has it removed in the same run that writes this one. + +printWidth: 140 +tabWidth: 2 +useTabs: false +semi: false +singleQuote: true +trailingComma: all +bracketSpacing: true +bracketSameLine: true +arrowParens: always +endOfLine: lf +htmlWhitespaceSensitivity: ignore +singleAttributePerLine: true +vueIndentScriptAndStyle: false diff --git a/CLAUDE.md b/CLAUDE.md index 05c1748..e99d2be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,7 +1,8 @@ # projects — Claude Code instructions The main package is at the module root — `go build .`, `go install github.com/datapointchris/projects@latest`. -`api/main.go` is the HTTP API, `projects-api`, which `go install` of the root does not build. +`api/main.go` is the HTTP API, `projects-api`, which `go install` of the root does not build. `web/` is +the browser page over that API, in Vue, and nothing in Go imports it. ## How to run and test @@ -397,16 +398,67 @@ per test. Set `TEST_DATABASE_URL` to use a running server. `-short` skips them, `TestEveryTableIsAuditedButWhatOptsOut` fails. - **A kind or status is a row in `model.ProjectKinds` or `model.ProjectStatuses`**, and in SQLite's `lookup_rows.sql`. `postgres.Migrate` upserts Postgres's from `model`, and - `TestEachStoreHoldsTheKnownKindsAndStatuses` holds both stores to it. + `TestEachStoreHoldsTheKnownKindsAndStatuses` holds both stores to it. The project schemas' `enum` + and the web app's `types.ts` list them too, and a test holds each to `model`. - **Item numbers come from the `item_numbers` sequence**, through `AssigningItemNumbers`. A database loaded from elsewhere needs the sequence set from the old sequence's own counter, because numbers it already handed out can outrun the rows left. - **`.sqlfluffignore` keeps the SQLite hook off `store/postgres`**, and a second hook in `.pre-commit-config.yaml` lints it in its own dialect, because the config holds one dialect. -- **A release publishes the image and manifests at the tag `go-semantic-release` cut**, in `release.yml`. - One version names both the TUI and the API, because a rule change in `service` moves both. A new - directory the binary imports joins `.dockerignore`'s allowlist, or `validate.yml`'s image build fails - the pull request. +- **A release publishes both images and the manifests at the tag `go-semantic-release` cut**, in + `release.yml`. One version names the TUI, the API and the web app, because a rule change in `service` + moves all three. A new directory the binary imports joins `.dockerignore`'s allowlist, or + `validate.yml`'s image build fails the pull request. `release/pin-manifests` refuses a built path + running an image no flag pinned, so a new image in `deploy/` takes a flag there. + +## The web app is one page on the API's origin, and the API logs the browser in + +`web/` is Vue 3, Pinia and Vite, with no router: `ProjectsView` is the whole app, at `/`. Its styles in +`src/assets/sass` are this repo's own copy, imported by nothing else, so a change to the look lands here +alone. The root `.prettierrc.yaml` formats it. + +```bash +task dev-stack:start # the stack from this checkout at http://localhost:8096 +task web:dev # Vite at http://localhost:3002, proxying /api to the stack +task web:test # Vitest +task web:test:e2e # one Playwright roundtrip through a fresh stack, which it then removes +``` + +- **The `projects-web` image is Caddy, and its Caddyfile is the only router.** `/api/*` and + `/.well-known/*` go to `projects-api:8000`, `/assets/*` is the hashed build with a miss answering 404, + and every other path is `index.html`. So the browser sees one origin, the session cookie reaches the + API, and nothing needs CORS. Caddy passes `%2F` through escaped. +- **The browser holds a session cookie and never a token.** `api/authentication/browser_login.go` runs + `gobrowserlogin` as the `WEB_CLIENT_ID` confidential client, and keeps sessions in Postgres in + `browser_sessions` and `browser_login_attempts`, which opt out of the audit trigger. It is on only + where `WEB_CLIENT_SECRET` is set, and `settings.LoadServer` then refuses a missing `PUBLIC_URL`, + because the callback is `PUBLIC_URL/api/auth/callback`. A `PUBLIC_URL` gobrowserlogin refuses, such as + http off loopback, stops the API at start. It is a `gobrowserlogin.Connecting`, which opens the store + and reads the provider in the background, as the token verifier does. Until it has, a login answers a + 503 page, and a request carrying a session answers 503. A request carrying nothing still gets the 401 + challenge. `/ready` leaves the login out, because bearer clients do not need it. +- **`POST /api/auth/logout` ends the session, and the page's Log out button posts it.** The page then + shows a logged-out line and reads nothing, because any read would start a login. The provider keeps + its own session, so logging in again may not ask for anything. `MountBrowserLogin` returns the three + paths it serves, which is what `Protect` passes with no credential. +- **A session acts as its person and reaches every route.** `routes.ServiceScopes` limits only service + clients. The request log names a session's client as `WEB_CLIENT_ID`. +- **Every request the page sends carries `X-CSRF: 1`**, set once in `src/api/client.ts`. The API answers + 403 to a session's request without it, whatever the method. +- **A 401 on a write, or on a read holding typed input, fails in place**, so nothing the person typed is + lost to a redirect. `holdsTypedInput: true` on the request marks the read, as the search does. Any + other read navigates to `/api/auth/login?return_to=…`, once a page and at most twice a minute, + counted in `sessionStorage`. `extractApiError` passes the client's own refusal through, so its + message is what the page shows. +- **The dev stack's identity provider is a stand-in** that grants every login as one person, with no + page to fill in. The login takes http only on a loopback host, and the browser and the API must name + the issuer by one URL. So the stand-in shares the API container's network, where `localhost:8097` + reaches it, and publishes that port to the host, where the browser reaches it the same way. + `task web:dev` starts the stack with Vite's address as `PUBLIC_URL`, so a login returns to Vite. +- **The page's kinds and statuses are `PROJECT_KINDS` and `PROJECT_STATUSES` in `src/api/types.ts`.** + The project schemas list both as an `enum`, and kind's `default`. One test holds the schemas to + `model`, and another holds `types.ts` to the schema, so a new kind or status fails both until each + copy has it. ## Never write the breaking-change trailer in a commit message diff --git a/README.md b/README.md index c1dda44..4509f85 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ The config file chooses where the data lives. With no `api` key, every command r SQLite database on this machine, so it works with no network at all. With `api` set, every command reads and writes that API and opens no database. `projects-api`, in this repo, is that API, on Postgres. Both modes run the API's own routes: in database mode they are served inside the process, -so a refusal, an exit code and a `--json` answer are the same in both. +so a refusal, an exit code and a `--json` answer are the same in both. `web/` is a browser page over +that server, served beside it. ```bash projects # help: every command, grouped @@ -419,7 +420,9 @@ Every setting comes from the environment: | Variable | Default | | | -------- | ------- | | | `OIDC_ISSUER` | required | The provider whose RFC 9068 access tokens the API accepts | -| `PUBLIC_URL` | unset | The address clients reach the API at. Set, it serves RFC 9728 metadata naming the issuer | +| `PUBLIC_URL` | unset | The address clients reach the API at. Set, it serves RFC 9728 metadata naming the issuer. Required with `WEB_CLIENT_SECRET` | +| `WEB_CLIENT_ID` | `projects-web` | The confidential client the API logs a browser in as | +| `WEB_CLIENT_SECRET` | unset | That client's secret. Set, the API logs browsers in. Unset, only a bearer token reaches it | | `POSTGRES_HOST` | required | | | `POSTGRES_PORT` | `5432` | | | `POSTGRES_USERNAME` | required | | @@ -446,7 +449,8 @@ object per line on stdout, and each request's line names its caller, its `X-Requ **A client's id says who it is.** A token from a client whose id starts `projects-cli-` acts as the person who logged in, and reaches every route. One starting `projects-svc-` is a service, and reaches only what its scopes list in `routes.ServiceScopes`. `projects.items.read` is the read scope. A client with neither -prefix answers 401, whatever it holds, so moving a caller onto this API takes a client of its own. +prefix answers 401, whatever it holds, so moving a caller onto this API takes a client of its own. A +browser's session acts as its person, as a `projects-cli-` token does. **`{id}` takes more than an id.** A project answers to its UUID or its name, and an item to its UUID or its number. A name holding a slash is reached by escaping it, as `/api/v1/projects/web%2Fapi`. @@ -458,14 +462,61 @@ A refusal is `{"error": , "message": }`. A refused input add field with its `loc` and `msg`. [api/schemas](api/schemas) holds a JSON Schema for every answer, and `api/wire` holds the Go structs. -**Each release publishes the image and its manifests under one version.** `ghcr.io/datapointchris/projects` -is the image, and `ghcr.io/datapointchris/projects/manifests` is an OCI artifact holding `migrate/` and -`app/`. Apply `migrate/` and wait for its Job before applying `app/`. Both are signed keylessly through +**Each release publishes both images and their manifests under one version.** +`ghcr.io/datapointchris/projects` is the API's image, `ghcr.io/datapointchris/projects-web` is the web +app's, and `ghcr.io/datapointchris/projects/manifests` is an OCI artifact holding `migrate/` and `app/`. +Apply `migrate/` and wait for its Job before applying `app/`. All three are signed keylessly through Sigstore, and the release's notes name each by digest: ```bash cosign verify --certificate-identity https://github.com/datapointchris/projects/.github/workflows/release.yml@refs/heads/main \ - --certificate-oidc-issuer https://token.actions.githubusercontent.com ghcr.io/datapointchris/projects:latest + --certificate-oidc-issuer https://token.actions.githubusercontent.com ghcr.io/datapointchris/projects-web:latest ``` -`deploy/app/api.yaml` lists what a cluster supplies around those manifests. +`deploy/app/api.yaml` and `deploy/app/web.yaml` list what a cluster supplies around those manifests. + +## The web app — one page, on the API's own origin + +`web/` is a Vue page at `/`: the projects on the left, the open project's items on the right, and a +search over every item. It holds no rule. Each change is a call to the API, and the page shows what the +API answers. + +The `projects-web` image is Caddy on port 8080, serving the built page and passing `/api` and +`/.well-known` to `projects-api:8000`. A host's route sends every path to it, so the browser sees one +origin and needs no CORS. A path escaped as `%2F` reaches the API escaped. + +```text +browser ──→ projects-web:8080 ── /api, /.well-known ──→ projects-api:8000 + │ + └── every other path: the page +``` + +**The API logs the browser in, and the browser never holds a token.** `/api/auth/login` sends it to the +provider as the `WEB_CLIENT_ID` client, and `/api/auth/callback` sets an httpOnly, SameSite=Strict +session cookie. The session lives in Postgres, in `browser_sessions`. Every request the page sends +carries `X-CSRF: 1`, and the API refuses a session's request without it. The provider's client needs +`PUBLIC_URL` + `/api/auth/callback` as its redirect URI, the `authorization_code` grant with S256 PKCE, +`client_secret_basic` at the token endpoint, and the `openid` and `profile` scopes. A provider that +advertises pushed authorization requests receives the login that way. + +When a session ends, a change fails where it was made, with a message, rather than leaving the page and +losing it. So does a search, whose query exists nowhere else. Any other read goes to the login and +comes back, at most twice a minute. + +The page's Log out button posts `/api/auth/logout`, which ends the session at the API. The provider +keeps its own, so logging in again may complete without asking for anything. Ending that one is the +provider's logout page. + +While the API cannot yet reach the provider or the session tables, it serves every bearer request. A +login answers a 503 page, and a request carrying a session answers 503. A `PUBLIC_URL` that is not +https, or http on a loopback host, stops the API at start, because no browser would keep its cookie. + +`docker-compose.dev.yml` runs the whole stack from this checkout at , with a +stand-in provider that logs every login in as one person: + +```bash +task dev-stack:start # Postgres, the migration, the API, the web image and the provider +task web:dev # the page from source at http://localhost:3002, reloading on each edit +task web:test:e2e # a fresh stack, one Playwright roundtrip through it, then the stack removed +task dev-stack:stop # stops it and deletes its database +``` diff --git a/Taskfile.yml b/Taskfile.yml index 034e861..b17ebe7 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -18,7 +18,8 @@ tasks: - task --list # A worktree is a clean checkout, so everything gitignored is absent. The - # module cache comes back from go.sum. .envrc is linked from the primary + # module cache comes back from go.sum, and the web app's packages, which the + # commit hooks run, from its lockfile. .envrc is linked from the primary # checkout, because a worktree without it runs against production data. # PRIMARY is derived from the shared git directory, so the link step no-ops in # the primary itself and wherever the primary has no .envrc. @@ -29,6 +30,7 @@ tasks: sh: git rev-parse --path-format=absolute --git-common-dir | sed 's|/\.git$||' cmds: - go mod download + - npm ci --prefix web --no-audit --no-fund - | if [ "{{.PRIMARY}}" != "$PWD" ] && [ -f "{{.PRIMARY}}/.envrc" ]; then ln -sfn "{{.PRIMARY}}/.envrc" .envrc @@ -53,6 +55,53 @@ tasks: cmds: - echo "Running tests..." - go test -v ./... + - task: web:test + + web:test: + desc: Run the web app's unit tests + dir: web + cmds: + - npm run test:unit + + # The browser reaches the stack at http://localhost:8096, and the stand-in + # identity provider logs every login in as one person. Its database lives in + # a volume, so a stop that keeps the data is `docker compose stop`. + dev-stack:start: + desc: Build and start the web app's stack from this checkout, and wait until it serves + vars: + PROJECTS_PUBLIC_URL: '{{.PROJECTS_PUBLIC_URL | default "http://localhost:8096"}}' + env: + PROJECTS_PUBLIC_URL: '{{.PROJECTS_PUBLIC_URL}}' + cmds: + - docker compose -f docker-compose.dev.yml up --build --wait + - echo "Serving at http://localhost:8096, and a login returns to {{.PROJECTS_PUBLIC_URL}}" + + dev-stack:stop: + desc: Stop the web app's stack and delete its database + cmds: + - docker compose -f docker-compose.dev.yml down -v + + # Vite reloads the page on every edit and sends /api to the running stack. + web:dev: + desc: Serve the web app from source at http://localhost:3002, against the stack + deps: + - task: dev-stack:start + vars: + PROJECTS_PUBLIC_URL: http://localhost:3002 + dir: web + cmds: + - npm run dev + + # One roundtrip through the stack the image ships in: the login, a project + # named with a slash, and an item created, edited and deleted. It deletes the + # stack afterwards, pass or fail. + web:test:e2e: + desc: Run the Playwright smoke against a fresh stack, then delete it + cmds: + - defer: docker compose -f docker-compose.dev.yml down -v + - docker compose -f docker-compose.dev.yml down -v + - docker compose -f docker-compose.dev.yml up --build --wait + - cd web && npx playwright install chromium && npx playwright test install: desc: Build and install the dev build to ~/go/bin, where the release lives diff --git a/api/authentication/authentication.go b/api/authentication/authentication.go index 16b3ec0..d6c6f73 100644 --- a/api/authentication/authentication.go +++ b/api/authentication/authentication.go @@ -1,6 +1,6 @@ -// Package authentication assembles who may reach the API, in one place a browser -// login can replace. A person's client acts as that person, and a service client -// reaches only what its scopes list. +// Package authentication assembles who may reach the API: a bearer token, or a +// session the API's own browser login started. A person's client and a session +// act as that person, and a service client reaches only what its scopes list. package authentication import ( @@ -9,6 +9,7 @@ import ( "github.com/datapointchris/goapiauth" "github.com/datapointchris/goapiauth/clientid" + "github.com/datapointchris/goapiauth/tokenorsession" "github.com/datapointchris/projects/api/requestlog" "github.com/datapointchris/projects/store" @@ -32,17 +33,23 @@ func Metadata(mux *http.ServeMux, publicURL, issuer string) { mux.Handle("GET "+goapiauth.ProtectedResourcePath, goapiauth.ProtectedResourceMetadata{Resource: publicURL, Issuer: issuer}) } -// Protect puts a bearer token in front of routes, and limits a service client to -// what scopes lists for it. -func Protect(routes *http.ServeMux, scopes map[string][]string, verifier goapiauth.TokenVerifier, log *slog.Logger, public ...string) http.Handler { - limited := goapiauth.LimitServices(routes, scopes, log) - return goapiauth.RequireBearer(verifier, log, public...)(bindWriter(limited)) +// Protect puts a bearer token, or a browser session where sessions is not nil, in +// front of routes, and limits a service client to what scopes lists for it. A +// session acts as its person and reaches every route, as a person's token does. +// webClientID names the client a session logged in through, in each request's log. +func Protect(routes *http.ServeMux, scopes map[string][]string, verifier goapiauth.TokenVerifier, sessions tokenorsession.Sessions, webClientID string, log *slog.Logger, public ...string) http.Handler { + limited := bindWriter(goapiauth.LimitServices(routes, scopes, log), webClientID) + if sessions == nil { + return goapiauth.RequireBearer(verifier, log, public...)(limited) + } + return tokenorsession.Require(verifier, sessions, log, public...)(limited) } // bindWriter names the caller on the request's context, so the audit trail records // who each write was for. A person is recorded by subject and a service by client -// id, because a service token's subject is only the client's own id or nothing. -func bindWriter(next http.Handler) http.Handler { +// id, because a service token's subject is only the client's own id or nothing. A +// session carries no client id, so its request's log names webClientID. +func bindWriter(next http.Handler, webClientID string) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ctx := r.Context() writer := store.Writer{ @@ -55,7 +62,11 @@ func bindWriter(next http.Handler) http.Handler { if identity.Service { writer.Actor = identity.ClientID } - requestlog.NoteCaller(ctx, writer.Actor, identity.ClientID, identity.Service) + clientID := identity.ClientID + if clientID == "" { + clientID = webClientID + } + requestlog.NoteCaller(ctx, writer.Actor, clientID, identity.Service) } next.ServeHTTP(w, r.WithContext(store.WithWriter(ctx, writer))) }) diff --git a/api/authentication/browser_login.go b/api/authentication/browser_login.go new file mode 100644 index 0000000..ce4c334 --- /dev/null +++ b/api/authentication/browser_login.go @@ -0,0 +1,67 @@ +package authentication + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "log/slog" + "net/http" + "strings" + + "github.com/datapointchris/gobrowserlogin" + "github.com/datapointchris/gobrowserlogin/postgresstore" + + "github.com/datapointchris/projects/api/requestlog" + "github.com/datapointchris/projects/api/wire" +) + +// The browser login's routes. A browser navigates to the login and the callback +// before it holds a session, and a page that lost its session still logs out. +const ( + loginPath = "/api/auth/login" + callbackPath = "/api/auth/callback" + logoutPath = "/api/auth/logout" +) + +// NewBrowserLogin logs the web app's browsers in as clientID, this API's +// confidential client at issuer, and keeps each session in db. Its callback is +// under publicURL. It refuses a configuration that can never log anyone in, such +// as a publicURL on http at a host other than loopback. Run starts it. +func NewBrowserLogin(db *sql.DB, issuer, clientID, clientSecret, publicURL string, log *slog.Logger) (*gobrowserlogin.Connecting, error) { + return gobrowserlogin.NewConnecting(gobrowserlogin.Config{ + Issuer: issuer, + ClientID: clientID, + ClientSecret: clientSecret, + RedirectURL: strings.TrimSuffix(publicURL, "/") + callbackPath, + Logger: log, + }, func(ctx context.Context) (gobrowserlogin.Store, error) { + return postgresstore.New(ctx, db) + }) +} + +// MountBrowserLogin serves the login, its callback and the logout on mux, and +// returns their paths, which Protect passes with no credential. +func MountBrowserLogin(mux *http.ServeMux, login *gobrowserlogin.Connecting, log *slog.Logger) []string { + mux.HandleFunc("GET "+loginPath, login.Login) + mux.HandleFunc("GET "+callbackPath, login.Callback) + mux.HandleFunc("POST "+logoutPath, func(w http.ResponseWriter, r *http.Request) { + switch err := login.Logout(w, r); { + case err == nil: + w.WriteHeader(http.StatusNoContent) + case errors.Is(err, gobrowserlogin.ErrNoCSRFHeader): + refuse(w, r, http.StatusForbidden, "a logout must send the "+gobrowserlogin.CSRFHeader+" header") + default: + log.ErrorContext(r.Context(), "logout failed", "err", err) + refuse(w, r, http.StatusServiceUnavailable, "the session could not be ended, so it lasts until it times out") + } + }) + return []string{loginPath, callbackPath, logoutPath} +} + +func refuse(w http.ResponseWriter, r *http.Request, status int, message string) { + requestlog.NoteRefusal(r.Context(), message) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(wire.Error{Error: http.StatusText(status), Message: message}) +} diff --git a/api/main.go b/api/main.go index fbc8d32..b0e95d4 100644 --- a/api/main.go +++ b/api/main.go @@ -11,10 +11,14 @@ import ( "net/http" "os" "os/signal" + "slices" "syscall" "time" "github.com/datapointchris/goapiauth" + "github.com/datapointchris/goapiauth/tokenorsession" + "github.com/datapointchris/gobrowserlogin" + "github.com/jackc/pgx/v5/stdlib" "github.com/datapointchris/projects/api/authentication" "github.com/datapointchris/projects/api/requestlog" @@ -83,17 +87,35 @@ func serve(ctx context.Context, log *slog.Logger) error { } go provider.Run(ctx, log) + checks := []routes.ReadinessCheck{ + {Name: "postgres", Check: postgresStore.Ping}, + {Name: "identity provider", Check: func(context.Context) error { return provider.Ready() }}, + } + public := routes.PublicPaths + var browserLogin *gobrowserlogin.Connecting + if loaded.BrowserLogin() { + browserLogin, err = authentication.NewBrowserLogin(stdlib.OpenDBFromPool(pool), loaded.OIDCIssuer, loaded.WebClientID, loaded.WebClientSecret, loaded.PublicURL, log) + if err != nil { + return fmt.Errorf("reading the browser login's settings: %w", err) + } + go browserLogin.Run(ctx) + } else { + log.WarnContext(ctx, "WEB_CLIENT_SECRET is unset, so the API logs no browser in and only a bearer token reaches it") + } + svc := service.New(postgresStore, service.AssigningItemNumbers()) - mux := routes.New(svc, log, - routes.ReadinessCheck{Name: "postgres", Check: postgresStore.Ping}, - routes.ReadinessCheck{Name: "identity provider", Check: func(context.Context) error { return provider.Ready() }}, - ) + mux := routes.New(svc, log, checks...) if loaded.PublicURL != "" { authentication.Metadata(mux, loaded.PublicURL, loaded.OIDCIssuer) } else { log.WarnContext(ctx, "PUBLIC_URL is unset, so no RFC 9728 metadata is served and a client cannot discover the issuer") } - protected := authentication.Protect(mux, routes.ServiceScopes, provider, log, routes.PublicPaths...) + var sessions tokenorsession.Sessions + if browserLogin != nil { + public = append(slices.Clone(public), authentication.MountBrowserLogin(mux, browserLogin, log)...) + sessions = browserLogin + } + protected := authentication.Protect(mux, routes.ServiceScopes, provider, sessions, loaded.WebClientID, log, public...) server := &http.Server{ Addr: net.JoinHostPort("", loaded.ListenPort), diff --git a/api/routes/routes_test.go b/api/routes/routes_test.go index 1bc568d..9b4a06b 100644 --- a/api/routes/routes_test.go +++ b/api/routes/routes_test.go @@ -9,6 +9,8 @@ import ( "net/url" "os" "path/filepath" + "slices" + "strings" "testing" "github.com/datapointchris/goapiauth/authtest" @@ -17,6 +19,7 @@ import ( "github.com/datapointchris/projects/api/authentication" "github.com/datapointchris/projects/api/requestlog" "github.com/datapointchris/projects/api/routes" + "github.com/datapointchris/projects/model" "github.com/datapointchris/projects/service" "github.com/datapointchris/projects/store/sqlite" ) @@ -44,7 +47,7 @@ func newAPI(t *testing.T) api { verifier := provider.Verifier(t, config.PersonPrefix, config.ServicePrefix) mux := routes.New(service.New(sqlite.New(database), service.AssigningItemNumbers()), log) authentication.Metadata(mux, "https://projects.example.com", provider.URL) - protected := authentication.Protect(mux, routes.ServiceScopes, verifier, log, routes.PublicPaths...) + protected := authentication.Protect(mux, routes.ServiceScopes, verifier, nil, "", log, routes.PublicPaths...) return api{ handler: requestlog.Middleware(log, protected), @@ -110,6 +113,70 @@ func schema(t *testing.T, name string) *jsonschema.Schema { return compiled } +// A client builds its kind and status choices from the schemas, so each lists what +// model holds, and the kind a project gets when its create names none. +func TestTheSchemasListTheKindsAndStatusesModelHolds(t *testing.T) { + paths, err := filepath.Glob(filepath.Join("..", "schemas", "*.schema.json")) + if err != nil { + t.Fatal(err) + } + sites := 0 + var walk func(path string, node any) + walk = func(path string, node any) { + switch node := node.(type) { + case []any: + for _, child := range node { + walk(path, child) + } + case map[string]any: + description, _ := node["description"].(string) + var want []string + switch { + case strings.Contains(description, "project_kinds table"): + want = model.ProjectKinds + if node["default"] != model.KindLife { + t.Errorf("%s: a kind's default is %v, where model's is %s", path, node["default"], model.KindLife) + } + case strings.Contains(description, "project_statuses table"): + want = model.ProjectStatuses + } + if want != nil { + sites++ + listed, _ := node["enum"].([]any) + if !slices.Equal(stringsOf(listed), want) { + t.Errorf("%s: %q lists %v, where model holds %v", path, description, listed, want) + } + } + for _, child := range node { + walk(path, child) + } + } + } + for _, path := range paths { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var document any + if err := json.Unmarshal(raw, &document); err != nil { + t.Fatalf("%s: %v", path, err) + } + walk(path, document) + } + if sites == 0 { + t.Fatal("no schema names the project_kinds or project_statuses table") + } +} + +func stringsOf(values []any) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + text, _ := value.(string) + out = append(out, text) + } + return out +} + // Every shape the API answers with is a real answer checked against its file, so // the schemas cannot drift from the code without this failing. func TestEveryAnswerMatchesItsSchema(t *testing.T) { diff --git a/api/schemas/project-item-detail.schema.json b/api/schemas/project-item-detail.schema.json index f8ddcd4..7a87a3b 100644 --- a/api/schemas/project-item-detail.schema.json +++ b/api/schemas/project-item-detail.schema.json @@ -135,11 +135,23 @@ }, "kind": { "type": "string", - "description": "One of the names in the project_kinds table." + "description": "One of the names in the project_kinds table. A project created without one is life.", + "enum": [ + "build", + "chore", + "life" + ], + "default": "life" }, "status": { "type": "string", - "description": "One of the names in the project_statuses table." + "description": "One of the names in the project_statuses table.", + "enum": [ + "active", + "someday", + "completed", + "dropped" + ] }, "status_reason": { "type": [ diff --git a/api/schemas/project-item.schema.json b/api/schemas/project-item.schema.json index 93c64ad..4c88b6e 100644 --- a/api/schemas/project-item.schema.json +++ b/api/schemas/project-item.schema.json @@ -135,11 +135,23 @@ }, "kind": { "type": "string", - "description": "One of the names in the project_kinds table." + "description": "One of the names in the project_kinds table. A project created without one is life.", + "enum": [ + "build", + "chore", + "life" + ], + "default": "life" }, "status": { "type": "string", - "description": "One of the names in the project_statuses table." + "description": "One of the names in the project_statuses table.", + "enum": [ + "active", + "someday", + "completed", + "dropped" + ] }, "status_reason": { "type": [ diff --git a/api/schemas/project-with-item-count.schema.json b/api/schemas/project-with-item-count.schema.json index 5d502c7..89f42de 100644 --- a/api/schemas/project-with-item-count.schema.json +++ b/api/schemas/project-with-item-count.schema.json @@ -36,11 +36,23 @@ }, "kind": { "type": "string", - "description": "One of the names in the project_kinds table." + "description": "One of the names in the project_kinds table. A project created without one is life.", + "enum": [ + "build", + "chore", + "life" + ], + "default": "life" }, "status": { "type": "string", - "description": "One of the names in the project_statuses table." + "description": "One of the names in the project_statuses table.", + "enum": [ + "active", + "someday", + "completed", + "dropped" + ] }, "status_reason": { "type": [ diff --git a/api/schemas/project.schema.json b/api/schemas/project.schema.json index 0a7b958..958d89e 100644 --- a/api/schemas/project.schema.json +++ b/api/schemas/project.schema.json @@ -32,11 +32,23 @@ }, "kind": { "type": "string", - "description": "One of the names in the project_kinds table." + "description": "One of the names in the project_kinds table. A project created without one is life.", + "enum": [ + "build", + "chore", + "life" + ], + "default": "life" }, "status": { "type": "string", - "description": "One of the names in the project_statuses table." + "description": "One of the names in the project_statuses table.", + "enum": [ + "active", + "someday", + "completed", + "dropped" + ] }, "status_reason": { "type": [ diff --git a/api/settings/settings.go b/api/settings/settings.go index a60d20e..4c62b2a 100644 --- a/api/settings/settings.go +++ b/api/settings/settings.go @@ -13,6 +13,7 @@ import ( const ( defaultListenPort = "8000" defaultPostgresPort = "5432" + defaultWebClientID = "projects-web" ) // Postgres is where the database is. @@ -42,8 +43,17 @@ type Server struct { OIDCIssuer string // PublicURL is the address clients reach the API at. Set, the API serves RFC // 9728 metadata naming the issuer, so a client can find where to log in. - PublicURL string - ListenPort string + PublicURL string + // WebClientID and WebClientSecret are the confidential client the API logs + // the web app's browsers in as. The secret set turns that login on. + WebClientID string + WebClientSecret string + ListenPort string +} + +// BrowserLogin is whether the API logs browsers in, which the secret decides. +func (s Server) BrowserLogin() bool { + return s.WebClientSecret != "" } // LoadPostgres reads the database's settings, the only ones the migration job needs. @@ -79,12 +89,18 @@ func LoadServer() (Server, error) { if issuer == "" { return Server{}, fmt.Errorf("settings not set: OIDC_ISSUER") } - return Server{ - Postgres: postgres, - OIDCIssuer: issuer, - PublicURL: os.Getenv("PUBLIC_URL"), - ListenPort: orDefault("PORT", defaultListenPort), - }, nil + loaded := Server{ + Postgres: postgres, + OIDCIssuer: issuer, + PublicURL: os.Getenv("PUBLIC_URL"), + WebClientID: orDefault("WEB_CLIENT_ID", defaultWebClientID), + WebClientSecret: os.Getenv("WEB_CLIENT_SECRET"), + ListenPort: orDefault("PORT", defaultListenPort), + } + if loaded.BrowserLogin() && loaded.PublicURL == "" { + return Server{}, fmt.Errorf("WEB_CLIENT_SECRET is set and PUBLIC_URL is not: the browser login's callback is PUBLIC_URL/api/auth/callback") + } + return loaded, nil } func orDefault(name, fallback string) string { diff --git a/deploy/app/api.yaml b/deploy/app/api.yaml index 6ac047e..f7354b0 100644 --- a/deploy/app/api.yaml +++ b/deploy/app/api.yaml @@ -3,12 +3,13 @@ # The cluster supplies what this directory does not create: # # - A ConfigMap and a Secret, both named projects-settings, holding the settings -# README.md lists as environment variables. POSTGRES_PASSWORD goes in the -# Secret. The pod reads both, so any other setting may sit in either. -# - A route to the Service reaching /.well-known/oauth-protected-resource as -# well as /api/v1, since a client logging in reads the first to find the -# issuer. PUBLIC_URL names the address that route serves. -# - Each NetworkPolicy the pod reaches Postgres and the issuer through. +# README.md lists as environment variables. POSTGRES_PASSWORD and +# WEB_CLIENT_SECRET go in the Secret. The pod reads both, so any other +# setting may sit in either. +# - No route of its own. projects-web, in web.yaml, passes /api and +# /.well-known here from the app's host, whose address PUBLIC_URL names. +# - Each NetworkPolicy the pod reaches Postgres and the issuer through, and the +# one letting projects-web reach it. apiVersion: v1 kind: ServiceAccount metadata: diff --git a/deploy/app/kustomization.yaml b/deploy/app/kustomization.yaml index aad70c3..9e165da 100644 --- a/deploy/app/kustomization.yaml +++ b/deploy/app/kustomization.yaml @@ -1,9 +1,11 @@ -# The projects API. A deployment applies this path only once migrate/ has -# finished against the same release, so the schema is never behind the code. +# The projects API and its web app. A deployment applies this path only once +# migrate/ has finished against the same release, so the schema is never behind +# the code. apiVersion: kustomize.config.k8s.io/v1beta1 kind: Kustomization resources: - api.yaml + - web.yaml # What every object and pod here holds alike. The release adds # app.kubernetes.io/version to its own copy. Each selector is written out in # its object instead, because a workload's selector cannot change once applied. @@ -17,3 +19,4 @@ labels: # it pushes it, with `kustomize edit set image`. images: - name: projects + - name: projects-web diff --git a/deploy/app/web.yaml b/deploy/app/web.yaml new file mode 100644 index 0000000..d0f8484 --- /dev/null +++ b/deploy/app/web.yaml @@ -0,0 +1,125 @@ +# The web app: Caddy serving the built page, and passing /api and /.well-known +# to the projects-api Service beside it. So the browser sees one origin, its +# session cookie reaches the API, and nothing needs CORS. +# +# The cluster supplies what this directory does not create: +# +# - A route sending every path on the app's host to this Service. The API is +# reached through it, so the API's own Service needs no route. +# - The NetworkPolicies that let the route reach this pod, let this pod reach +# projects-api on port 8000 and the cluster's DNS, and let projects-api +# accept it. +apiVersion: v1 +kind: ServiceAccount +metadata: + name: projects-web + labels: + app.kubernetes.io/instance: projects-web + app.kubernetes.io/component: web +automountServiceAccountToken: false +--- +apiVersion: v1 +kind: Service +metadata: + name: projects-web + labels: + app.kubernetes.io/instance: projects-web + app.kubernetes.io/component: web +spec: + selector: + app.kubernetes.io/name: projects + app.kubernetes.io/instance: projects-web + ports: + - name: http + port: 8080 + targetPort: http +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: projects-web + labels: + app.kubernetes.io/instance: projects-web + app.kubernetes.io/component: web + annotations: + kubernetes.io/description: The projects web app, serving its page and passing /api to projects-api +spec: + replicas: 1 + selector: + matchLabels: + app.kubernetes.io/name: projects + app.kubernetes.io/instance: projects-web + # The new pod is ready before the old one stops, so a release drops no requests. + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + template: + metadata: + labels: + app.kubernetes.io/instance: projects-web + app.kubernetes.io/component: web + spec: + serviceAccountName: projects-web + automountServiceAccountToken: false + enableServiceLinks: false + # The image's numeric USER is the uid, so runAsNonRoot can check it. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + containers: + - name: web + image: projects-web + imagePullPolicy: IfNotPresent + ports: + - name: http + containerPort: 8080 + # Endpoints drop a terminating pod a moment after TERM reaches it, so + # the sleep keeps it accepting until no proxy routes to it. + lifecycle: + preStop: + sleep: + seconds: 5 + # Caddy's own answer, which needs neither the API nor the page. + readinessProbe: + httpGet: + path: /health + port: http + periodSeconds: 10 + timeoutSeconds: 3 + livenessProbe: + httpGet: + path: /health + port: http + periodSeconds: 20 + failureThreshold: 3 + # The limit equals the request, so the pod never uses more memory + # than the scheduler set aside for it. `docker stats` on the dev + # stack's `web` container reads what Caddy holds serving the page. + resources: + requests: + cpu: 10m + memory: 64Mi + limits: + memory: 64Mi + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + # Caddy writes its instance id under /data and its config state under + # /config, so each is a writable volume over the read-only root. + volumeMounts: + - name: caddy-data + mountPath: /data + - name: caddy-config + mountPath: /config + volumes: + - name: caddy-data + emptyDir: + sizeLimit: 16Mi + - name: caddy-config + emptyDir: + sizeLimit: 16Mi diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..4fe9d48 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,92 @@ +# The web app's development stack, built from this checkout: Postgres, the +# schema migration, the API, the web image, and a stand-in identity provider. +# The browser reaches only the web image, at http://localhost:8096, as it reaches +# the real route in production. +# +# docker compose -f docker-compose.dev.yml up --build --wait +# +# `task dev-stack:start` runs it, `task web:dev` serves the app from source +# against it, and `task web:test:e2e` runs the smoke through a fresh one. +# Nothing here holds real data. + +name: projects-web-dev + +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_DB: projects + POSTGRES_USER: projects + POSTGRES_PASSWORD: projects + volumes: + - pgdata:/var/lib/postgresql/data + # Over TCP, because a first start runs a socket-only server through init, + # and a check on the socket passes before the port opens. + healthcheck: + test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -U projects -d projects'] + interval: 2s + timeout: 5s + retries: 30 + + migrate: + build: + context: . + command: [projects-api, migrate] + environment: &database + POSTGRES_HOST: postgres + POSTGRES_USERNAME: projects + POSTGRES_PASSWORD: projects + POSTGRES_DB: projects + depends_on: + postgres: + condition: service_healthy + + # Named as the cluster's Service is, because the web image's Caddyfile proxies to it. + projects-api: + build: + context: . + environment: + <<: *database + # The browser and the API have to name the provider by one URL, and the + # login takes http only on a loopback host. So the provider shares this + # container's network, where localhost reaches it, and its port is + # published here, where localhost reaches it from the browser. + OIDC_ISSUER: http://localhost:8097/projects + # The login returns the browser here. `task web:dev` names Vite's address + # instead, so a login started from source lands back on it. + PUBLIC_URL: ${PROJECTS_PUBLIC_URL:-http://localhost:8096} + WEB_CLIENT_ID: projects-web + WEB_CLIENT_SECRET: projects-web-dev-secret + ports: + - '127.0.0.1:8097:8097' + depends_on: + migrate: + condition: service_completed_successfully + healthcheck: + test: ['CMD', 'wget', '-qO-', 'http://localhost:8000/ready'] + interval: 2s + timeout: 3s + retries: 60 + + # It grants a code to any login with no page to fill in, and puts the claims + # below on every token, so every login is the same person. + identity-provider: + image: ghcr.io/navikt/mock-oauth2-server:6.1.4@sha256:647dd8c78a54f0e6fcd58d79c884e612f8f998a0662276f3e1db22e464a50192 + network_mode: service:projects-api + environment: + SERVER_PORT: '8097' + JSON_CONFIG: >- + {"interactiveLogin": false, "tokenCallbacks": [{"issuerId": "projects", "requestMappings": [{"requestParam": "grant_type", "match": "*", + "claims": {"sub": "projects-dev-person", "preferred_username": "dev", "name": "Dev Person"}}]}]} + + web: + build: + context: web + ports: + - '127.0.0.1:8096:8080' + depends_on: + projects-api: + condition: service_started + +volumes: + pgdata: diff --git a/go.mod b/go.mod index 91d9785..da96559 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,9 @@ go 1.26.5 toolchain go1.26.10 +// The web app holds no Go, and its npm packages can ship Go of their own. +ignore ./web + require ( charm.land/bubbles/v2 v2.2.1 charm.land/bubbletea/v2 v2.1.0 @@ -11,6 +14,7 @@ require ( charm.land/lipgloss/v2 v2.0.6 github.com/charmbracelet/x/ansi v0.11.9 github.com/datapointchris/goapiauth v0.2.0 + github.com/datapointchris/gobrowserlogin v0.2.0 github.com/datapointchris/goclikit v0.5.3 github.com/datapointchris/goclilogin v0.8.0 github.com/datapointchris/goselfupdate v0.11.0 @@ -64,6 +68,7 @@ require ( github.com/containerd/errdefs/pkg v0.3.0 // indirect github.com/containerd/log v0.1.0 // indirect github.com/containerd/platforms v0.2.1 // indirect + github.com/coreos/go-oidc/v3 v3.21.0 // indirect github.com/cpuguy83/dockercfg v0.3.2 // indirect github.com/creack/pty v1.1.24 // indirect github.com/cubicdaiya/gonp v1.0.4 // indirect diff --git a/go.sum b/go.sum index dd9eccf..1f69fa4 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= +github.com/coreos/go-oidc/v3 v3.21.0 h1:wZo4Q9Pum8dYEj0eMUPrqR+kvuGkeUplbLpNCkBqoWM= +github.com/coreos/go-oidc/v3 v3.21.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= @@ -87,6 +89,8 @@ github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMF github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/datapointchris/goapiauth v0.2.0 h1:Bqo+F5X70YnmRgWNIpqohFT5brScxt82888iqPS3a/U= github.com/datapointchris/goapiauth v0.2.0/go.mod h1:R5j+QfvEqu0mTwap4nz13fq5RRuXwKGUnAH6VvfhLmQ= +github.com/datapointchris/gobrowserlogin v0.2.0 h1:rJ8MlbL//Xvy8uJbjXR8Hy/57nyaMxFliaw2zEvgoGw= +github.com/datapointchris/gobrowserlogin v0.2.0/go.mod h1:wfN8b/nONCpCdrZH2S15bNNK6YSqtVjpxgeQanY5Yw8= github.com/datapointchris/goclikit v0.5.3 h1:6ec9nuswLXeIqZLa0YVEQeqsCnrjkqF/3gDtCBSowNQ= github.com/datapointchris/goclikit v0.5.3/go.mod h1:hEbplQyvrWq5m5UepXVNKfv8jvvlmQshNMrWdoHBb6Y= github.com/datapointchris/goclilogin v0.8.0 h1:pHeTUzgl++YT+jI0iSir9Wtow+L5u+JM1cVZ3p8Fsbk= diff --git a/release/pin-manifests b/release/pin-manifests index 5c4580d..62e6fb7 100755 --- a/release/pin-manifests +++ b/release/pin-manifests @@ -1,15 +1,17 @@ #!/usr/bin/env bash -# Copies deploy/ with the image pinned and the version labeled in every kustomize path, and builds each. +# Copies deploy/ with the images pinned and the version labeled in every kustomize path, and builds each. set -euo pipefail usage() { cat <<'EOF' -Usage: pin-manifests --image --version --out +Usage: pin-manifests --image --web-image + --version --out Copies deploy/ into , which must be empty or absent. In each -kustomize path under it, names the image projects as --image, and labels every -object and pod template app.kubernetes.io/version: --version. Then builds each -path, so one that will not build fails here, and prints the paths, one a line. +kustomize path under it, names the image projects as --image, and the image +projects-web as --web-image wherever the path lists it. Labels every object +and pod template app.kubernetes.io/version: --version. Then builds each path, +so one that will not build fails here, and prints the paths, one a line. The release pushes as the manifests artifact. validate.yml checks each path it prints. @@ -32,12 +34,13 @@ if [ $# -eq 0 ]; then exit 0 fi -image='' version='' out='' +image='' web_image='' version='' out='' while [ $# -gt 0 ]; do case $1 in - --image | --version | --out) + --image | --web-image | --version | --out) [ $# -ge 2 ] || usage_error "$1 needs a value" - printf -v "${1#--}" '%s' "$2" + name=${1#--} + printf -v "${name//-/_}" '%s' "$2" shift 2 ;; -h | --help) @@ -47,10 +50,11 @@ while [ $# -gt 0 ]; do *) usage_error "unknown argument $1" ;; esac done -for name in image version out; do - [ -n "${!name}" ] || usage_error "--$name is required" +for name in image web_image version out; do + [ -n "${!name}" ] || usage_error "--${name//_/-} is required" done [[ $image == *@sha256:* ]] || usage_error "--image names no digest: $image" +[[ $web_image == *@sha256:* ]] || usage_error "--web-image names no digest: $web_image" deploy=$(cd "$(dirname "${BASH_SOURCE[0]}")/../deploy" && pwd) mkdir -p "$out" @@ -64,9 +68,15 @@ for kustomization in "${kustomizations[@]}"; do path=$(dirname "$kustomization") ( cd "$path" + # Read before any edit, which rewrites the file in kustomize's own layout. + lists_web=false + if grep -Eq '^ *- name: projects-web$' kustomization.yaml; then lists_web=true; fi kustomize edit set image "projects=$image" + if $lists_web; then kustomize edit set image "projects-web=$web_image"; fi kustomize edit add label --without-selector --include-templates "app.kubernetes.io/version:$version" ) - kustomize build "$path" >/dev/null + rendered=$(kustomize build "$path") + unpinned=$(sed -n 's/^[ -]*image: //p' <<<"$rendered" | grep -v '@sha256:' || true) + [ -z "$unpinned" ] || die "$path runs an image no flag pinned: $unpinned" echo "$path" done diff --git a/store/postgres/generated/models.go b/store/postgres/generated/models.go index 77ea32d..6964384 100644 --- a/store/postgres/generated/models.go +++ b/store/postgres/generated/models.go @@ -22,6 +22,22 @@ type AuditTrail struct { Baggage *string } +type BrowserLoginAttempt struct { + StateSha256 []byte + Nonce string + CodeVerifier string + ReturnTo string + ExpiresAt time.Time +} + +type BrowserSession struct { + CookieSha256 []byte + Identity []byte + CreatedAt time.Time + SeenAt time.Time + ExpiresAt time.Time +} + type Project struct { ID string Name string diff --git a/store/postgres/migrations/00002_browser_sessions.sql b/store/postgres/migrations/00002_browser_sessions.sql new file mode 100644 index 0000000..56e043e --- /dev/null +++ b/store/postgres/migrations/00002_browser_sessions.sql @@ -0,0 +1,34 @@ +-- +goose Up +-- The tables gobrowserlogin's postgresstore reads and writes, as its Schema +-- declares them. The API checks their columns at start, so a column renamed here +-- keeps the browser login from starting. Neither carries an audit trigger: a +-- session's row is written on every login and again as it is used, and no +-- person edits one. + +-- A login a browser has started and not finished. state_sha256 is the SHA-256 of +-- the OAuth state, and the callback deletes the row as it reads it. +CREATE TABLE browser_login_attempts ( + state_sha256 bytea CONSTRAINT pk_browser_login_attempts PRIMARY KEY, + nonce text NOT NULL, + code_verifier text NOT NULL, + return_to text NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE INDEX ix_browser_login_attempts_expires_at ON browser_login_attempts (expires_at); + +-- One browser's login. cookie_sha256 is the SHA-256 of the session cookie's value, +-- so a copy of this table names no cookie a reader could present. +CREATE TABLE browser_sessions ( + cookie_sha256 bytea CONSTRAINT pk_browser_sessions PRIMARY KEY, + identity jsonb NOT NULL, + created_at timestamptz NOT NULL, + seen_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL +); + +CREATE INDEX ix_browser_sessions_expires_at ON browser_sessions (expires_at); + +-- +goose Down +DROP TABLE browser_sessions; +DROP TABLE browser_login_attempts; diff --git a/store/postgres/store_test.go b/store/postgres/store_test.go index 73105a4..57fd63c 100644 --- a/store/postgres/store_test.go +++ b/store/postgres/store_test.go @@ -76,7 +76,7 @@ func TestEveryWriteIsAuditedWithItsWriter(t *testing.T) { // unrecorded with nothing to say so. func TestEveryTableIsAuditedButWhatOptsOut(t *testing.T) { pool := postgrestest.Pool(t) - optsOut := []string{"audit_trail", "goose_db_version", "undo_log"} + optsOut := []string{"audit_trail", "browser_login_attempts", "browser_sessions", "goose_db_version", "undo_log"} rows, err := pool.Query(context.Background(), ` SELECT c.relname, EXISTS ( diff --git a/web/.dockerignore b/web/.dockerignore new file mode 100644 index 0000000..70db5e3 --- /dev/null +++ b/web/.dockerignore @@ -0,0 +1,9 @@ +# The image builds from package.json, the lockfile, the sources and the +# Caddyfile. npm ci and the build remake the rest. +node_modules +dist +e2e +test-results +playwright-report +**/__tests__ +*.md diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 0000000..b6f27f1 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/.prettierignore b/web/.prettierignore new file mode 100644 index 0000000..e29f7f4 --- /dev/null +++ b/web/.prettierignore @@ -0,0 +1,7 @@ +# Prettier walks everything but node_modules, and from this directory it reads +# no .gitignore. Build and test output stays as the tools wrote it. +dist/ +coverage/ +test-results/ +playwright-report/ +package-lock.json diff --git a/web/Caddyfile.docker b/web/Caddyfile.docker new file mode 100644 index 0000000..3bc3742 --- /dev/null +++ b/web/Caddyfile.docker @@ -0,0 +1,71 @@ +# The projects-web image's Caddyfile. The host's route sends every path here, +# and this splits them: /api and /.well-known go to the API, and the rest is +# the built SPA. So the browser sees one origin, its session cookie +# reaches the API, and no CORS is needed. +{ + # Nothing reconfigures a running container, so the admin endpoint and the + # copy of the config it would save stay off. + admin off + persist_config off + # TLS ends at the edge in front of this container. + auto_https off +} + +# A high port, so the process needs no capability to bind it. +:8080 { + encode gzip + + # One JSON object a line on stdout. Caddy redacts the Cookie and + # Authorization headers from it. + log { + output stdout + format json + } + + # For this container's own probes. Wrapped in `handle` so it ends routing + # before the SPA fallback catches it. + handle /health { + respond "ok" 200 + } + + # The API's routes, its browser login under /api/auth, and the RFC 9728 + # metadata a CLI reads before it holds a token. The rest of /.well-known + # goes there too, so no document under it is answered with the SPA's page. + # The request's path is passed on as sent, so a project name escaped as %2F + # reaches the API escaped. + @api path /api/* /.well-known/* + handle @api { + reverse_proxy projects-api:8000 + } + + # The build names each file here by a hash of its content, so a file is + # cached for a year. A name no release holds answers 404 rather than the + # page, which a tab still running an older release would otherwise parse + # as a script and keep for that year. + handle /assets/* { + root * /srv + @present file + header @present Cache-Control "public, max-age=31536000, immutable" + file_server + } + + # Every other path is the one page, revalidated so a release shows promptly. + handle { + root * /srv + try_files {path} /index.html + file_server + header Cache-Control "public, max-age=300, must-revalidate" + } + + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + X-Content-Type-Options "nosniff" + X-Frame-Options "DENY" + Referrer-Policy "strict-origin-when-cross-origin" + Permissions-Policy "interest-cohort=(), geolocation=(), microphone=(), camera=()" + # The page's font comes from Google Fonts, and 'unsafe-inline' style is + # for Vue's style bindings. Scripts and requests stay on this origin. + Content-Security-Policy "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; script-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" + -Server + } +} diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..157cbc1 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 + +# The web app's image: Caddy serving the built SPA and proxying /api to the API. +# The build context is this directory. + +# The same Node major as development, because npm lockfile formats change between majors. +FROM node:24-alpine AS builder + +WORKDIR /web + +# Dependencies first, in a layer of their own, so a source change reuses it. +COPY package.json package-lock.json .npmrc ./ +RUN npm ci --no-audit --no-fund + +COPY . . +RUN npm run build + + +FROM caddy:2.11-alpine + +LABEL org.opencontainers.image.source="https://github.com/datapointchris/projects" \ + org.opencontainers.image.description="The projects web app: the SPA, served by Caddy beside a proxy to the projects API" \ + org.opencontainers.image.licenses="MIT" + +COPY --from=builder /web/dist /srv +COPY Caddyfile.docker /etc/caddy/Caddyfile + +# Caddy writes its instance id under /data and reads /config. A cluster mounts +# both writable over a read-only root, and anywhere else the image's own copies +# have to be writable by the process. +RUN chown 10501:10501 /data /config + +# A numeric uid, so a cluster's runAsNonRoot can check it. +USER 10501:10501 +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD ["wget", "-qO-", "http://localhost:8080/health"] + +CMD ["caddy", "run", "--config", "/etc/caddy/Caddyfile", "--adapter", "caddyfile"] diff --git a/web/e2e/projects.spec.ts b/web/e2e/projects.spec.ts new file mode 100644 index 0000000..7e96974 --- /dev/null +++ b/web/e2e/projects.spec.ts @@ -0,0 +1,80 @@ +import { test, expect, type Page } from '@playwright/test' + +const SUCCESS = '.flash-messages__message--success' +const ERROR = '.flash-messages__message--error' + +async function createProject(page: Page, name: string) { + await page.getByTestId('project-add-button').click() + await page.getByTestId('project-name-input').fill(name) + await page.getByTestId('project-submit-button').click() + await expect(page.locator(SUCCESS, { hasText: `${name} added` })).toBeVisible() + await expect(page.getByTestId('project-name-input')).not.toBeVisible() +} + +async function createItem(page: Page, title: string, notes: string) { + await page.getByTestId('project-item-add-button').click() + await page.getByTestId('project-item-title-input').fill(title) + await page.getByTestId('project-item-notes-input').fill(notes) + await page.getByTestId('project-item-submit-button').click() + await expect(page.locator(SUCCESS, { hasText: `${title} added` })).toBeVisible() + await expect(page.getByTestId('project-item-title-input')).not.toBeVisible() +} + +// One roundtrip through the app's own stack: the stand-in provider logs the +// browser in, Caddy serves the page and proxies /api, and the API writes Postgres. +test('logs in, then creates, edits and deletes a project and its item', async ({ page }) => { + const stamp = Date.now() + const projectName = `smoke/${stamp}` + const itemTitle = `Smoke item ${stamp}` + + // No session yet, so the page's first read sends the browser through the login and back. + await page.goto('/') + await expect(page).toHaveURL(/\/$/) + await expect(page.locator('.projects-page')).toBeVisible() + await expect(page.getByTestId('project-add-button')).toBeVisible() + + await createProject(page, projectName) + const project = page.getByTestId('project-item').filter({ hasText: projectName }) + await expect(project).toBeVisible() + + // A name holding a slash reaches the API escaped, through Caddy, on the browser's own session. + const byName = await page.request.get(`/api/v1/projects/${encodeURIComponent(projectName)}`, { headers: { 'X-CSRF': '1' } }) + expect(byName.status()).toBe(200) + expect((await byName.json()).name).toBe(projectName) + + await project.click() + await expect(page.locator('.projects-page__items-header')).toContainText(projectName) + + await createItem(page, itemTitle, 'Written by the smoke test') + const row = page.getByTestId('project-item-row').filter({ hasText: itemTitle }) + await expect(row).toBeVisible() + + await row.getByTestId('project-item-edit-button').click() + await page.getByTestId('project-item-title-input').fill(`${itemTitle} edited`) + await page.getByTestId('project-item-submit-button').click() + await expect(page.locator(SUCCESS, { hasText: 'updated' })).toBeVisible() + const edited = page.getByTestId('project-item-row').filter({ hasText: `${itemTitle} edited` }) + await expect(edited).toBeVisible() + + // Reloading reads everything back from the API, so what shows is what was stored. + await page.reload() + await page.getByTestId('project-item').filter({ hasText: projectName }).click() + await expect(page.getByTestId('project-item-row').filter({ hasText: `${itemTitle} edited` })).toBeVisible() + + await page + .getByTestId('project-item-row') + .filter({ hasText: `${itemTitle} edited` }) + .getByTestId('project-item-delete-button') + .click() + await expect(page.getByTestId('project-item-row').filter({ hasText: itemTitle })).toHaveCount(0) + + await page.getByTestId('project-delete-button').click() + await expect(page.locator(SUCCESS, { hasText: `${projectName} deleted` })).toBeVisible() + await expect(page.getByTestId('project-item').filter({ hasText: projectName })).toHaveCount(0) + await expect(page.locator(ERROR)).toHaveCount(0) + + await page.getByTestId('logout-button').click() + await expect(page.getByTestId('logged-out')).toBeVisible() + const afterLogout = await page.request.get('/api/v1/projects', { headers: { 'X-CSRF': '1' } }) + expect(afterLogout.status()).toBe(401) +}) diff --git a/web/eslint.config.js b/web/eslint.config.js new file mode 100644 index 0000000..0038624 --- /dev/null +++ b/web/eslint.config.js @@ -0,0 +1,53 @@ +import js from '@eslint/js' +import pluginVue from 'eslint-plugin-vue' +import skipFormatting from '@vue/eslint-config-prettier/skip-formatting' +import vueTsEslintConfig from '@vue/eslint-config-typescript' + +export default [ + { + name: 'app/files-to-lint', + files: ['**/*.{js,mjs,jsx,vue,ts,tsx}'], + }, + + { + name: 'app/files-to-ignore', + ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', '**/node_modules/**'], + }, + + js.configs.recommended, + ...pluginVue.configs['flat/recommended'], + ...vueTsEslintConfig(), + skipFormatting, + + { + name: 'app/custom-rules', + languageOptions: { + globals: { + process: 'readonly', + }, + }, + rules: { + 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', + 'vue/multi-word-component-names': 'off', + 'vue/no-reserved-component-names': 'off', + 'vue/require-default-prop': 'off', + 'vue/block-order': [ + 'error', + { + order: ['template', 'script', 'style'], + }, + ], + 'vue/html-self-closing': [ + 'error', + { + html: { + void: 'always', + normal: 'never', + component: 'always', + }, + }, + ], + }, + }, +] diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..203de49 --- /dev/null +++ b/web/index.html @@ -0,0 +1,35 @@ + + + + + + + + + + Projects + + + +
+ + + diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..46d48f5 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,5962 @@ +{ + "name": "projects-web", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "projects-web", + "version": "0.0.0", + "dependencies": { + "@fortawesome/fontawesome-free": "^7.2.0", + "axios": "^1.13.6", + "consola": "^3.4.2", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vuedraggable": "^4.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@pinia/testing": "^1.0.3", + "@playwright/test": "^1.64.0", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.8.1", + "eslint": "^9.39.2", + "eslint-plugin-vue": "^10.6.2", + "jsdom": "^27.3.0", + "prettier": "^3.7.4", + "sass": "^1.89.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.53.0", + "vite": "^7.2.4", + "vitest": "^4.0.16", + "vue-tsc": "^3.1.4" + }, + "engines": { + "node": ">=24", + "npm": ">=11" + } + }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "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" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "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.6" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.0.tgz", + "integrity": "sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz", + "integrity": "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.4.tgz", + "integrity": "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz", + "integrity": "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.4.tgz", + "integrity": "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz", + "integrity": "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz", + "integrity": "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz", + "integrity": "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz", + "integrity": "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz", + "integrity": "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz", + "integrity": "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz", + "integrity": "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz", + "integrity": "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz", + "integrity": "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz", + "integrity": "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz", + "integrity": "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz", + "integrity": "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz", + "integrity": "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz", + "integrity": "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz", + "integrity": "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz", + "integrity": "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz", + "integrity": "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz", + "integrity": "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz", + "integrity": "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz", + "integrity": "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz", + "integrity": "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz", + "integrity": "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "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.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/@eslint/eslintrc/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@fortawesome/fontawesome-free": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-free/-/fontawesome-free-7.2.0.tgz", + "integrity": "sha512-3DguDv/oUE+7vjMeTSOjCSG+KeawgVQOHrKRnvUuqYh1mfArrh7s+s8hXW3e4RerBA1+Wh+hBqf8sJNpqNrBWg==", + "license": "(CC-BY-4.0 AND OFL-1.1 AND MIT)", + "engines": { + "node": ">=6" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@parcel/watcher": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.6.tgz", + "integrity": "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.6", + "@parcel/watcher-darwin-arm64": "2.5.6", + "@parcel/watcher-darwin-x64": "2.5.6", + "@parcel/watcher-freebsd-x64": "2.5.6", + "@parcel/watcher-linux-arm-glibc": "2.5.6", + "@parcel/watcher-linux-arm-musl": "2.5.6", + "@parcel/watcher-linux-arm64-glibc": "2.5.6", + "@parcel/watcher-linux-arm64-musl": "2.5.6", + "@parcel/watcher-linux-x64-glibc": "2.5.6", + "@parcel/watcher-linux-x64-musl": "2.5.6", + "@parcel/watcher-win32-arm64": "2.5.6", + "@parcel/watcher-win32-ia32": "2.5.6", + "@parcel/watcher-win32-x64": "2.5.6" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz", + "integrity": "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz", + "integrity": "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz", + "integrity": "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz", + "integrity": "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz", + "integrity": "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz", + "integrity": "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz", + "integrity": "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz", + "integrity": "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz", + "integrity": "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz", + "integrity": "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz", + "integrity": "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz", + "integrity": "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz", + "integrity": "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@pinia/testing": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-1.0.3.tgz", + "integrity": "sha512-g+qR49GNdI1Z8rZxKrQC3GN+LfnGTNf5Kk8Nz5Cz6mIGva5WRS+ffPXQfzhA0nu6TveWzPNYTjGl4nJqd3Cu9Q==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "pinia": ">=3.0.4" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@pkgr/core": { + "version": "0.2.9", + "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz", + "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/pkgr" + } + }, + "node_modules/@playwright/test": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz", + "integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.2", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz", + "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.12.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.0.tgz", + "integrity": "sha512-GYDxsZi3ChgmckRT9HPU0WEhKLP08ev/Yfcq2AstjrDASOYCSXeyjDsHg4v5t4jOj7cyDX3vmprafKlWIG9MXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz", + "integrity": "sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/type-utils": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.57.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.57.0.tgz", + "integrity": "sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.57.0.tgz", + "integrity": "sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.57.0", + "@typescript-eslint/types": "^8.57.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz", + "integrity": "sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz", + "integrity": "sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz", + "integrity": "sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.57.0.tgz", + "integrity": "sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz", + "integrity": "sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.57.0", + "@typescript-eslint/tsconfig-utils": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/visitor-keys": "8.57.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.57.0.tgz", + "integrity": "sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.57.0", + "@typescript-eslint/types": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz", + "integrity": "sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.57.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-vue": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.5.tgz", + "integrity": "sha512-bL3AxKuQySfk1iGcBsQnoRVexTPJq0Z/ixFVM8OhVJAP6ZXXXLtM7NFKWhLl30Kg7uTBqIaPXbh+nuQCuBDedg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-rc.2" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@volar/language-core": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", + "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/source-map": "2.4.28" + } + }, + "node_modules/@volar/source-map": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz", + "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@volar/typescript": { + "version": "2.4.28", + "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz", + "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "path-browserify": "^1.0.1", + "vscode-uri": "^3.0.8" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", + "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/shared": "3.5.30", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-core/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@vue/compiler-core/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", + "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", + "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@vue/compiler-core": "3.5.30", + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.8", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-sfc/node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", + "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/devtools-api": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz", + "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==", + "license": "MIT", + "dependencies": { + "@vue/devtools-kit": "^7.7.9" + } + }, + "node_modules/@vue/devtools-kit": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz", + "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==", + "license": "MIT", + "dependencies": { + "@vue/devtools-shared": "^7.7.9", + "birpc": "^2.3.0", + "hookable": "^5.5.3", + "mitt": "^3.0.1", + "perfect-debounce": "^1.0.0", + "speakingurl": "^14.0.1", + "superjson": "^2.2.2" + } + }, + "node_modules/@vue/devtools-shared": { + "version": "7.7.9", + "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz", + "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==", + "license": "MIT", + "dependencies": { + "rfdc": "^1.4.1" + } + }, + "node_modules/@vue/eslint-config-prettier": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-prettier/-/eslint-config-prettier-10.2.0.tgz", + "integrity": "sha512-GL3YBLwv/+b86yHcNNfPJxOTtVFJ4Mbc9UU3zR+KVoG7SwGTjPT+32fXamscNumElhcpXW3mT0DgzS9w32S7Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-config-prettier": "^10.0.1", + "eslint-plugin-prettier": "^5.2.2" + }, + "peerDependencies": { + "eslint": ">= 8.21.0", + "prettier": ">= 3.0.0" + } + }, + "node_modules/@vue/eslint-config-typescript": { + "version": "14.7.0", + "resolved": "https://registry.npmjs.org/@vue/eslint-config-typescript/-/eslint-config-typescript-14.7.0.tgz", + "integrity": "sha512-iegbMINVc+seZ/QxtzWiOBozctrHiF2WvGedruu2EbLujg9VuU0FQiNcN2z1ycuaoKKpF4m2qzB5HDEMKbxtIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.56.0", + "fast-glob": "^3.3.3", + "typescript-eslint": "^8.56.0", + "vue-eslint-parser": "^10.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.10.0 || ^10.0.0", + "eslint-plugin-vue": "^9.28.0 || ^10.0.0", + "typescript": ">=4.8.4" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@vue/language-core": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz", + "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/language-core": "2.4.28", + "@vue/compiler-dom": "^3.5.0", + "@vue/shared": "^3.5.0", + "alien-signals": "^3.0.0", + "muggle-string": "^0.4.1", + "path-browserify": "^1.0.1", + "picomatch": "^4.0.2" + } + }, + "node_modules/@vue/language-core/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@vue/reactivity": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", + "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", + "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/shared": "3.5.30" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", + "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.30", + "@vue/runtime-core": "3.5.30", + "@vue/shared": "3.5.30", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", + "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "vue": "3.5.30" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", + "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", + "license": "MIT" + }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, + "node_modules/@vue/tsconfig": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@vue/tsconfig/-/tsconfig-0.8.1.tgz", + "integrity": "sha512-aK7feIWPXFSUhsCP9PFqPyFOcz4ENkb8hZ2pneL6m2UjCkccvaOhC/5KCKluuBufvp2KzkbdA2W2pk20vLzu3g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "typescript": "5.x", + "vue": "^3.4.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/alien-signals": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz", + "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.6", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", + "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/birpc": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz", + "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", + "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chokidar": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz", + "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/consola": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", + "integrity": "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==", + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/copy-anything": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz", + "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==", + "license": "MIT", + "dependencies": { + "is-what": "^5.2.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", + "integrity": "sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.4", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz", + "integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.4", + "@esbuild/android-arm": "0.27.4", + "@esbuild/android-arm64": "0.27.4", + "@esbuild/android-x64": "0.27.4", + "@esbuild/darwin-arm64": "0.27.4", + "@esbuild/darwin-x64": "0.27.4", + "@esbuild/freebsd-arm64": "0.27.4", + "@esbuild/freebsd-x64": "0.27.4", + "@esbuild/linux-arm": "0.27.4", + "@esbuild/linux-arm64": "0.27.4", + "@esbuild/linux-ia32": "0.27.4", + "@esbuild/linux-loong64": "0.27.4", + "@esbuild/linux-mips64el": "0.27.4", + "@esbuild/linux-ppc64": "0.27.4", + "@esbuild/linux-riscv64": "0.27.4", + "@esbuild/linux-s390x": "0.27.4", + "@esbuild/linux-x64": "0.27.4", + "@esbuild/netbsd-arm64": "0.27.4", + "@esbuild/netbsd-x64": "0.27.4", + "@esbuild/openbsd-arm64": "0.27.4", + "@esbuild/openbsd-x64": "0.27.4", + "@esbuild/openharmony-arm64": "0.27.4", + "@esbuild/sunos-x64": "0.27.4", + "@esbuild/win32-arm64": "0.27.4", + "@esbuild/win32-ia32": "0.27.4", + "@esbuild/win32-x64": "0.27.4" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@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.14.0", + "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.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-plugin-prettier": { + "version": "5.5.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.5.tgz", + "integrity": "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "prettier-linter-helpers": "^1.0.1", + "synckit": "^0.11.12" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-plugin-prettier" + }, + "peerDependencies": { + "@types/eslint": ">=8.0.0", + "eslint": ">=8.0.0", + "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", + "prettier": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@types/eslint": { + "optional": true + }, + "eslint-config-prettier": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-vue": { + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.8.0.tgz", + "integrity": "sha512-f1J/tcbnrpgC8suPN5AtdJ5MQjuXbSU9pGRSSYAuF3SHoiYCOdEX6O22pLaRyLHXvDcOe+O5ENgc1owQ587agA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.4.0", + "natural-compare": "^1.4.0", + "nth-check": "^2.1.1", + "postcss-selector-parser": "^7.1.0", + "semver": "^7.6.3", + "xml-name-validator": "^4.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "@stylistic/eslint-plugin": "^2.0.0 || ^3.0.0 || ^4.0.0 || ^5.0.0", + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "vue-eslint-parser": "^10.0.0" + }, + "peerDependenciesMeta": { + "@stylistic/eslint-plugin": { + "optional": true + }, + "@typescript-eslint/parser": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.0.tgz", + "integrity": "sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "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" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hookable": { + "version": "5.5.3", + "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz", + "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==", + "license": "MIT" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immutable": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.5.tgz", + "integrity": "sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-what": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz", + "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mesqueeb" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.4.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.4.0.tgz", + "integrity": "sha512-mjzqwWRD9Y1J1KUi7W97Gja1bwOOM5Ug0EZ6UDK3xS7j7mndrkwozHtSblfomlzyB4NepioNt+B2sOSzczVgtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@acemir/cssom": "^0.9.28", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.6.0", + "cssstyle": "^5.3.4", + "data-urls": "^6.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", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "10.2.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", + "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/mitt": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", + "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/muggle-string": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz", + "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/obug": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", + "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-browserify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz", + "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==", + "dev": true, + "license": "MIT" + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz", + "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pinia": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz", + "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^7.7.7" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "typescript": ">=4.5.0", + "vue": "^3.5.11" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/playwright": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz", + "integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz", + "integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", + "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", + "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-linter-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", + "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-diff": "^1.1.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/readdirp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz", + "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rfdc": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", + "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", + "license": "MIT" + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/sass": { + "version": "1.98.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.98.0.tgz", + "integrity": "sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/speakingurl": { + "version": "14.0.1", + "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz", + "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", + "integrity": "sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/superjson": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz", + "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==", + "license": "MIT", + "dependencies": { + "copy-anything": "^4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/synckit": { + "version": "0.11.12", + "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.12.tgz", + "integrity": "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@pkgr/core": "^0.2.9" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/synckit" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz", + "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.25.tgz", + "integrity": "sha512-keinCnPbwXEUG3ilrWQZU+CqcTTzHq9m2HhoUP2l7Xmi8l1LuijAXLpAJ5zRW+ifKTNscs4NdCkfkDCBYm352w==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.25" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.25", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.25.tgz", + "integrity": "sha512-ZjCZK0rppSBu7rjHYDYsEaMOIbbT+nWF57hKkv4IUmZWBNrBWBOjIElc0mKRgLM8bm7x/BBlof6t2gi/Oq/Asw==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.57.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.57.0.tgz", + "integrity": "sha512-W8GcigEMEeB07xEZol8oJ26rigm3+bfPHxHvwbYUlu1fUDsGuQ7Hiskx5xGW/xM4USc9Ephe3jtv7ZYPQntHeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.57.0", + "@typescript-eslint/parser": "8.57.0", + "@typescript-eslint/typescript-estree": "8.57.0", + "@typescript-eslint/utils": "8.57.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vscode-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", + "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue": { + "version": "3.5.30", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", + "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.30", + "@vue/compiler-sfc": "3.5.30", + "@vue/runtime-dom": "3.5.30", + "@vue/server-renderer": "3.5.30", + "@vue/shared": "3.5.30" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vue-eslint-parser": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/vue-eslint-parser/-/vue-eslint-parser-10.4.0.tgz", + "integrity": "sha512-Vxi9pJdbN3ZnVGLODVtZ7y4Y2kzAAE2Cm0CZ3ZDRvydVYxZ6VrnBhLikBsRS+dpwj4Jv4UCv21PTEwF5rQ9WXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "eslint-scope": "^8.2.0 || ^9.0.0", + "eslint-visitor-keys": "^4.2.0 || ^5.0.0", + "espree": "^10.3.0 || ^11.0.0", + "esquery": "^1.6.0", + "semver": "^7.6.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://github.com/sponsors/mysticatea" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/vue-eslint-parser/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/vue-tsc": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz", + "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@volar/typescript": "2.4.28", + "@vue/language-core": "3.2.5" + }, + "bin": { + "vue-tsc": "bin/vue-tsc.js" + }, + "peerDependencies": { + "typescript": ">=5.0.0" + } + }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..46daac3 --- /dev/null +++ b/web/package.json @@ -0,0 +1,53 @@ +{ + "name": "projects-web", + "private": true, + "version": "0.0.0", + "description": "The projects web app: projects, their items, dependencies and tasks, in the browser", + "type": "module", + "engines": { + "node": ">=24", + "npm": ">=11" + }, + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:unit": "vitest run", + "test:watch": "vitest", + "test:e2e": "playwright test", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "typecheck": "vue-tsc -b --noEmit" + }, + "dependencies": { + "@fortawesome/fontawesome-free": "^7.2.0", + "axios": "^1.13.6", + "consola": "^3.4.2", + "pinia": "^3.0.4", + "vue": "^3.5.30", + "vuedraggable": "^4.1.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.2", + "@pinia/testing": "^1.0.3", + "@playwright/test": "^1.64.0", + "@types/node": "^24.10.1", + "@vitejs/plugin-vue": "^6.0.1", + "@vue/eslint-config-prettier": "^10.2.0", + "@vue/eslint-config-typescript": "^14.6.0", + "@vue/test-utils": "^2.4.6", + "@vue/tsconfig": "^0.8.1", + "eslint": "^9.39.2", + "eslint-plugin-vue": "^10.6.2", + "jsdom": "^27.3.0", + "prettier": "^3.7.4", + "sass": "^1.89.0", + "typescript": "~5.9.3", + "typescript-eslint": "^8.53.0", + "vite": "^7.2.4", + "vitest": "^4.0.16", + "vue-tsc": "^3.1.4" + } +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..7572616 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,23 @@ +import { defineConfig, devices } from '@playwright/test' +import process from 'node:process' + +// The smoke runs against the dev stack in `docker-compose.dev.yml`: Caddy serving +// the build, the API, Postgres, and a stand-in identity provider. `task +// web:test:e2e` starts a fresh one and removes it afterwards. +const baseURL = process.env.PROJECTS_WEB_STACK_URL || 'http://localhost:8096' + +export default defineConfig({ + testDir: './e2e', + fullyParallel: false, + forbidOnly: !!process.env.CI, + workers: 1, + reporter: [['list']], + timeout: 30000, + expect: { timeout: 10000 }, + use: { + baseURL, + trace: 'retain-on-failure', + screenshot: 'only-on-failure', + }, + projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }], +}) diff --git a/web/public/icons/apple-touch-icon.png b/web/public/icons/apple-touch-icon.png new file mode 100644 index 0000000..aadfa6d Binary files /dev/null and b/web/public/icons/apple-touch-icon.png differ diff --git a/web/public/icons/icon.svg b/web/public/icons/icon.svg new file mode 100644 index 0000000..a617874 --- /dev/null +++ b/web/public/icons/icon.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/web/src/App.vue b/web/src/App.vue new file mode 100644 index 0000000..d6c579a --- /dev/null +++ b/web/src/App.vue @@ -0,0 +1,47 @@ + + + + + diff --git a/web/src/api/__tests__/client.test.ts b/web/src/api/__tests__/client.test.ts new file mode 100644 index 0000000..919ec71 --- /dev/null +++ b/web/src/api/__tests__/client.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { AxiosError, AxiosHeaders, type InternalAxiosRequestConfig } from 'axios' + +type Page = (typeof import('../client'))['api'] + +// A page is a fresh load of the module, so its one-login-a-page flag starts clear. +async function page(status: number, seen: InternalAxiosRequestConfig[] = []): Promise { + vi.resetModules() + const { api } = await import('../client') + api.defaults.adapter = async (config) => { + seen.push(config) + const response = { status, statusText: '', data: { error: 'Unauthorized', message: 'no session' }, headers: {}, config } + if (status < 400) return response + throw new AxiosError('refused', 'ERR_BAD_REQUEST', config, null, response) + } + return api +} + +function stubLocation() { + const assign = vi.fn() + vi.stubGlobal('location', { pathname: '/', search: '?q=1', hash: '', assign }) + return assign +} + +afterEach(() => { + vi.unstubAllGlobals() + sessionStorage.clear() +}) + +describe('api', () => { + it('sends the header the API requires beside its session cookie', async () => { + const seen: InternalAxiosRequestConfig[] = [] + const api = await page(200, seen) + await api.get('/projects') + await api.post('/projects', { name: 'x' }) + for (const config of seen) expect(AxiosHeaders.from(config.headers).get('X-CSRF')).toBe('1') + }) + + // A page's reads are one login however many get a 401. A person starting again + // from a refused login is the second page. A third inside the minute is a login + // that completes without taking, which would otherwise loop through the provider. + it('sends the browser to log in once a page, from two pages a minute at most', async () => { + const assign = stubLocation() + + const first = await page(401) + void first.get('/projects') + void first.get('/project-items') + await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(1)) + expect(assign).toHaveBeenCalledWith('/api/auth/login?return_to=%2F%3Fq%3D1') + + const second = await page(401) + void second.get('/projects') + await vi.waitFor(() => expect(assign).toHaveBeenCalledTimes(2)) + + const third = await page(401) + await expect(third.get('/projects')).rejects.toMatchObject({ status: 401 }) + expect(assign).toHaveBeenCalledTimes(2) + }) + + it('fails a write, and a read of typed input, in place on a 401', async () => { + const assign = stubLocation() + const api = await page(401) + const { extractApiError } = await import('../errors') + + // The page reads every failure through extractApiError, so the reason has to survive it. + const refused = await api.patch('/project-items/1', { title: 'kept' }).catch((e: unknown) => e) + expect(extractApiError(refused)).toMatchObject({ status: 401, detail: expect.stringMatching(/not saved/) }) + await expect(api.get('/project-items/search', { params: { q: 'typed' }, holdsTypedInput: true })).rejects.toMatchObject({ status: 401 }) + expect(assign).not.toHaveBeenCalled() + }) +}) diff --git a/web/src/api/__tests__/types.test.ts b/web/src/api/__tests__/types.test.ts new file mode 100644 index 0000000..28123ec --- /dev/null +++ b/web/src/api/__tests__/types.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import projectSchema from '../../../../api/schemas/project.schema.json' +import { DEFAULT_PROJECT_KIND, PROJECT_KINDS, PROJECT_STATUSES } from '../types' + +// The modal offers these kinds and the filter these statuses, so a kind or status +// the API gains or loses fails here before the page offers the wrong set. +describe('the project vocabulary', () => { + it('lists what the API schema lists', () => { + const { kind, status } = projectSchema.properties + expect([...PROJECT_KINDS]).toEqual(kind.enum) + expect(DEFAULT_PROJECT_KIND).toBe(kind.default) + expect([...PROJECT_STATUSES]).toEqual(status.enum) + }) +}) diff --git a/web/src/api/client.ts b/web/src/api/client.ts new file mode 100644 index 0000000..f3498ca --- /dev/null +++ b/web/src/api/client.ts @@ -0,0 +1,181 @@ +import axios from 'axios' +import { createLogger } from '@/utils/logger' +import { ApiError, extractApiError } from './errors' + +const logger = createLogger('ApiClient') + +// Same origin in every environment: Caddy, or Vite's proxy in development, sends +// /api to the API. The API logs the browser in itself, so the browser holds one +// HttpOnly cookie and this code never sees a token. +const BASE = '/api/v1' +const LOGIN = '/api/auth/login' +const LOGOUT = '/api/auth/logout' + +// The API refuses its session cookie on a request without this header, which a +// page on another origin cannot add without a preflight the API never answers. +const CSRF_HEADER = 'X-CSRF' + +// A login that completes while the API still answers 401, as with cookies +// blocked, would send the browser round the provider forever. A person starting +// again from a refused login is a second page sending the browser to log in +// inside the window, so a third is the loop, and its reads fail instead. +const LOGIN_WINDOW_MS = 60_000 +const LOGIN_STARTS_ALLOWED = 2 +const LOGIN_STARTS_KEY = 'loginStarts' + +const SESSION_ENDED_ON_WRITE = 'Your login has ended, so this change was not saved. Reload the page to log in again.' +const SESSION_ENDED_ON_TYPED_READ = 'Your login has ended. Copy what you typed, then reload the page to log in again.' +const LOGIN_REFUSED = 'Logging in did not complete. Wait a minute, then reload the page.' + +declare module 'axios' { + interface AxiosRequestConfig { + // A read whose parameters were typed in and are held nowhere else, such as a + // search. A 401 fails it in place, as it does a write, because sending the + // browser to log in would drop what was typed. + holdsTypedInput?: boolean + } +} + +// One start per page: the reads a page fires together all get the 401. +let loggingIn = false + +function recentLoginStarts(now: number): number[] { + try { + const starts = JSON.parse(sessionStorage.getItem(LOGIN_STARTS_KEY) ?? '[]') as number[] + return starts.filter((at) => now - at < LOGIN_WINDOW_MS) + } catch { + return [] + } +} + +function sendToLogin(): boolean { + if (loggingIn) return true + const now = Date.now() + const recent = recentLoginStarts(now) + if (recent.length >= LOGIN_STARTS_ALLOWED) return false + sessionStorage.setItem(LOGIN_STARTS_KEY, JSON.stringify([...recent, now])) + loggingIn = true + const here = location.pathname + location.search + location.hash + location.assign(`${LOGIN}?return_to=${encodeURIComponent(here)}`) + return true +} + +export const LOGIN_AGAIN_HREF = `${LOGIN}?return_to=${encodeURIComponent('/')}` + +// Ends this browser's session at the API. The identity provider keeps its own, +// so a login after it may complete without asking for anything. +export async function logOut(): Promise { + await api.post(LOGOUT, undefined, { baseURL: '' }) +} + +function sessionEnded(detail: string, requestId?: string): ApiError { + return new ApiError({ message: `API 401: ${detail}`, status: 401, detail, requestId }) +} + +// Track request metadata (start time, request ID) outside Axios's config object. +// WeakMap avoids type augmentation issues and cleans up automatically. +interface RequestMeta { + startTime: number + requestId: string +} +const requestMeta = new WeakMap() + +export const api = axios.create({ + baseURL: BASE, + headers: { + 'Content-Type': 'application/json', + [CSRF_HEADER]: '1', + }, + timeout: 30000, +}) + +// Request interceptor: inject X-Request-ID and log outgoing request +api.interceptors.request.use((config) => { + const requestId = crypto.randomUUID().slice(0, 8) + config.headers.set('X-Request-ID', requestId) + + requestMeta.set(config, { startTime: Date.now(), requestId }) + + logger.debug('request_outgoing', { + method: config.method?.toUpperCase(), + url: `${config.baseURL}${config.url}`, + request_id: requestId, + }) + + return config +}) + +// Response interceptor: log response, convert errors to ApiError, and answer a 401 +api.interceptors.response.use( + (response) => { + const meta = requestMeta.get(response.config) + const duration = meta ? Date.now() - meta.startTime : 0 + + logger.info('request_completed', { + method: response.config.method?.toUpperCase(), + url: `${response.config.baseURL}${response.config.url}`, + status: response.status, + duration_ms: duration, + request_id: meta?.requestId, + }) + + return response + }, + (error) => { + const meta = error.config ? requestMeta.get(error.config) : undefined + const duration = meta ? Date.now() - meta.startTime : 0 + const apiError = extractApiError(error) + + if (apiError.isNetworkError) { + logger.error('request_network_error', { + method: error.config?.method?.toUpperCase(), + url: error.config ? `${error.config.baseURL}${error.config.url}` : 'unknown', + error: apiError.detail, + duration_ms: duration, + request_id: meta?.requestId, + }) + } else { + logger.error('request_failed', { + method: error.config?.method?.toUpperCase(), + url: error.config ? `${error.config.baseURL}${error.config.url}` : 'unknown', + status: apiError.status, + detail: apiError.detail, + duration_ms: duration, + request_id: apiError.requestId || meta?.requestId, + }) + } + + if (apiError.status === 401) { + const method = (error.config?.method ?? 'get').toUpperCase() + // A write fails in place so its caller rolls back and says so; navigating would drop the change. + if (method !== 'GET') return Promise.reject(sessionEnded(SESSION_ENDED_ON_WRITE, apiError.requestId)) + if (error.config?.holdsTypedInput) return Promise.reject(sessionEnded(SESSION_ENDED_ON_TYPED_READ, apiError.requestId)) + // The page is navigating away, so the caller never settles and shows no error. + if (sendToLogin()) return new Promise(() => {}) + return Promise.reject(sessionEnded(LOGIN_REFUSED, apiError.requestId)) + } + + return Promise.reject(apiError) + }, +) + +export type { + Project, + ProjectCreate, + ProjectKind, + ProjectStatus, + ProjectStatusFilter, + ProjectUpdate, + ProjectWithItemCount, + ProjectItem, + ProjectItemCreate, + ProjectItemUpdate, + ProjectItemDetail, + ProjectItemInProject, + ProjectItemReorder, + ProjectItemMembershipCreate, + ProjectItemDependencyCreate, + ProjectItemTask, + ProjectItemTaskCreate, + ProjectItemTaskUpdate, +} from './types' diff --git a/web/src/api/errors.ts b/web/src/api/errors.ts new file mode 100644 index 0000000..8390f79 --- /dev/null +++ b/web/src/api/errors.ts @@ -0,0 +1,121 @@ +/** + * Structured API error types for the Vue frontend. + * + * - ApiHttpError: HTTP 4xx/5xx responses + * - ApiNetworkError: Connection/timeout failures + * + * Each error carries structured context (status, detail, request_id) so + * stores and views never need to manually extract error info from Axios. + */ + +// Minimal type for the Axios error shape we need — avoids import resolution +// issues across different TypeScript build modes. +interface AxiosLikeError { + response?: { + status: number + data?: { message?: string; errors?: Record[] } + headers?: Record + } + message?: string +} + +export interface ValidationError { + field: string + message: string +} + +/** + * Structured API error with full context. + * Stores and views receive this instead of raw Axios errors. + */ +export class ApiError extends Error { + /** HTTP status code (undefined for network errors) */ + readonly status?: number + /** Error detail from API response */ + readonly detail: string + /** Field-level validation errors from API (422 responses) */ + readonly validationErrors: ValidationError[] + /** Request ID for cross-service tracing */ + readonly requestId?: string + /** Whether this is a network/connection error vs HTTP error */ + readonly isNetworkError: boolean + + constructor(opts: { + message: string + status?: number + detail: string + validationErrors?: ValidationError[] + requestId?: string + isNetworkError?: boolean + }) { + super(opts.message) + this.name = 'ApiError' + this.status = opts.status + this.detail = opts.detail + this.validationErrors = opts.validationErrors ?? [] + this.requestId = opts.requestId + this.isNetworkError = opts.isNetworkError ?? false + } + + /** Human-readable message suitable for displaying to the user */ + get userMessage(): string { + if (this.isNetworkError) { + return 'Unable to reach the server. Check your connection.' + } + if (this.status === 422 && this.validationErrors.length > 0) { + return this.validationErrors.map((e) => `${e.field}: ${e.message}`).join(', ') + } + return this.detail + } +} + +/** + * Extract a structured ApiError from an Axios error. + * Centralizes all error parsing so it's never duplicated in stores/views. + */ +export function extractApiError(error: unknown): ApiError { + // The client's own refusal, such as a write after the login ended, already says why. + if (error instanceof ApiError) return error + + const axiosError = error as AxiosLikeError + + // Network error (no response received) + if (!axiosError.response) { + return new ApiError({ + message: axiosError.message || 'Network error', + detail: 'Unable to reach the server. Check your connection.', + isNetworkError: true, + }) + } + + const { status, data, headers } = axiosError.response + const requestId = (headers?.['x-request-id'] as string) || undefined + + // {"error": , "message": }, and a 422 adds each refused input as `errors` + let detail = 'An unexpected error occurred' + const validationErrors: ValidationError[] = [] + + if (data) { + if (typeof data.message === 'string' && data.message) { + detail = data.message + } + if (Array.isArray(data.errors)) { + for (const err of data.errors) { + const loc = (err as Record).loc as string[] | undefined + const msg = (err as Record).msg as string | undefined + if (loc && msg) { + const field = loc[loc.length - 1] || 'unknown' + validationErrors.push({ field, message: msg }) + } + } + } + } + + return new ApiError({ + message: `API ${status}: ${detail}`, + status, + detail, + validationErrors, + requestId, + }) +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts new file mode 100644 index 0000000..956df41 --- /dev/null +++ b/web/src/api/types.ts @@ -0,0 +1,136 @@ +// --- Project --- + +/** The API's project schema lists these, and a test holds this copy to it. */ +export const PROJECT_KINDS = ['build', 'chore', 'life'] as const +export type ProjectKind = (typeof PROJECT_KINDS)[number] + +/** The kind the API gives a project created without one. */ +export const DEFAULT_PROJECT_KIND: ProjectKind = 'life' + +/** The API's project schema lists these, and a test holds this copy to it. */ +export const PROJECT_STATUSES = ['active', 'someday', 'completed', 'dropped'] as const + +/** + * Where a project is in its lifecycle, from the project_statuses lookup table. + * A project is a finite effort, so completing it is what hides it — there is no + * separate archive flag the way items have one. `dropped` exists beside + * `completed` because `completed` alone would force you to lie about anything + * you merely stopped caring about, and it always carries a reason. `someday` + * hides like a closed project, but it is open work set aside, so the server + * leaves `closed_at` empty. + */ +export type ProjectStatus = (typeof PROJECT_STATUSES)[number] + +/** `all` is the absence of the filter, not a status a project can be in. */ +export type ProjectStatusFilter = ProjectStatus | 'all' + +export interface Project { + id: string + name: string + description?: string + kind: ProjectKind + status: ProjectStatus + status_reason?: string | null + closed_at?: string | null + position: number + created_at: string +} + +export interface ProjectCreate { + name: string + description?: string + kind?: ProjectKind + position?: number +} + +/** + * `closed_at` is absent deliberately — the server stamps it on the transition + * into a terminal status and clears it on reopen, so it cannot drift from the + * status it describes. + */ +export interface ProjectUpdate { + name?: string + description?: string | null + kind?: ProjectKind + status?: ProjectStatus + status_reason?: string | null + position?: number +} + +// open_count and completed_count exclude archived items, so they sum to +// item_count only when nothing in the project is archived. +export interface ProjectWithItemCount extends Project { + item_count: number + open_count: number + completed_count: number +} + +export interface ProjectItem { + id: string + // Server-assigned handle. `id` is the key and is never shown to anyone; this + // is what a row displays and what a command line takes. + number: number + title: string + notes?: string + completed: boolean + archived: boolean + created_at: string + updated_at: string +} + +export interface ProjectItemCreate { + title: string + notes?: string + project_ids: string[] +} + +export interface ProjectItemUpdate { + title?: string + notes?: string | null + completed?: boolean + archived?: boolean +} + +export interface ProjectItemDetail extends ProjectItem { + projects: Project[] + dependency_ids: string[] +} + +export interface ProjectItemInProject extends ProjectItem { + position: number +} + +export interface ProjectItemReorder { + project_id: string + position: number +} + +export interface ProjectItemMembershipCreate { + project_id: string +} + +export interface ProjectItemDependencyCreate { + depends_on_id: string +} + +// --- Project Item Task --- + +export interface ProjectItemTask { + id: string + item_id: string + title: string + completed: boolean + position: number + created_at: string +} + +export interface ProjectItemTaskCreate { + title: string + position?: number +} + +export interface ProjectItemTaskUpdate { + title?: string + completed?: boolean + position?: number +} diff --git a/web/src/assets/sass/abstracts/_breakpoints.scss b/web/src/assets/sass/abstracts/_breakpoints.scss new file mode 100644 index 0000000..804b529 --- /dev/null +++ b/web/src/assets/sass/abstracts/_breakpoints.scss @@ -0,0 +1,8 @@ +// The width below which the layout is a phone's. +$phone-max-width: 720px; + +@mixin phone { + @media (max-width: $phone-max-width) { + @content; + } +} diff --git a/web/src/assets/sass/abstracts/_card_shadows.scss b/web/src/assets/sass/abstracts/_card_shadows.scss new file mode 100644 index 0000000..08d7ad9 --- /dev/null +++ b/web/src/assets/sass/abstracts/_card_shadows.scss @@ -0,0 +1,39 @@ +// The raised and pressed shadows of a card: the project list's rows and the +// panel holding a project's items. +:root { + // prettier-ignore + --shadow-card: + 0 -2px 2px var(--clr-black-trans-200), + 0px 4px 4px var(--clr-black-trans-400), + 0px 16px 16px var(--clr-black-trans-600), + 16px 8px 16px var(--clr-black-trans-600), + inset 5px 5px 10px var(--clr-black-trans-200), + inset -10px -10px 10px var(--clr-black-trans-200), + inset -6px -6px 14px var(--clr-black-trans-600), + -4px -4px 10px var(--clr-shadow-highlight-md), + inset 6px 6px 10px var(--clr-shadow-highlight-md); + + // prettier-ignore + --shadow-card-active: + inset 2px 2px 5px var(--clr-black-trans-800), + -2px -2px 5px var(--clr-black-trans-800), + + inset 4px 4px 8px var(--clr-black-trans-800), + -8px -8px 12px var(--clr-black-trans-600), + + inset 8px 8px 32px var(--clr-black-trans-400), + + inset -2px -2px 5px var(--clr-shadow-highlight-sm), + 2px 2px 5px var(--clr-shadow-highlight-md), + + inset -4px -4px 8px var(--clr-shadow-highlight-md), + 8px 8px 24px var(--clr-shadow-highlight-lg), + + inset -8px -8px 32px var(--clr-shadow-highlight-md); + + // prettier-ignore + --shadow-card-hover: + 10px 10px 24px var(--clr-black-trans-800), + -4px -4px 12px var(--clr-shadow-highlight-lg), + inset 8px 8px 12px var(--clr-shadow-highlight-lg); +} diff --git a/web/src/assets/sass/abstracts/_colors.scss b/web/src/assets/sass/abstracts/_colors.scss new file mode 100644 index 0000000..2070cc9 --- /dev/null +++ b/web/src/assets/sass/abstracts/_colors.scss @@ -0,0 +1,69 @@ +:root { + // Theme hue — change this single value to shift the entire primary palette + // Default: 224 (turquoise in OKLCH space, equivalent to HSL 194) + --base-hue: 224; + + // Primary colors generated from --base-hue using OKLCH + // Lightness and chroma are fixed; hue rotates with the theme + --clr-primary--darker: oklch(0.254 0.05 var(--base-hue)); + --clr-primary: oklch(0.269 0.05 var(--base-hue)); + --clr-primary--lighter: oklch(0.298 0.05 var(--base-hue)); + + // Accent colors generated from --accent-hue using OKLCH + // Default: 62 (orange in OKLCH space, equivalent to HSL 31) + --accent-hue: 62; + --clr-accent: oklch(0.75 0.157 var(--accent-hue)); + --clr-accent-light: oklch(0.94 0.045 var(--accent-hue)); + --clr-accent-dark: oklch(0.34 0.076 var(--accent-hue)); + + // Semantic palette — foreground colors derive from accent-hue + // Neovim role mapping: Type (secondary), String (tertiary), DiagInfo (info), Comment (subtle) + --clr-secondary: oklch(0.65 0.12 calc(var(--accent-hue) + 120)); + --clr-tertiary: oklch(0.65 0.1 calc(var(--accent-hue) + 240)); + --clr-info: oklch(0.7 0.08 calc(var(--accent-hue) + 180)); + --clr-subtle: oklch(0.55 0.04 var(--base-hue)); + + --clr-text: hsl(0 0% 87%); + + // Status colors at full brightness + // Glow consumers (box-shadows) apply their own dimming via oklch(from var(...) ...) + --clr-error: oklch(0.63 0.25 29); + --clr-warning: oklch(0.75 0.15 85); + --clr-success: oklch(0.55 0.15 145); + --clr-critical: var(--clr-error); + --clr-overdue: oklch(0.58 0.2 35); + + --clr-gray-050: hsl(0, 0%, 87%); + --clr-gray-100: hsl(0, 0%, 67%); + --clr-gray-200: hsl(0, 0%, 60%); + --clr-gray-300: hsl(0, 0%, 53%); + --clr-gray-400: hsl(0, 0%, 47%); + --clr-gray-500: hsl(0, 0%, 40%); + --clr-gray-600: hsl(0, 0%, 33%); + --clr-gray-700: hsl(0, 0%, 27%); + --clr-gray-800: hsl(0, 0%, 20%); + --clr-gray-900: hsl(0, 0%, 13%); + --clr-gray-1000: hsl(0, 0%, 7%); + + --clr-gray-trans-400: hsla(0 0% 100% / 0.5); + --clr-gray-trans-500: hsla(0 0% 100% / 0.5); + --clr-gray-trans-600: hsla(0 0% 100% / 0.2); + --clr-gray-trans-700: hsla(0 0% 50% / 0.5); + --clr-gray-trans-800: hsla(0 0% 50% / 0.3); + --clr-gray-trans-850: hsla(0 0% 50% / 0.2); + --clr-gray-trans-875: hsla(0 0% 50% / 0.1); + --clr-gray-trans-900: hsla(0 0% 15% / 0.95); + + // Shadow highlights — derived from theme primary via OKLCH relative color syntax + // Lighter, less chromatic version of primary for natural neumorphic highlights + // that adapt to every theme hue without simultaneous contrast artifacts + --clr-shadow-highlight-sm: oklch(from var(--clr-primary) calc(l + 0.2) calc(c * 0.5) h / 0.12); + --clr-shadow-highlight-md: oklch(from var(--clr-primary) calc(l + 0.2) calc(c * 0.5) h / 0.25); + --clr-shadow-highlight-lg: oklch(from var(--clr-primary) calc(l + 0.2) calc(c * 0.5) h / 0.35); + + --clr-black-trans-200: hsl(0 0% 5% / 0.2); + --clr-black-trans-400: hsl(0 0% 5% / 0.4); + --clr-black-trans-600: hsl(0 0% 5% / 0.6); + --clr-black-trans-800: hsl(0 0% 5% / 0.8); + --clr-black-trans-1000: hsl(0 0% 5% / 1); +} diff --git a/web/src/assets/sass/abstracts/_effects.scss b/web/src/assets/sass/abstracts/_effects.scss new file mode 100644 index 0000000..4583e72 --- /dev/null +++ b/web/src/assets/sass/abstracts/_effects.scss @@ -0,0 +1,20 @@ +:root { + // prettier-ignore + --deep-3d-box-shadow: + 1px 1px 0px var(--clr-gray-200), + 2px 2px 0px var(--clr-gray-300), + 3px 3px 0px var(--clr-gray-400), + 4px 4px 0px var(--clr-gray-500), + 5px 5px 0px var(--clr-gray-600), + 6px 6px 0px var(--clr-gray-700), + 7px 7px 0px var(--clr-gray-800), + 8px 8px 0px var(--clr-gray-900), + 9px 9px 0px var(--clr-gray-1000), + 28px 28px 30px var(--clr-black-trans-800), + 20px 30px 35px var(--clr-black-trans-600), + 30px 40px 40px var(--clr-black-trans-600); +} + +@mixin deep-3d-box { + box-shadow: var(--deep-3d-box-shadow); +} diff --git a/web/src/assets/sass/abstracts/_shape.scss b/web/src/assets/sass/abstracts/_shape.scss new file mode 100644 index 0000000..8662519 --- /dev/null +++ b/web/src/assets/sass/abstracts/_shape.scss @@ -0,0 +1,22 @@ +// Corners, role borders and motion timing, in one place. + +:root { + --radius-xs: 3px; + --radius-sm: 4px; + --radius-default: 6px; + --radius-md: 8px; + --radius-lg: 12px; + --radius-xl: 20px; + --radius-2xl: 25px; + --radius-3xl: 30px; + --radius-circle: 50%; + + --border-card: none; + --border-input: none; + --border-subnav: 2px solid var(--clr-gray-700); + + --transition-fast: 0.1s; + --transition-default: 0.15s; + --transition-medium: 0.2s; + --transition-slow: 0.3s; +} diff --git a/web/src/assets/sass/abstracts/_spacing.scss b/web/src/assets/sass/abstracts/_spacing.scss new file mode 100644 index 0000000..b9ec2a7 --- /dev/null +++ b/web/src/assets/sass/abstracts/_spacing.scss @@ -0,0 +1,26 @@ +/* @link https://utopia.fyi/space/calculator?c=320,18,1.2,1240,20,1.25,5,2,&s=0.75|0.5|0.25,1.5|2|3|4|6,s-l&g=s,l,xl,12 */ + +:root { + --space-3xs: clamp(0.3125rem, 0.3125rem + 0vi, 0.3125rem); + --space-2xs: clamp(0.5625rem, 0.5408rem + 0.1087vi, 0.625rem); + --space-xs: clamp(0.875rem, 0.8533rem + 0.1087vi, 0.9375rem); /* 1 rem */ + --space-s: clamp(1.125rem, 1.0815rem + 0.2174vi, 1.25rem); /* 1.5 rem */ + --space-m: clamp(1.6875rem, 1.6223rem + 0.3261vi, 1.875rem); /* 2 rem */ + --space-l: clamp(2.25rem, 2.163rem + 0.4348vi, 2.5rem); /* 2.5 rem */ + --space-xl: clamp(3.375rem, 3.2446rem + 0.6522vi, 3.75rem); /* 3 rem */ + --space-2xl: clamp(4.5rem, 4.3261rem + 0.8696vi, 5rem); /* 4-5 rem */ + --space-3xl: clamp(6.75rem, 6.4891rem + 1.3043vi, 7.5rem); + + /* One-up pairs */ + --space-3xs-2xs: clamp(0.3125rem, 0.2038rem + 0.5435vi, 0.625rem); + --space-2xs-xs: clamp(0.5625rem, 0.4321rem + 0.6522vi, 0.9375rem); + --space-xs-s: clamp(0.875rem, 0.7446rem + 0.6522vi, 1.25rem); + --space-s-m: clamp(1.125rem, 0.8641rem + 1.3043vi, 1.875rem); + --space-m-l: clamp(1.6875rem, 1.4049rem + 1.413vi, 2.5rem); + --space-l-xl: clamp(2.25rem, 1.7283rem + 2.6087vi, 3.75rem); + --space-xl-2xl: clamp(3.375rem, 2.8098rem + 2.8261vi, 5rem); + --space-2xl-3xl: clamp(4.5rem, 3.4565rem + 5.2174vi, 7.5rem); + + /* Custom pairs */ + --space-s-l: clamp(1.125rem, 0.6467rem + 2.3913vi, 2.5rem); +} diff --git a/web/src/assets/sass/base/_base.scss b/web/src/assets/sass/base/_base.scss new file mode 100644 index 0000000..18c9eef --- /dev/null +++ b/web/src/assets/sass/base/_base.scss @@ -0,0 +1,25 @@ +html { + height: 100%; + background-color: var(--clr-primary--darker); +} + +body { + min-height: 100%; + background-color: var(--clr-primary--darker); + color: var(--clr-text); + font-family: var(--ff-base); + font-weight: 300; + line-height: 1.5em; +} + +h1 { + font-size: var(--fs-900); +} + +h2 { + font-size: var(--fs-700); +} + +h3 { + font-size: var(--fs-600); +} diff --git a/web/src/assets/sass/base/_reset.scss b/web/src/assets/sass/base/_reset.scss new file mode 100644 index 0000000..9b95ea6 --- /dev/null +++ b/web/src/assets/sass/base/_reset.scss @@ -0,0 +1,125 @@ +//// +/// Copyright (c) 2016-2024 Martin Donath +/// +/// Permission is hereby granted, free of charge, to any person obtaining a +/// copy of this software and associated documentation files (the "Software"), +/// to deal in the Software without restriction, including without limitation +/// the rights to use, copy, modify, merge, publish, distribute, sublicense, +/// and/or sell copies of the Software, and to permit persons to whom the +/// Software is furnished to do so, subject to the following conditions: +/// +/// The above copyright notice and this permission notice shall be included in +/// all copies or substantial portions of the Software. +/// +/// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +/// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +/// FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT. IN NO EVENT SHALL +/// THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +/// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +/// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +/// DEALINGS +//// + +// ---------------------------------------------------------------------------- +// Rules +// ---------------------------------------------------------------------------- + +// Enforce correct box model and prevent adjustments of font size after +// orientation changes in IE and iOS +html { + box-sizing: border-box; + text-size-adjust: none; +} + +// All elements shall inherit the document default +*, +*::before, +*::after { + box-sizing: border-box; + + // [reduced motion]: Disable all transitions + @media (prefers-reduced-motion) { + transition: none !important; // stylelint-disable-line + } +} + +// Remove margin in all browsers +body { + margin: 0; +} + +// Reset tap outlines on iOS and Android +a, +button, +label, +input { + -webkit-tap-highlight-color: transparent; +} + +// Reset link styles +a { + color: inherit; + text-decoration: none; +} + +// Normalize horizontal separator styles +hr { + box-sizing: content-box; + display: block; + height: px2rem(1px); + padding: 0; + overflow: visible; + border: 0; +} + +// Normalize font-size in all browsers +small { + font-size: 80%; +} + +// Prevent subscript and superscript from affecting line-height +sub, +sup { + line-height: 1em; +} + +// Remove border on image +img { + border-style: none; +} + +// Reset table styles +table { + border-spacing: 0; + border-collapse: separate; +} + +// Reset table cell styles +td, +th { + font-weight: 400; + vertical-align: top; +} + +// Reset button styles +button { + padding: 0; + margin: 0; + background: transparent; + border: 0; + font: inherit; +} + +// Reset input styles +input { + border: 0; + outline: none; + font: inherit; +} + +// Inherit font for form elements + +textarea, +select { + font: inherit; +} diff --git a/web/src/assets/sass/base/_typography.scss b/web/src/assets/sass/base/_typography.scss new file mode 100644 index 0000000..ff38d5c --- /dev/null +++ b/web/src/assets/sass/base/_typography.scss @@ -0,0 +1,77 @@ +@import url('https://fonts.googleapis.com/css2?family=Ubuntu+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap'); + +:root { + --ff-base: 'Ubuntu Mono', monospace; + + --fs-200: clamp(0.75rem, 0.6522rem + 0.0435vi, 0.875rem); + --fs-300: clamp(0.9375rem, 0.9158rem + 0.1087vi, 1rem); + --fs-400: clamp(1.125rem, 1.0815rem + 0.2174vi, 1.25rem); + --fs-500: clamp(1.35rem, 1.2761rem + 0.3696vi, 1.5625rem); + --fs-600: clamp(1.62rem, 1.5041rem + 0.5793vi, 1.9531rem); + --fs-700: clamp(1.944rem, 1.771rem + 0.8651vi, 2.4414rem); + --fs-800: clamp(2.3328rem, 2.0827rem + 1.2504vi, 3.0518rem); + --fs-900: clamp(2.7994rem, 2.4462rem + 1.7658vi, 3.8147rem); + --fs-1000: clamp(3.3592rem, 2.8691rem + 2.4507vi, 4.7684rem); + --fs-1100: clamp(4.0311rem, 3.36rem + 3.3555vi, 5.9605rem); + + // ignore these so prettier doesn't put them on one line + + // prettier-ignore + --deep-3d-text-shadow: + 1px 1px 0px var(--clr-gray-400), + 2px 2px 0px var(--clr-gray-400), + 3px 3px 0px var(--clr-gray-500), + 4px 4px 0px var(--clr-gray-600), + 5px 5px 0px var(--clr-gray-700), + 6px 6px 0px var(--clr-gray-800), + 7px 7px 0px var(--clr-gray-900); + + // prettier-ignore + --deep-3d-text-shadow-elongated: + 1px 1px 0px var(--clr-gray-900), + 2px 2px 0px var(--clr-gray-800), + 3px 3px 0px var(--clr-gray-700), + 4px 4px 0px var(--clr-gray-600), + 5px 5px 0px var(--clr-gray-500), + 6px 6px 0px var(--clr-gray-400), + 7px 7px 0px var(--clr-gray-400); + + // prettier-ignore + --deep-3d-text-shadow-reversed: + 1px -1px 0px var(--clr-gray-400), + 2px -2px 0px var(--clr-gray-400), + 3px -3px 0px var(--clr-gray-500), + 4px -4px 0px var(--clr-gray-600), + 5px -5px 0px var(--clr-gray-700), + 6px -6px 0px var(--clr-gray-800), + 7px -7px 0px var(--clr-gray-900); + + // prettier-ignore + --deep-3d-text-shadow-reversed-elongated: + 1px -1px 0px var(--clr-gray-400), + 2px -2px 0px var(--clr-gray-500), + 3px -3px 0px var(--clr-gray-600), + 4px -4px 0px var(--clr-gray-700), + 5px -5px 0px var(--clr-gray-800), + 6px -6px 0px var(--clr-gray-900), + 7px -7px 0px var(--clr-gray-900); + + // prettier-ignore + --text-glow: + 0 0 7px var(--clr-gray-trans-500), + 0 0 15px var(--clr-accent); +} + +@mixin text-glow-colored($glow-color: var(--clr-accent)) { + text-shadow: + 0 0 7px var(--clr-gray-trans-500), + 0 0 15px $glow-color; +} + +@mixin deep-3d-text { + text-shadow: var(--deep-3d-text-shadow); +} + +.centered { + text-align: center; +} diff --git a/web/src/assets/sass/components/_add_edit_modal.scss b/web/src/assets/sass/components/_add_edit_modal.scss new file mode 100644 index 0000000..c54cab3 --- /dev/null +++ b/web/src/assets/sass/components/_add_edit_modal.scss @@ -0,0 +1,314 @@ +@use '../abstracts/breakpoints'; +@use '../abstracts/effects'; + +// Reusable modal overlay + window with slam-in / squeeze-close animations. +// Used by AddEditModal.vue — page-specific form content goes in the slot. + +.add-edit-modal__overlay { + position: fixed; + top: 0; + right: 0; + bottom: 0; + left: 0; + background: var(--clr-black-trans-800); + z-index: 5; + opacity: 0; + visibility: hidden; + pointer-events: none; + + &.visible { + visibility: visible; + pointer-events: auto; + animation: overlay-in 0.8s ease forwards; + } + + &.closing { + visibility: visible; + pointer-events: none; + animation: overlay-out 1.25s ease forwards; + } + + &.closing-success { + visibility: visible; + pointer-events: none; + animation: overlay-out 1.25s ease forwards; + } +} + +.add-edit-modal__window { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + background: var(--clr-gray-900); + padding: var(--space-xl); + border-top: 2px solid var(--clr-gray-400); + border-left: 2px solid var(--clr-gray-400); + border-right: 1px solid var(--clr-gray-700); + border-bottom: 1px solid var(--clr-gray-700); + z-index: 10; + border-radius: var(--space-xl); + max-height: 90vh; + overflow-y: auto; + opacity: 0; + visibility: hidden; + pointer-events: none; + + will-change: transform, opacity, filter; + + // Layered depth: deep-3d stepped shadow + big soft ambient + inner highlight + // prettier-ignore + box-shadow: + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + + &.visible { + visibility: visible; + pointer-events: auto; + animation: window-slam-in 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + &.closing { + visibility: visible; + pointer-events: none; + animation: window-cancel-squeeze 0.75s ease forwards; + } + + &.closing-success { + visibility: visible; + pointer-events: none; + animation: window-success-squeeze 0.75s ease forwards; + transform-origin: center top; + } + + @include breakpoints.phone { + width: calc(100vw - 2 * var(--space-xs)); + padding: var(--space-s); + border-radius: var(--space-m); + } +} + +// Inner form panel — callers use this class on their form element +.add-edit-modal__form { + padding: var(--space-s) var(--space-m); + text-align: center; + border-radius: var(--button-border-radius); + background-color: var(--clr-primary); + color: var(--clr-accent-light); + font-size: var(--fs-500); + + // Inner form gets its own shadow layer for depth separation from the window + // prettier-ignore + box-shadow: + var(--shadow-card), + inset 0 2px 4px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.add-edit-modal__form-item { + padding: var(--space-2xs); + flex: 1; + + label { + display: block; + margin-bottom: var(--space-xs); + } + + .textbox { + width: 100%; + } +} + +.add-edit-modal__form-row { + display: flex; + gap: var(--space-s); +} + +.add-edit-modal__form-buttons { + display: flex; + justify-content: space-evenly; + padding-top: var(--space-s); + + button { + color: var(--clr-accent-light); + font-size: var(--fs-500); + } +} + +// === Keyframe animations === + +@keyframes overlay-in { + 0% { + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes overlay-out { + 0% { + opacity: 1; + } + + 50% { + opacity: 1; + } + + 100% { + opacity: 0; + } +} + +// Rubber-band bounce: each stop uses ease-in-out so the motion decelerates +// into each peak and accelerates back — like a spring +@keyframes window-slam-in { + 0% { + transform: translate(-50%, -50%) scale(0.05) rotate(-15deg); + opacity: 0; + filter: brightness(0.3) blur(6px); + animation-timing-function: cubic-bezier(0, 0, 0.2, 1); + } + + 20% { + transform: translate(-50%, -50%) scale(1.3) rotate(2.5deg); + opacity: 1; + filter: brightness(1) blur(0); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 32% { + transform: translate(-50%, -50%) scale(0.85) rotate(-1.8deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 44% { + transform: translate(-50%, -50%) scale(1.14) rotate(1.2deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 56% { + transform: translate(-50%, -50%) scale(0.93) rotate(-0.6deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 68% { + transform: translate(-50%, -50%) scale(1.06) rotate(0.3deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 80% { + transform: translate(-50%, -50%) scale(0.98) rotate(-0.1deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 90% { + transform: translate(-50%, -50%) scale(1.015) rotate(0.05deg); + animation-timing-function: cubic-bezier(0.42, 0, 0.58, 1); + } + + 100% { + transform: translate(-50%, -50%) scale(1) rotate(0deg); + opacity: 1; + filter: brightness(1); + } +} + +// Cancel: smooth inhale then squeeze to nothing +@keyframes window-cancel-squeeze { + 0% { + transform: translate(-50%, -50%) scale(1) rotate(0deg); + opacity: 1; + filter: brightness(1); + animation-timing-function: ease-out; + } + + 30% { + transform: translate(-50%, -50%) scale(1.25) rotate(-2deg); + opacity: 1; + filter: brightness(1.1); + animation-timing-function: ease-in; + } + + 95% { + opacity: 1; + } + + 100% { + transform: translate(-50%, -50%) scale(0) rotate(8deg); + opacity: 0; + filter: brightness(0.5); + } +} + +// Success: same inhale-squeeze as cancel but with green+white glow at peak +// Dark depth shadows persist throughout — green/white added as top layers +// Same layer count in every keyframe so the browser can interpolate +@keyframes window-success-squeeze { + 0% { + transform: translate(-50%, -50%) scale(1) rotate(0deg); + opacity: 1; + filter: brightness(1) hue-rotate(0deg); + // prettier-ignore + box-shadow: + 0 0 0 rgba(80, 255, 80, 0), + 0 0 0 rgba(255, 255, 255, 0), + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + animation-timing-function: ease-out; + } + + 10% { + // prettier-ignore + box-shadow: + 0 0 0 rgba(80, 255, 80, 0), + 0 0 0 rgba(255, 255, 255, 0), + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + } + + 30% { + transform: translate(-50%, -50%) scale(1.25) rotate(-2deg); + opacity: 1; + filter: brightness(1.3) hue-rotate(15deg); + // prettier-ignore + box-shadow: + 0 0 40px rgba(80, 255, 80, 0.95), + 0 0 80px rgba(255, 255, 255, 0.2), + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + animation-timing-function: ease-in; + } + + 95% { + opacity: 1; + } + + 100% { + transform: translate(-50%, -50%) scale(0) rotate(8deg); + opacity: 0; + filter: brightness(0.5) hue-rotate(0deg); + // prettier-ignore + box-shadow: + 0 0 0 rgba(80, 255, 80, 0), + 0 0 0 rgba(255, 255, 255, 0), + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + } +} diff --git a/web/src/assets/sass/components/_buttons.scss b/web/src/assets/sass/components/_buttons.scss new file mode 100644 index 0000000..d15a01b --- /dev/null +++ b/web/src/assets/sass/components/_buttons.scss @@ -0,0 +1,428 @@ +@use '../base/typography'; + +:root { + --button-border-radius: var(--radius-lg); + // The corner a panel, card or code block takes. + --border-radius: var(--button-border-radius); + + // ─── Neumorphic shadow system ─────────────────────────────────────── + // + // Three shadow states for interactive elements: resting, hover, pressed. + // These are CSS custom properties (not SCSS variables) because they use + // oklch(from var(--clr-primary) ...) which resolves at RUNTIME based on + // the active theme. SCSS variables would bake values at compile time. + // + // ARCHITECTURE: + // CSS vars (here) → define the shadow VALUES (adapt to theme) + // SCSS mixin below → defines the BEHAVIOR (which var for which state) + // Consumer → includes the mixin, provides only layout + // + // SHADOW LAYER RULES: + // - Resting and hover MUST have the same number of layers (10) + // - Each layer position MUST match inset/outer across both states + // - CSS can only interpolate between shadows with identical layer + // counts and types — mismatches cause instant snapping not smooth transition + // - Unused layers: pad with `0px 0px 0px transparent` (inset or outer to match) + // - Pressed has a different inset/outer pattern (snaps, doesn't interpolate) + // which is fine — press is a discrete state, not a continuous hover + // + // HIGHLIGHT COLORS: + // Uses --clr-shadow-highlight-* (defined in _colors.scss) which derive + // from --clr-primary via OKLCH relative color syntax. This means highlights + // carry the theme's hue instead of neutral gray, avoiding simultaneous + // contrast artifacts (e.g. yellow cast on purple backgrounds). + // + // PHYSICAL MODEL (light source top-left): + // Resting: sitting on surface — dark bottom-right, highlight top-left + // Hover: lifted off surface — larger/softer shadows, translateY(-2px) + // Pressed: pushed into surface — inset shadows, translateY(1px) + + // prettier-ignore + --shadow-button: + 0 -2px 2px var(--clr-black-trans-200), + 0px 4px 4px var(--clr-black-trans-400), + 0px 16px 16px var(--clr-black-trans-600), + 16px 8px 16px var(--clr-black-trans-600), + inset 5px 5px 10px var(--clr-black-trans-200), + inset -10px -10px 10px var(--clr-black-trans-200), + inset -6px -6px 14px var(--clr-black-trans-600), + -4px -4px 10px var(--clr-shadow-highlight-md), + 0px 0px 0px transparent, + inset 6px 6px 10px var(--clr-shadow-highlight-md); + + // prettier-ignore + --shadow-button-hover: + 0 -3px 3px var(--clr-black-trans-200), + 0px 7px 7px var(--clr-black-trans-400), + 0px 18px 18px var(--clr-black-trans-600), + 18px 10px 18px var(--clr-black-trans-600), + inset 0px 0px 0px transparent, + inset 0px 0px 0px transparent, + inset -10px -10px 16px var(--clr-black-trans-600), + -5px -5px 12px var(--clr-shadow-highlight-lg), + 0px 0px 0px transparent, + inset 10px 10px 16px var(--clr-shadow-highlight-lg); + + // prettier-ignore + --shadow-button-active: + inset 2px 2px 5px var(--clr-black-trans-800), + -2px -2px 5px var(--clr-black-trans-800), + inset 4px 4px 8px var(--clr-black-trans-800), + -8px -8px 12px var(--clr-black-trans-600), + inset 8px 8px 32px var(--clr-black-trans-400), + inset -2px -2px 5px var(--clr-shadow-highlight-sm), + 2px 2px 5px var(--clr-shadow-highlight-md), + inset -4px -4px 8px var(--clr-shadow-highlight-md), + 8px 8px 24px var(--clr-shadow-highlight-lg), + inset -8px -8px 32px var(--clr-shadow-highlight-md); +} + +// ─── Neumorphic button mixin ──────────────────────────────────────── +// +// Applies the complete --shadow-button-* system with hover lift, pressed +// sink, and consistent transitions. Consumer provides layout (padding, +// flex, font) — this mixin provides the effect. +// +// $active-class: CSS class suffix for pressed state (e.g., '--active', '--selected') +// If null, uses :active pseudo-class instead +// $hover-transform: any CSS transform on hover (default: scale(1.02)) +// $pressed-transform: any CSS transform when pressed (default: scale(0.99) — subtle shrink) +// Set to none for shadow-only state changes +// $hover-color: text color on hover (default: brighten to --clr-text) +// $active-color: text color when active/selected (default: accent) +@mixin neu-button( + $active-class: null, + $hover-transform: scale(1.02), + $pressed-transform: scale(0.99), + $hover-color: var(--clr-text), + $active-color: var(--clr-accent) +) { + border-radius: var(--button-border-radius); + background-color: var(--clr-primary); + box-shadow: var(--shadow-button); + color: var(--clr-gray-100); + cursor: pointer; + transition: all var(--transition-fast) ease; + will-change: transform; + position: relative; + + @if $active-class { + &:hover:not(&#{$active-class}) { + box-shadow: var(--shadow-button-hover); + color: $hover-color; + transform: $hover-transform; + z-index: 2; + } + + &#{$active-class} { + box-shadow: var(--shadow-button-active); + background-color: var(--clr-primary--darker); + color: $active-color; + transform: $pressed-transform; + } + } @else { + &:hover { + box-shadow: var(--shadow-button-hover); + color: $hover-color; + transform: $hover-transform; + z-index: 2; + } + + &:active { + box-shadow: var(--shadow-button-active); + background-color: var(--clr-primary--darker); + color: $active-color; + transform: $pressed-transform; + } + } +} + +@mixin button-boxy($bottom-color: var(--clr-gray-200), $right-color: var(--clr-gray-050)) { + box-shadow: + 1px 0 0 $right-color, + 1px 1px 0 $bottom-color, + 2px 1px 0 $right-color, + 2px 2px 0 $bottom-color, + 3px 2px 0 $right-color, + 3px 3px 0 $bottom-color, + 4px 3px 0 $right-color, + 4px 4px 0 $bottom-color; +} + +@mixin button-textbox { + margin: var(--space-2xs); + padding: var(--space-2xs) var(--space-m); + font-size: var(--fs-400); + color: var(--clr-text); + background-color: var(--clr-gray-800); + border-radius: var(--button-border-radius); + box-shadow: + 5px 5px 5px 0px var(--clr-black-trans-1000), + inset 0px 0px 5px 0px var(--clr-gray-400), + inset -5px -5px 4px -2px var(--clr-gray-400); +} + +.button { + @include button-textbox; + text-shadow: 2px 4px 1px var(--clr-black-trans-800); + transition: all var(--transition-fast) ease-in-out; + + &--pressed { + // invert the regular button-3d box-shadow + transform: scale(1.05); /* a depressed button looks smaller */ + color: var(--clr-accent-light); + box-shadow: var(--shadow-card-active); + } + + &--hidden { + // for submit buttons that are using an icon + background: none; + border: none; + padding: 0; + } + + &--success { + &:hover { + color: var(--clr-success); + } + } + + &--warning { + &:hover { + color: var(--clr-warning); + } + } + + &--danger { + &:hover { + color: var(--clr-error); + } + } + + &:hover:not(.button--pressed, .button--success, .button--warning, .button--danger) { + @include typography.text-glow-colored; + } + + &__text { + display: inline-block; + padding-bottom: 2px; /*the space for the gradient*/ + background: linear-gradient(var(--clr-text), var(--clr-text)) center bottom; + background-size: 0% 2px; + background-repeat: no-repeat; + transition: all var(--transition-fast) ease-in-out; + + &:hover { + background-size: 100% 2px; + } + + &--success { + background: linear-gradient(var(--clr-success), var(--clr-success)) center bottom; + background-size: 0% 2px; + background-repeat: no-repeat; + + &:hover { + background-size: 100% 2px; + } + } + &--warning { + background: linear-gradient(var(--clr-warning), var(--clr-warning)) center bottom; + background-size: 0% 2px; + background-repeat: no-repeat; + + &:hover { + background-size: 100% 2px; + } + } + &--danger { + background: linear-gradient(var(--clr-error), var(--clr-error)) center bottom; + background-size: 0% 2px; + background-repeat: no-repeat; + + &:hover { + background-size: 100% 2px; + } + } + } +} + +.button-icon { + // for submit buttons that are using a fontawesome icon + transition: all var(--transition-medium) ease-in-out; + color: var(--clr-text); + + &:hover:not(.pressed) { + transform: scale(1.8); + transition: all var(--transition-medium) ease-in-out; + color: var(--clr-accent-light); + } + &.pressed { + color: var(--clr-accent); + } + + &.success { + &:hover { + color: var(--clr-success); + } + } + + &.warning { + &:hover { + color: var(--clr-warning); + } + } + + &.danger { + &:hover { + color: var(--clr-error); + } + } +} + +.button-toggle-selector { + padding: var(--space-s); + border-radius: var(--button-border-radius); + background-color: var(--clr-primary); + box-shadow: var(--shadow-card); + font-size: var(--fs-400); + color: var(--clr-text); + text-align: center; + + &.pressed { + // invert the regular button-3d box-shadow + transform: scale(1.05); /* a depressed button looks smaller */ + color: var(--clr-accent-light); + box-shadow: var(--shadow-card-active); + } + + &.left { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } + + &.right { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } +} + +@mixin action-button( + $button-size: 75px, + $button-step-blend: 1px, + $button-press-time: 0.4s, + $content-ratio: 0.3, + $text-and-icon-color: var(--clr-accent-light), + $selected-color: var(--clr-accent) +) { + position: relative; + width: $button-size; + height: $button-size; + + display: flex; + justify-content: center; + align-items: center; + border-radius: $button-size; + + color: $text-and-icon-color; + transition: all $button-press-time ease-in-out; + + $button-outline-width: calc($button-size / 2); + $button-outline-size: $button-size + $button-outline-width; + $button-press-delay: $button-press-time * 0.3; + + &__text { + position: relative; + z-index: 2; + cursor: pointer; + font-size: $button-size * $content-ratio; + line-height: 1; + text-align: center; + } + + &__icon { + /* This works with svg but not fontawesome ie (fa-solid fa-code) + must use class names for sizing */ + width: $button-size * $content-ratio; + height: $button-size * $content-ratio; + } + + &:hover { + color: $selected-color; + transition: all $button-press-time ease-in-out; + } + + &::before { + /* GRAY BUTTON ON TOP */ + position: absolute; + top: 0; + left: 0; + display: block; + width: $button-size; + height: $button-size; + content: ''; + border-radius: $button-size; + background-color: var(--clr-gray-800); + filter: blur($button-step-blend * 0.5); + transition: all $button-press-time ease-in-out; + box-shadow: + 8px 8px 20px var(--clr-black-trans-600), + -8px -8px 20px var(--clr-shadow-highlight-lg), + inset -8px -8px 20px var(--clr-black-trans-1000), + inset 8px 8px 20px var(--clr-shadow-highlight-md); + } + + &:hover::before { + background-color: var(--clr-gray-900); + filter: blur($button-step-blend * 3); + transition: all $button-press-time ease-in-out; + box-shadow: + -8px -8px 20px var(--clr-black-trans-600), + 4px 4px 20px var(--clr-shadow-highlight-lg), + inset 8px 8px 20px var(--clr-black-trans-1000), + inset -8px -8px 20px var(--clr-shadow-highlight-md); + } + + &::after { + /* OUTER RING + The outer ring has a delay for the press animation to make it appear + that it is being depressed by the inner button. + */ + + position: absolute; + translate: transform(-50%, -50%); + display: block; + width: $button-outline-size; + height: $button-outline-size; + content: ''; + cursor: pointer; + border-radius: $button-outline-size; + filter: blur($button-step-blend); + transition: all $button-press-time ease-in-out $button-press-delay; + box-shadow: + 8px 8px 20px var(--clr-black-trans-600), + -4px -4px 20px var(--clr-gray-trans-800), + inset -8px -8px 20px var(--clr-black-trans-1000), + inset 8px 8px 20px var(--clr-gray-trans-600); + } + + &:hover::after { + transform: scale(1.01); /* changing the shadow makes the button appear smaller, so scale up */ + filter: blur($button-step-blend * 3); + transition: all $button-press-time ease-in-out $button-press-delay; + box-shadow: + -8px -8px 20px var(--clr-black-trans-600), + 4px 4px 20px var(--clr-shadow-highlight-lg), + inset 8px 8px 20px var(--clr-black-trans-1000), + inset -8px -8px 20px var(--clr-shadow-highlight-md); + } +} + +.button-sidebar-toggle { + $button-size: 50px; + margin: var(--space-xs) auto; + @include action-button($button-size); +} + +.button-sidebar-tab { + $button-size: 40px; + @include action-button($button-size); +} diff --git a/web/src/assets/sass/components/_flash_messages.scss b/web/src/assets/sass/components/_flash_messages.scss new file mode 100644 index 0000000..574f642 --- /dev/null +++ b/web/src/assets/sass/components/_flash_messages.scss @@ -0,0 +1,134 @@ +@use '../abstracts/breakpoints'; + +.flash-messages { + position: fixed; + right: var(--space-m); + bottom: var(--space-m); + width: 40%; + z-index: 9999; + + @include breakpoints.phone { + left: var(--space-xs); + right: var(--space-xs); + bottom: calc(var(--space-xs) + env(safe-area-inset-bottom)); + width: auto; + } + + &__list { + display: flex; + flex-direction: column; + } + + &__dismiss-all { + display: flex; + justify-content: flex-end; + } + + &__dismiss-all-button { + padding: var(--space-3xs) var(--space-s); + border-radius: var(--button-border-radius); + background-color: var(--clr-primary--lighter); + box-shadow: + inset -8px -8px 20px var(--clr-black-trans-1000), + inset 8px 8px 20px var(--clr-shadow-highlight-md); + color: var(--clr-gray-300); + font-size: var(--fs-200); + cursor: pointer; + display: flex; + align-items: center; + gap: var(--space-3xs); + transition: all var(--transition-medium) ease; + + &:hover { + color: var(--clr-error); + } + } + + &__message { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-xs); + padding: var(--space-3xs) var(--space-s); + border-radius: var(--button-border-radius); + background-color: var(--clr-primary--lighter); + box-shadow: + inset -8px -8px 20px var(--clr-black-trans-1000), + inset 8px 8px 20px var(--clr-shadow-highlight-md); + font-size: var(--fs-300); + line-height: 1.5em; + color: var(--clr-text); + border-left: 3px solid transparent; + + &--success { + border-left-color: var(--clr-success); + } + + &--warning { + border-left-color: var(--clr-warning); + } + + &--error { + border-left-color: var(--clr-error); + } + } + + &__text { + flex: 1; + } + + &__close-button { + background: none; + color: var(--clr-gray-400); + font-size: var(--fs-300); + cursor: pointer; + flex-shrink: 0; + padding: var(--space-3xs); + transition: color var(--transition-medium) ease; + + &:hover { + color: var(--clr-error); + } + } +} + +// Vue TransitionGroup animations for notifications +// +// WHY GLOBAL (not scoped): moves DOM outside the component +// tree, so scoped styles (which add data-v-xxx attribute selectors) don't match. +// +// WHY NO position:absolute ON LEAVE: TransitionGroup's FLIP move animation sets +// inline `transform: translate()` which overwrites our `transform: scale(0)`. +// The CSS `scale` property (individual transform) should compose independently, +// but doesn't work reliably across browsers. Without position:absolute, the +// leaving element keeps its layout space so siblings jump instead of sliding — +// an acceptable tradeoff for working leave animations. +// +// WHY v-show + delayed hide ON CONTAINER: v-if destroys the DOM immediately when +// the last notification is removed, killing its leave animation. v-show keeps the +// DOM alive, and a 1.5s JS timer hides the container after the animation finishes. +// +// WHY appear ON TransitionGroup: without it, the first notification (which causes +// TransitionGroup to mount via v-show) renders without an enter animation. + +.notification-enter-active { + transition: + opacity 1s ease, + transform 1s cubic-bezier(0.34, 1.56, 0.64, 1); +} + +.notification-leave-active { + transition: + opacity 0.6s ease-in 0.4s, + transform 1s cubic-bezier(0.36, 0, 0.66, -0.56); +} + +.notification-move { + transition: transform 1s ease; +} + +.notification-enter-from, +.notification-leave-to { + opacity: 0; + transform: scale(0); +} diff --git a/web/src/assets/sass/components/_listing_modal.scss b/web/src/assets/sass/components/_listing_modal.scss new file mode 100644 index 0000000..8743b76 --- /dev/null +++ b/web/src/assets/sass/components/_listing_modal.scss @@ -0,0 +1,95 @@ +// Listing/table modal — sibling to AddEditModal for data-heavy content. +// Reuses the same overlay and animation keyframes from _add_edit_modal.scss. +// The window gets a responsive width so grid/table layouts can resolve fr units. + +.listing-modal__window { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%) scale(0); + width: clamp(600px, 80vw, 1200px); + max-height: 85vh; + background: var(--clr-gray-900); + padding: var(--space-xl); + border-top: 2px solid var(--clr-gray-400); + border-left: 2px solid var(--clr-gray-400); + border-right: 1px solid var(--clr-gray-700); + border-bottom: 1px solid var(--clr-gray-700); + z-index: 10; + border-radius: var(--space-xl); + opacity: 0; + visibility: hidden; + pointer-events: none; + overflow: hidden; // window doesn't scroll — content panel does + + will-change: transform, opacity, filter; + + // Same layered depth as AddEditModal + // prettier-ignore + box-shadow: + var(--deep-3d-box-shadow), + 0 40px 80px rgba(0, 0, 0, 0.6), + 0 20px 40px rgba(0, 0, 0, 0.4), + inset 0 1px 0 rgba(255, 255, 255, 0.08), + inset 0 0 30px rgba(0, 0, 0, 0.3); + + // Reuse the same animation classes as AddEditModal + &.visible { + visibility: visible; + pointer-events: auto; + animation: window-slam-in 1.8s cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + &.closing { + visibility: visible; + pointer-events: none; + animation: window-cancel-squeeze 0.75s ease forwards; + } + + &.closing-success { + visibility: visible; + pointer-events: none; + animation: window-success-squeeze 0.75s ease forwards; + transform-origin: center top; + } +} + +// Inner content panel — static frame with heading and buttons +.listing-modal__content { + position: relative; + padding: var(--space-s) var(--space-m); + border-radius: var(--button-border-radius); + background-color: var(--clr-primary--darker); + color: var(--clr-accent-light); + font-size: var(--fs-500); + + // prettier-ignore + box-shadow: + var(--shadow-card), + inset 0 2px 4px rgba(0, 0, 0, 0.3), + 0 4px 16px rgba(0, 0, 0, 0.4); +} + +// Scrollable list area — sits between heading and buttons, fades at edges +.listing-modal__scrollable { + max-height: calc(70vh - var(--space-xl) * 2); + overflow-y: auto; + scrollbar-width: none; + &::-webkit-scrollbar { + display: none; + } + background-color: var(--clr-primary); + border-radius: var(--button-border-radius); + padding: var(--space-xs); + box-shadow: var(--shadow-button-active); +} + +.listing-modal__title { + text-align: center; +} + +.listing-modal__buttons { + display: flex; + justify-content: center; + padding-top: var(--space-s); +} diff --git a/web/src/assets/sass/layout/_forms.scss b/web/src/assets/sass/layout/_forms.scss new file mode 100644 index 0000000..5f5aee4 --- /dev/null +++ b/web/src/assets/sass/layout/_forms.scss @@ -0,0 +1,72 @@ +@use '../components/buttons'; + +.textbox { + @include buttons.button-textbox; + width: 90%; // no need to specify columns or size with this + border: var(--border-input); + border-radius: var(--button-border-radius); + font-size: var(--fs-400); + padding: 0.5em 1.75em; + color: var(--clr-text); + background-color: var(--clr-gray-800); + + &--error { + border: 2px solid var(--clr-error); + animation: textbox-error-flash 1s ease forwards; + } +} + +@keyframes textbox-error-flash { + 0% { + border-color: white; + box-shadow: + 0 0 20px rgba(255, 255, 255, 0.8), + 0 0 40px rgba(255, 60, 60, 0.6); + } + + 100% { + border-color: var(--clr-error); + box-shadow: none; + } +} + +.add-item-wrapper { + width: clamp(50svw, 50rem, 80svw); + margin: auto; + margin-bottom: var(--space-2xl); + padding: var(--space-m); + + text-align: center; + font-size: var(--fs-400); + line-height: 1.5em; + + border-radius: var(--button-border-radius); + background-color: var(--clr-black-trans-600); + box-shadow: + var(--shadow-card), + -1px -2px 3px var(--clr-black-trans-200), + inset -5px -5px 12px var(--clr-gray-trans-900), + inset 3px 3px 12px var(--clr-shadow-highlight-lg); +} + +.add-item-form { + display: flex; + flex-wrap: wrap; + gap: var(--space-m); + justify-content: center; + + &__item { + flex-grow: 1; // Allow the item to grow to fill the container + flex-shrink: 0; // Prevent the item from shrinking + flex-basis: 40%; // Specify the initial size of the item + label { + display: block; + } + + &--full-width { + flex-grow: 1; + flex-shrink: 0; + flex-basis: 80%; + } + } +} diff --git a/web/src/assets/sass/main.scss b/web/src/assets/sass/main.scss new file mode 100644 index 0000000..01ac7d7 --- /dev/null +++ b/web/src/assets/sass/main.scss @@ -0,0 +1,20 @@ +// The app's global styles. Partials resolve from this directory through Vite's +// loadPaths, so a component's scoped style can `@use 'components/buttons'`. +// FontAwesome is loaded as CSS imports in main.ts. + +@use 'abstracts/colors'; +@use 'abstracts/spacing'; +@use 'abstracts/effects'; +@use 'abstracts/shape'; +@use 'abstracts/card_shadows'; + +@use 'base/reset'; +@use 'base/typography'; +@use 'base/base'; + +@use 'layout/forms'; + +@use 'components/buttons'; +@use 'components/flash_messages'; +@use 'components/add_edit_modal'; +@use 'components/listing_modal'; diff --git a/web/src/components/ActionButton.vue b/web/src/components/ActionButton.vue new file mode 100644 index 0000000..b1dcf0e --- /dev/null +++ b/web/src/components/ActionButton.vue @@ -0,0 +1,45 @@ + + + + + diff --git a/web/src/components/AddEditModal.vue b/web/src/components/AddEditModal.vue new file mode 100644 index 0000000..aaa4f21 --- /dev/null +++ b/web/src/components/AddEditModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/web/src/components/ListingModal.vue b/web/src/components/ListingModal.vue new file mode 100644 index 0000000..a3238b8 --- /dev/null +++ b/web/src/components/ListingModal.vue @@ -0,0 +1,72 @@ + + + diff --git a/web/src/components/NeuSelect.vue b/web/src/components/NeuSelect.vue new file mode 100644 index 0000000..2713e1e --- /dev/null +++ b/web/src/components/NeuSelect.vue @@ -0,0 +1,230 @@ + + + + + + + + diff --git a/web/src/components/NotificationToast.vue b/web/src/components/NotificationToast.vue new file mode 100644 index 0000000..b170250 --- /dev/null +++ b/web/src/components/NotificationToast.vue @@ -0,0 +1,66 @@ + + + diff --git a/web/src/components/projects/AddEditProjectItemModal.vue b/web/src/components/projects/AddEditProjectItemModal.vue new file mode 100644 index 0000000..75dd12d --- /dev/null +++ b/web/src/components/projects/AddEditProjectItemModal.vue @@ -0,0 +1,123 @@ + + + + + diff --git a/web/src/components/projects/AddEditProjectModal.vue b/web/src/components/projects/AddEditProjectModal.vue new file mode 100644 index 0000000..a1c87c7 --- /dev/null +++ b/web/src/components/projects/AddEditProjectModal.vue @@ -0,0 +1,143 @@ + + + + + diff --git a/web/src/components/projects/DropProjectModal.vue b/web/src/components/projects/DropProjectModal.vue new file mode 100644 index 0000000..ae8285c --- /dev/null +++ b/web/src/components/projects/DropProjectModal.vue @@ -0,0 +1,90 @@ + + + + + diff --git a/web/src/components/projects/ManageDependenciesModal.vue b/web/src/components/projects/ManageDependenciesModal.vue new file mode 100644 index 0000000..3748dca --- /dev/null +++ b/web/src/components/projects/ManageDependenciesModal.vue @@ -0,0 +1,298 @@ + + + + + diff --git a/web/src/components/projects/ManageProjectsModal.vue b/web/src/components/projects/ManageProjectsModal.vue new file mode 100644 index 0000000..5e8b88d --- /dev/null +++ b/web/src/components/projects/ManageProjectsModal.vue @@ -0,0 +1,266 @@ + + + + + diff --git a/web/src/components/projects/ProjectItemTasks.vue b/web/src/components/projects/ProjectItemTasks.vue new file mode 100644 index 0000000..8bbee73 --- /dev/null +++ b/web/src/components/projects/ProjectItemTasks.vue @@ -0,0 +1,216 @@ + + + + + diff --git a/web/src/composables/useNotifications.ts b/web/src/composables/useNotifications.ts new file mode 100644 index 0000000..be27fcc --- /dev/null +++ b/web/src/composables/useNotifications.ts @@ -0,0 +1,54 @@ +import { ref } from 'vue' + +export interface Notification { + id: number + message: string + category: 'success' | 'warning' | 'error' + dismissTimer: ReturnType | null +} + +const AUTO_DISMISS_MS = 10_000 + +let nextId = 0 + +const notifications = ref([]) + +export function useNotifications() { + function show(message: string, category: Notification['category'] = 'success') { + const id = nextId++ + const notification: Notification = { + id, + message, + category, + dismissTimer: setTimeout(() => close(id), AUTO_DISMISS_MS), + } + notifications.value.push(notification) + } + + function close(id: number) { + const idx = notifications.value.findIndex((n) => n.id === id) + if (idx !== -1) { + const notification = notifications.value[idx]! + if (notification.dismissTimer) { + clearTimeout(notification.dismissTimer) + } + notifications.value.splice(idx, 1) + } + } + + function closeAll() { + for (const n of notifications.value) { + if (n.dismissTimer) { + clearTimeout(n.dismissTimer) + } + } + notifications.value.splice(0) + } + + return { + notifications, + show, + close, + closeAll, + } +} diff --git a/web/src/env.d.ts b/web/src/env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/web/src/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..56132e2 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,13 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import App from './App.vue' + +import '@fortawesome/fontawesome-free/css/fontawesome.css' +import '@fortawesome/fontawesome-free/css/solid.css' +import './assets/sass/main.scss' + +const app = createApp(App) + +app.use(createPinia()) + +app.mount('#app') diff --git a/web/src/stores/__tests__/projects.test.ts b/web/src/stores/__tests__/projects.test.ts new file mode 100644 index 0000000..55a05da --- /dev/null +++ b/web/src/stores/__tests__/projects.test.ts @@ -0,0 +1,453 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useProjectsStore } from '../projects' +import { ApiError } from '@/api/errors' + +vi.mock('@/api/client', () => ({ + api: { + get: vi.fn(), + post: vi.fn(), + patch: vi.fn(), + delete: vi.fn(), + }, +})) + +import { api } from '@/api/client' +const mockApi = vi.mocked(api) + +const testProjects = [ + { id: 1, name: 'Music Setup', position: 0, created_at: '2026-01-01T00:00:00Z', item_count: 3 }, + { id: 2, name: 'Home Renovation', position: 1, created_at: '2026-02-01T00:00:00Z', item_count: 2 }, + { id: 3, name: 'Career Goals', position: 2, created_at: '2026-03-01T00:00:00Z', item_count: 0 }, +] + +const testItems = [ + { + id: 10, + title: 'Decide budget', + notes: 'Check forums', + completed: false, + archived: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + position: 0, + }, + { + id: 11, + title: 'Research interfaces', + notes: null, + completed: false, + archived: false, + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + position: 1, + }, + { + id: 12, + title: 'Buy interface', + notes: 'Wait for sale', + completed: true, + archived: false, + created_at: '2026-01-03T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + position: 2, + }, +] + +const testItemDetail = { + id: 10, + title: 'Decide budget', + notes: 'Check forums', + completed: false, + archived: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + projects: [{ id: 1, name: 'Music Setup', position: 0, created_at: '2026-01-01T00:00:00Z' }], + dependency_ids: [11], +} + +describe('useProjectsStore', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() + // Default: blocker fetches return empty (most tests don't need blockers) + mockApi.get.mockImplementation((url: string) => { + if (typeof url === 'string' && url.includes('/blockers')) { + return Promise.resolve({ data: [] }) + } + return Promise.resolve({ data: [] }) + }) + }) + + // --- createProject --- + + it('creates a project and refetches', async () => { + mockApi.post.mockResolvedValue({ data: { id: 4, name: 'New', position: 3 } }) + mockApi.get.mockResolvedValue({ data: [...testProjects] }) + const store = useProjectsStore() + + await store.createProject({ name: 'New' }) + + expect(mockApi.post).toHaveBeenCalledWith('/projects', { name: 'New' }) + expect(mockApi.get).toHaveBeenCalledWith('/projects', { params: { status: 'active' } }) + }) + + // --- updateProject --- + + it('updates a project and refetches', async () => { + mockApi.patch.mockResolvedValue({ data: { id: 1, name: 'Renamed', position: 0 } }) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + + await store.updateProject(1, { name: 'Renamed' }) + + expect(mockApi.patch).toHaveBeenCalledWith('/projects/1', { name: 'Renamed' }) + }) + + // --- removeProject --- + + it('removes a project from local state', async () => { + mockApi.delete.mockResolvedValue({}) + const store = useProjectsStore() + store.projects = [...testProjects] + + await store.removeProject(2) + + expect(mockApi.delete).toHaveBeenCalledWith('/projects/2') + expect(store.projects).toHaveLength(2) + expect(store.projects.find((p) => p.id === 2)).toBeUndefined() + }) + + it('clears selection when removing selected project', async () => { + mockApi.delete.mockResolvedValue({}) + const store = useProjectsStore() + store.projects = [...testProjects] + store.selectedProjectId = 2 + store.items = [...testItems] + + await store.removeProject(2) + + expect(store.selectedProjectId).toBeNull() + expect(store.items).toEqual([]) + }) + + it('sets error on remove failure', async () => { + const apiError = new ApiError({ message: 'fail', detail: 'Has orphan items', status: 409 }) + mockApi.delete.mockRejectedValue(apiError) + const store = useProjectsStore() + store.projects = [...testProjects] + + await expect(store.removeProject(1)).rejects.toThrow(ApiError) + expect(store.projects).toHaveLength(3) + }) + + // --- sortedProjects --- + + it('sorts projects by position', () => { + const store = useProjectsStore() + store.projects = [testProjects[2]!, testProjects[0]!, testProjects[1]!] + + expect(store.sortedProjects[0]!.name).toBe('Music Setup') + expect(store.sortedProjects[1]!.name).toBe('Home Renovation') + expect(store.sortedProjects[2]!.name).toBe('Career Goals') + }) + + // --- fetchItems --- + + it('fetches items for a project and sets state', async () => { + mockApi.get.mockImplementation((url: string) => { + if (url === '/projects/1/items?status=all') return Promise.resolve({ data: testItems }) + return Promise.resolve({ data: [] }) + }) + const store = useProjectsStore() + + await store.fetchItems(1) + + expect(store.selectedProjectId).toBe(1) + expect(store.items).toEqual(testItems) + expect(store.itemsLoading).toBe(false) + }) + + // --- sortedItems --- + + it('sorts items by position', () => { + const store = useProjectsStore() + store.items = [testItems[2]!, testItems[0]!, testItems[1]!] + + expect(store.sortedItems[0]!.title).toBe('Decide budget') + expect(store.sortedItems[1]!.title).toBe('Research interfaces') + expect(store.sortedItems[2]!.title).toBe('Buy interface') + }) + + // --- createItem --- + + it('creates an item and refetches projects and items', async () => { + const newItemDetail = { ...testItemDetail, id: 20, title: 'New item', dependency_ids: [], projects: [] } + mockApi.post.mockResolvedValue({ data: newItemDetail }) + mockApi.get.mockImplementation((url: string) => { + if (url === '/projects') return Promise.resolve({ data: testProjects }) + if (url === '/projects/1/items?status=all') return Promise.resolve({ data: testItems }) + return Promise.resolve({ data: [] }) + }) + const store = useProjectsStore() + store.selectedProjectId = 1 + + const result = await store.createItem({ title: 'New item', project_ids: [1] }) + + expect(mockApi.post).toHaveBeenCalledWith('/project-items', { title: 'New item', project_ids: [1] }) + expect(result.id).toBe(20) + }) + + // --- updateItem --- + + it('updates an item in place preserving position', async () => { + const updatedItem = { + id: 10, + title: 'Updated', + notes: 'New', + completed: false, + archived: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + } + mockApi.patch.mockResolvedValue({ data: updatedItem }) + const store = useProjectsStore() + store.items = [...testItems] + + await store.updateItem(10, { title: 'Updated', notes: 'New' }) + + expect(mockApi.patch).toHaveBeenCalledWith('/project-items/10', { title: 'Updated', notes: 'New' }) + const item = store.items.find((i) => i.id === 10) + expect(item!.title).toBe('Updated') + expect(item!.position).toBe(0) // position preserved from original + }) + + // --- removeItem --- + + it('removes an item and refetches projects', async () => { + mockApi.delete.mockResolvedValue({}) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + store.items = [...testItems] + + await store.removeItem(11) + + expect(mockApi.delete).toHaveBeenCalledWith('/project-items/11') + expect(store.items).toHaveLength(2) + expect(store.items.find((i) => i.id === 11)).toBeUndefined() + }) + + // --- reorderItems --- + + it('sends position updates only for items that moved', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + const store = useProjectsStore() + store.selectedProjectId = 1 + store.items = [...testItems] // positions: 0, 1, 2 + + // Reorder: swap first two items + await store.reorderItems([11, 10, 12]) + + // Item 10 moved from position 0 → 1, item 11 from 1 → 0 + // Item 12 stays at position 2 (no call) + expect(mockApi.patch).toHaveBeenCalledTimes(2) + expect(mockApi.patch).toHaveBeenCalledWith('/project-items/11/reorder', { project_id: 1, position: 0 }) + expect(mockApi.patch).toHaveBeenCalledWith('/project-items/10/reorder', { project_id: 1, position: 1 }) + }) + + // --- reorderProjects --- + + it('sends position updates for moved projects', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + const store = useProjectsStore() + store.projects = [...testProjects] + + await store.reorderProjects([3, 1, 2]) + + expect(mockApi.patch).toHaveBeenCalledWith('/projects/3', { position: 0 }) + expect(mockApi.patch).toHaveBeenCalledWith('/projects/1', { position: 1 }) + expect(mockApi.patch).toHaveBeenCalledWith('/projects/2', { position: 2 }) + }) + + // --- archiveItem --- + + it('archiveItem calls updateItem with archived: true', async () => { + const updatedItem = { ...testItems[0], archived: true } + mockApi.patch.mockResolvedValue({ data: updatedItem }) + const store = useProjectsStore() + store.items = [...testItems] + + await store.archiveItem(10) + + expect(mockApi.patch).toHaveBeenCalledWith('/project-items/10', { archived: true }) + }) + + // --- fetchItemBlockers --- + + it('fetches blockers for all items in project', async () => { + const blockerItem = { id: 10, title: 'Decide budget', completed: false, archived: false, created_at: '', updated_at: '' } + mockApi.get.mockImplementation((url: string) => { + if (url === '/project-items/11/blockers') return Promise.resolve({ data: [blockerItem] }) + if (url.includes('/blockers')) return Promise.resolve({ data: [] }) + return Promise.resolve({ data: [] }) + }) + const store = useProjectsStore() + store.items = [...testItems] + + await store.fetchItemBlockers() + + expect(store.itemBlockers[11]).toEqual([blockerItem]) + expect(store.itemBlockers[10]).toBeUndefined() + expect(store.itemBlockers[12]).toBeUndefined() + }) + + // --- fetchItemDetail --- + + it('fetches item detail with projects and dependencies', async () => { + mockApi.get.mockResolvedValue({ data: testItemDetail }) + const store = useProjectsStore() + + const result = await store.fetchItemDetail(10) + + expect(mockApi.get).toHaveBeenCalledWith('/project-items/10') + expect(result.projects).toHaveLength(1) + expect(result.dependency_ids).toEqual([11]) + }) + + // --- addDependency --- + + it('adds a dependency and returns updated detail', async () => { + const updatedDetail = { ...testItemDetail, dependency_ids: [11, 12] } + mockApi.post.mockResolvedValue({ data: updatedDetail }) + const store = useProjectsStore() + + const result = await store.addDependency(10, { depends_on_id: 12 }) + + expect(mockApi.post).toHaveBeenCalledWith('/project-items/10/dependencies', { depends_on_id: 12 }) + expect(result.dependency_ids).toEqual([11, 12]) + }) + + // --- addToProject / removeFromProject --- + + it('adds item to project and refetches projects', async () => { + mockApi.post.mockResolvedValue({ data: { id: 2, name: 'Home Renovation', position: 1, created_at: '' } }) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + + await store.addToProject(10, { project_id: 2 }) + + expect(mockApi.post).toHaveBeenCalledWith('/project-items/10/projects', { project_id: 2 }) + expect(mockApi.get).toHaveBeenCalledWith('/projects', { params: { status: 'active' } }) + }) + + it('removes item from project and refetches', async () => { + mockApi.delete.mockResolvedValue({}) + mockApi.get.mockImplementation((url: string) => { + if (url === '/projects') return Promise.resolve({ data: testProjects }) + if (url === '/projects/1/items?status=all') return Promise.resolve({ data: testItems }) + return Promise.resolve({ data: [] }) + }) + const store = useProjectsStore() + store.selectedProjectId = 1 + + await store.removeFromProject(10, 2) + + expect(mockApi.delete).toHaveBeenCalledWith('/project-items/10/projects/2') + }) + + // --- searchItems --- + + it('searches items across all projects', async () => { + const results = [testItems[0]!, testItems[1]!] + mockApi.get.mockResolvedValue({ data: results }) + const store = useProjectsStore() + + const result = await store.searchItems('budget') + + expect(mockApi.get).toHaveBeenCalledWith('/project-items/search', { params: { q: 'budget' }, holdsTypedInput: true }) + expect(result).toHaveLength(2) + }) + + // --- Project status --- + + it('sends the chosen filter and refetches when it changes', async () => { + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + + await store.setStatusFilter('all') + + expect(store.statusFilter).toBe('all') + expect(mockApi.get).toHaveBeenCalledWith('/projects', { params: { status: 'all' } }) + }) + + it('does not refetch when the filter is already what was asked for', async () => { + const store = useProjectsStore() + + await store.setStatusFilter('active') + + expect(mockApi.get).not.toHaveBeenCalled() + }) + + it('forgets a selected project the new filter no longer shows', async () => { + mockApi.get.mockResolvedValue({ data: [] }) + const store = useProjectsStore() + store.projects = [...testProjects] as never + store.selectedProjectId = 1 as never + store.selectedProjectIds = [1] as never + + await store.setStatusFilter('completed') + + // A selection pointing at an absent project renders an empty pane with no + // way back to it. + expect(store.selectedProjectId).toBeNull() + expect(store.selectedProjectIds).toEqual([]) + }) + + it('completes a project through PATCH rather than an action endpoint', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + + await store.setProjectStatus(1 as never, 'completed') + + expect(mockApi.patch).toHaveBeenCalledWith('/projects/1', { status: 'completed' }) + }) + + it('sends the reason when dropping', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + + await store.setProjectStatus(1 as never, 'dropped', 'Go covers it') + + expect(mockApi.patch).toHaveBeenCalledWith('/projects/1', { + status: 'dropped', + status_reason: 'Go covers it', + }) + }) + + it('clears the selection when the closed project drops out of the list', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + mockApi.get.mockResolvedValue({ data: testProjects.slice(1) }) + const store = useProjectsStore() + store.selectedProjectId = 1 as never + store.selectedProjectIds = [1] as never + + await store.setProjectStatus(1 as never, 'completed') + + expect(store.selectedProjectId).toBeNull() + }) + + it('keeps the selection when the filter still shows the closed project', async () => { + mockApi.patch.mockResolvedValue({ data: {} }) + mockApi.get.mockResolvedValue({ data: testProjects }) + const store = useProjectsStore() + store.statusFilter = 'all' + store.selectedProjectId = 1 as never + store.selectedProjectIds = [1] as never + + await store.setProjectStatus(1 as never, 'completed') + + expect(store.selectedProjectId).toBe(1) + }) +}) diff --git a/web/src/stores/projects.ts b/web/src/stores/projects.ts new file mode 100644 index 0000000..a27ca67 --- /dev/null +++ b/web/src/stores/projects.ts @@ -0,0 +1,666 @@ +import { ref, computed } from 'vue' +import { defineStore } from 'pinia' +import { api } from '@/api/client' +import { ApiError } from '@/api/errors' +import { createLogger } from '@/utils/logger' +import type { + ProjectWithItemCount, + ProjectCreate, + ProjectUpdate, + ProjectStatus, + ProjectStatusFilter, + ProjectItem, + ProjectItemCreate, + ProjectItemUpdate, + ProjectItemDetail, + ProjectItemInProject, + ProjectItemReorder, + ProjectItemMembershipCreate, + ProjectItemDependencyCreate, + ProjectItemTask, + ProjectItemTaskCreate, + ProjectItemTaskUpdate, +} from '@/api/client' + +const logger = createLogger('ProjectsStore') + +// The API narrows this list to open items, so the board asks for the rest back +// explicitly. It renders every state — a completed row keeps the toggle that +// reopens it, and an archived row has styling of its own — and none of that is +// reachable if the default decides for it. +function projectItemsPath(projectId: string) { + return `/projects/${projectId}/items?status=all` +} + +export const useProjectsStore = defineStore('projects', () => { + // --- Project state --- + const projects = ref([]) + const statusFilter = ref('active') + const selectedProjectId = ref(null) + const selectedProjectIds = ref([]) + const loading = ref(false) + const error = ref(null) + + // --- Item state (scoped to selected project, or all projects) --- + const items = ref([]) + const itemsLoading = ref(false) + const itemBlockers = ref>({}) + const isViewAll = ref(false) + const projectGroups = ref<{ project: ProjectWithItemCount; items: ProjectItemInProject[] }[]>([]) + + // --- Task state (per-item, loaded on demand) --- + const itemTasks = ref>({}) + + const sortedProjects = computed(() => [...projects.value].sort((a, b) => a.position - b.position)) + + const sortedItems = computed(() => [...items.value].sort((a, b) => a.position - b.position)) + + const selectedProject = computed(() => projects.value.find((p) => p.id === selectedProjectId.value) ?? null) + + function clearError() { + error.value = null + } + + // --- Project CRUD --- + + async function fetchProjects() { + loading.value = true + error.value = null + try { + // Always explicit, unlike the CLI: the filter is a control on the page, so + // leaving it to the server's default would make the select's `active` + // position mean something the store never said. + const response = await api.get('/projects', { + params: { status: statusFilter.value }, + }) + projects.value = response.data + logger.info('projects_fetched', { count: response.data.length, status: statusFilter.value }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('projects_fetch_failed', { detail: apiError.detail, status: apiError.status }) + throw apiError + } finally { + loading.value = false + } + } + + async function createProject(input: ProjectCreate) { + error.value = null + try { + await api.post('/projects', input) + await fetchProjects() + logger.info('project_created', { name: input.name }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('project_create_failed', { detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function updateProject(id: string, input: ProjectUpdate) { + error.value = null + try { + await api.patch(`/projects/${id}`, input) + await fetchProjects() + logger.info('project_updated', { id }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('project_update_failed', { id, detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function setStatusFilter(status: ProjectStatusFilter) { + if (statusFilter.value === status) return + statusFilter.value = status + await fetchProjects() + // Whatever was selected may no longer be in the list the filter now shows, + // and a selection pointing at an absent project renders an empty pane with + // no way back to it. + for (const id of [...selectedProjectIds.value]) { + if (!projects.value.some((p) => p.id === id)) dropSelection(id) + } + } + + /** Forget a project the list no longer holds, whether closed, filtered out, or deleted. */ + function dropSelection(id: string) { + projects.value = projects.value.filter((p) => p.id !== id) + selectedProjectIds.value = selectedProjectIds.value.filter((x) => x !== id) + if (selectedProjectId.value === id) { + selectedProjectId.value = selectedProjectIds.value[0] ?? null + if (selectedProjectIds.value.length === 0) items.value = [] + } + } + + /** + * Status transitions, all of them a PATCH — there are no complete/drop/reopen + * action endpoints, so the server validates the transition in one place. + * Dropping requires a reason, which is what distinguishes it from deferring. + */ + async function setProjectStatus(id: string, status: ProjectStatus, reason?: string) { + const update: ProjectUpdate = { status } + if (reason !== undefined) update.status_reason = reason + await updateProject(id, update) + if (!projects.value.some((p) => p.id === id)) dropSelection(id) + } + + async function removeProject(id: string) { + error.value = null + try { + await api.delete(`/projects/${id}`) + dropSelection(id) + logger.info('project_deleted', { id }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('project_delete_failed', { id, detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + // --- Item operations (scoped to selected project) --- + + async function fetchItems(projectId: string) { + selectedProjectId.value = projectId + selectedProjectIds.value = [projectId] + isViewAll.value = false + itemsLoading.value = true + itemBlockers.value = {} + error.value = null + try { + const response = await api.get(projectItemsPath(projectId)) + items.value = response.data + logger.info('items_fetched', { project_id: projectId, count: response.data.length }) + fetchItemBlockers() + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('items_fetch_failed', { + project_id: projectId, + detail: apiError.detail, + status: apiError.status, + }) + throw apiError + } finally { + itemsLoading.value = false + } + } + + async function fetchItemsForProjects(ids: string[]) { + selectedProjectId.value = ids[0] ?? null + selectedProjectIds.value = ids + isViewAll.value = false + itemsLoading.value = true + itemBlockers.value = {} + error.value = null + try { + if (projects.value.length === 0) await fetchProjects() + const projectsToFetch = sortedProjects.value.filter((p) => ids.includes(p.id)) + const groups = await Promise.all( + projectsToFetch.map(async (project) => { + const response = await api.get(projectItemsPath(project.id)) + return { project, items: response.data } + }), + ) + projectGroups.value = groups + const seen = new Set() + items.value = groups + .flatMap((g) => g.items) + .filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) + fetchItemBlockers() + logger.info('projects_fetched_multi', { ids: ids.length, items: items.value.length }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('projects_fetch_multi_failed', { detail: apiError.detail, status: apiError.status }) + throw apiError + } finally { + itemsLoading.value = false + } + } + + function toggleProjectSelection(id: string) { + const idx = selectedProjectIds.value.indexOf(id) + if (idx >= 0) { + selectedProjectIds.value = selectedProjectIds.value.filter((x) => x !== id) + } else { + selectedProjectIds.value = [...selectedProjectIds.value, id] + } + + const ids = selectedProjectIds.value + if (ids.length === 0) { + selectedProjectId.value = null + items.value = [] + projectGroups.value = [] + isViewAll.value = false + } else if (ids.length === 1 && ids[0] !== undefined) { + fetchItems(ids[0]) + } else { + fetchItemsForProjects(ids) + } + } + + async function fetchAllProjectsWithItems() { + selectedProjectId.value = null + selectedProjectIds.value = [] + isViewAll.value = true + itemsLoading.value = true + itemBlockers.value = {} + error.value = null + try { + if (projects.value.length === 0) await fetchProjects() + const groups = await Promise.all( + sortedProjects.value.map(async (project) => { + const response = await api.get(projectItemsPath(project.id)) + return { project, items: response.data } + }), + ) + projectGroups.value = groups + // Deduplicated flat list for blocker fetching (items can be in multiple projects) + const seen = new Set() + items.value = groups + .flatMap((g) => g.items) + .filter((item) => { + if (seen.has(item.id)) return false + seen.add(item.id) + return true + }) + fetchItemBlockers() + logger.info('all_projects_fetched', { groups: groups.length, items: items.value.length }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('all_projects_fetch_failed', { detail: apiError.detail, status: apiError.status }) + throw apiError + } finally { + itemsLoading.value = false + } + } + + async function createItem(input: ProjectItemCreate) { + error.value = null + try { + const response = await api.post('/project-items', input) + logger.info('item_created', { id: response.data.id, title: input.title }) + // Refresh both projects (item counts) and items list + await fetchProjects() + if (selectedProjectId.value !== null) { + await fetchItems(selectedProjectId.value) + } + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_create_failed', { detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function updateItem(id: string, input: ProjectItemUpdate) { + error.value = null + try { + const response = await api.patch(`/project-items/${id}`, input) + const index = items.value.findIndex((i) => i.id === id) + const existing = items.value[index] + if (index !== -1 && existing) { + items.value[index] = { ...response.data, position: existing.position } + } + logger.info('item_updated', { id }) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_update_failed', { id, detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function removeItem(id: string) { + error.value = null + try { + await api.delete(`/project-items/${id}`) + items.value = items.value.filter((i) => i.id !== id) + await fetchProjects() + logger.info('item_deleted', { id }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_delete_failed', { id, detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function reorderItem(id: string, input: ProjectItemReorder) { + error.value = null + try { + await api.patch(`/project-items/${id}/reorder`, input) + logger.info('item_reordered', { id, project_id: input.project_id, position: input.position }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_reorder_failed', { id, detail: apiError.detail, status: apiError.status }) + throw apiError + } + } + + async function reorderItems(orderedIds: string[]) { + if (selectedProjectId.value === null) return + const projectId = selectedProjectId.value + error.value = null + + try { + const updates: Promise[] = [] + + for (const [index, id] of orderedIds.entries()) { + const item = items.value.find((i) => i.id === id) + if (item && item.position !== index) { + updates.push(api.patch(`/project-items/${id}/reorder`, { project_id: projectId, position: index })) + } + } + + if (updates.length > 0) { + await Promise.all(updates) + for (const [index, id] of orderedIds.entries()) { + const item = items.value.find((i) => i.id === id) + if (item) item.position = index + } + logger.info('items_reordered', { project_id: projectId, count: updates.length }) + } + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('items_reorder_failed', { detail: apiError.detail }) + await fetchItems(projectId) + throw apiError + } + } + + async function reorderProjects(orderedIds: string[]) { + error.value = null + + try { + const updates: Promise[] = [] + + for (const [index, id] of orderedIds.entries()) { + const project = projects.value.find((p) => p.id === id) + if (project && project.position !== index) { + updates.push(api.patch(`/projects/${id}`, { position: index })) + } + } + + if (updates.length > 0) { + await Promise.all(updates) + for (const [index, id] of orderedIds.entries()) { + const project = projects.value.find((p) => p.id === id) + if (project) project.position = index + } + logger.info('projects_reordered', { count: updates.length }) + } + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('projects_reorder_failed', { detail: apiError.detail }) + await fetchProjects() + throw apiError + } + } + + async function completeItem(id: string) { + return updateItem(id, { completed: true }) + } + + async function archiveItem(id: string) { + return updateItem(id, { archived: true }) + } + + // --- Blocker tracking --- + + async function fetchItemBlockers() { + const blockerMap: Record = {} + + await Promise.all( + items.value.map(async (item) => { + try { + const response = await api.get(`/project-items/${item.id}/blockers`) + if (response.data.length > 0) { + blockerMap[item.id] = response.data + } + } catch { + // Non-critical — skip blocker info for this item + } + }), + ) + + itemBlockers.value = blockerMap + logger.debug('item_blockers_fetched', { blocked_count: Object.keys(blockerMap).length }) + } + + async function fetchItemDetail(id: string) { + const response = await api.get(`/project-items/${id}`) + return response.data + } + + // --- Dependencies --- + + async function addDependency(itemId: string, input: ProjectItemDependencyCreate) { + error.value = null + try { + const response = await api.post(`/project-items/${itemId}/dependencies`, input) + logger.info('dependency_added', { item_id: itemId, depends_on: input.depends_on_id }) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('dependency_add_failed', { + item_id: itemId, + detail: apiError.detail, + status: apiError.status, + }) + throw apiError + } + } + + async function removeDependency(itemId: string, depId: string) { + error.value = null + try { + await api.delete(`/project-items/${itemId}/dependencies/${depId}`) + logger.info('dependency_removed', { item_id: itemId, dep_id: depId }) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('dependency_remove_failed', { + item_id: itemId, + detail: apiError.detail, + status: apiError.status, + }) + throw apiError + } + } + + async function getBlockers(itemId: string) { + try { + const response = await api.get(`/project-items/${itemId}/blockers`) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + logger.error('blockers_fetch_failed', { + item_id: itemId, + detail: apiError.detail, + status: apiError.status, + }) + throw apiError + } + } + + // --- Membership --- + + async function addToProject(itemId: string, input: ProjectItemMembershipCreate) { + error.value = null + try { + await api.post(`/project-items/${itemId}/projects`, input) + logger.info('item_added_to_project', { item_id: itemId, project_id: input.project_id }) + await fetchProjects() + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + throw apiError + } + } + + async function removeFromProject(itemId: string, projectId: string) { + error.value = null + try { + await api.delete(`/project-items/${itemId}/projects/${projectId}`) + logger.info('item_removed_from_project', { item_id: itemId, project_id: projectId }) + await fetchProjects() + if (selectedProjectId.value !== null) { + await fetchItems(selectedProjectId.value) + } + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + throw apiError + } + } + + // --- Search --- + + async function searchItems(query: string) { + try { + const response = await api.get('/project-items/search', { + params: { q: query }, + holdsTypedInput: true, + }) + logger.info('items_searched', { query, count: response.data.length }) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + logger.error('items_search_failed', { query, detail: apiError.detail }) + throw apiError + } + } + + // --- Item Tasks --- + + async function fetchItemTasks(itemId: string) { + try { + const response = await api.get(`/project-items/${itemId}/tasks`) + itemTasks.value[itemId] = response.data + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + logger.error('item_tasks_fetch_failed', { item_id: itemId, detail: apiError.detail }) + throw apiError + } + } + + async function createItemTask(itemId: string, input: ProjectItemTaskCreate) { + error.value = null + try { + const response = await api.post(`/project-items/${itemId}/tasks`, input) + logger.info('item_task_created', { item_id: itemId, title: input.title }) + await fetchItemTasks(itemId) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_task_create_failed', { item_id: itemId, detail: apiError.detail }) + throw apiError + } + } + + async function updateItemTask(itemId: string, taskId: string, input: ProjectItemTaskUpdate) { + error.value = null + try { + const response = await api.patch(`/project-items/${itemId}/tasks/${taskId}`, input) + logger.info('item_task_updated', { item_id: itemId, task_id: taskId }) + await fetchItemTasks(itemId) + return response.data + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_task_update_failed', { item_id: itemId, task_id: taskId, detail: apiError.detail }) + throw apiError + } + } + + async function removeItemTask(itemId: string, taskId: string) { + error.value = null + try { + await api.delete(`/project-items/${itemId}/tasks/${taskId}`) + logger.info('item_task_deleted', { item_id: itemId, task_id: taskId }) + await fetchItemTasks(itemId) + } catch (e) { + const apiError = e instanceof ApiError ? e : new ApiError({ message: String(e), detail: String(e) }) + error.value = apiError + logger.error('item_task_delete_failed', { item_id: itemId, task_id: taskId, detail: apiError.detail }) + throw apiError + } + } + + async function completeItemTask(itemId: string, taskId: string) { + return updateItemTask(itemId, taskId, { completed: true }) + } + + return { + // Project state + projects, + statusFilter, + selectedProjectId, + selectedProjectIds, + loading, + error, + sortedProjects, + selectedProject, + // Item state + items, + itemsLoading, + itemBlockers, + isViewAll, + projectGroups, + sortedItems, + // Task state + itemTasks, + // Actions + clearError, + fetchProjects, + createProject, + updateProject, + setStatusFilter, + setProjectStatus, + removeProject, + fetchItems, + fetchItemsForProjects, + toggleProjectSelection, + fetchAllProjectsWithItems, + createItem, + updateItem, + removeItem, + reorderItem, + reorderItems, + reorderProjects, + completeItem, + archiveItem, + fetchItemBlockers, + fetchItemDetail, + addDependency, + removeDependency, + getBlockers, + addToProject, + removeFromProject, + searchItems, + // Task actions + fetchItemTasks, + createItemTask, + updateItemTask, + removeItemTask, + completeItemTask, + } +}) diff --git a/web/src/utils/logger.ts b/web/src/utils/logger.ts new file mode 100644 index 0000000..5124917 --- /dev/null +++ b/web/src/utils/logger.ts @@ -0,0 +1,181 @@ +/** + * Structured logger for the Vue frontend, powered by consola: an event name + * with structured context, scoped to a module by createLogger('ModuleName'). + * + * Dev: human-readable key=value lines in the browser console. + * Production: one JSON object a line in the browser's DevTools. The SPA is + * static files, so nothing ships these logs anywhere. + * + * Usage: + * const logger = createLogger('ProjectsStore') + * logger.info('projects_fetched', { count: 5 }) + */ + +import { createConsola, LogLevels } from 'consola' +import type { ConsolaReporter, LogObject } from 'consola' + +const isDev = import.meta.env.DEV + +// --- Formatting utilities (exported for testing) --- + +export function formatValue(value: unknown): string { + if (typeof value === 'string') return value + return JSON.stringify(value) +} + +export function levelName(level: number): string { + // consola levels: error=0, warn=1, info=3, debug=4 (lower = more severe) + if (level <= LogLevels.error) return 'error' + if (level <= LogLevels.warn) return 'warning' + if (level <= LogLevels.info) return 'info' + return 'debug' +} + +const CONSOLE_METHOD: Record = { + error: 'error', + warning: 'warn', + info: 'info', + debug: 'debug', +} + +/** + * Build a structured log line from a LogObject. + * Exported for testing — this is the core formatting logic. + */ +export function formatLogLine(logObj: LogObject): { line: string; level: string } { + const level = levelName(logObj.level) + const timestamp = new Date().toISOString() + const tag = logObj.tag || 'app' + + // First arg is the event name, rest are context objects + const args = logObj.args || [] + const event = typeof args[0] === 'string' ? args[0] : logObj.message || 'log' + const contextParts: string[] = [`module=${tag}`] + + // Merge all object arguments as structured context + for (let i = typeof args[0] === 'string' ? 1 : 0; i < args.length; i++) { + const arg = args[i] + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + for (const [key, value] of Object.entries(arg as Record)) { + if (value !== undefined && value !== null) { + contextParts.push(`${key}=${formatValue(value)}`) + } + } + } + } + + const line = `${timestamp} [${level.padEnd(7)}] ${(event as string).padEnd(30)} ${contextParts.join(' ')}` + return { line, level } +} + +/** + * Build a JSON log entry from a LogObject. + * Exported for testing. + */ +export function formatLogJson(logObj: LogObject): Record { + const level = levelName(logObj.level) + const args = logObj.args || [] + const event = typeof args[0] === 'string' ? args[0] : logObj.message || 'log' + + const entry: Record = { + timestamp: new Date().toISOString(), + level, + event, + module: logObj.tag || 'app', + } + + for (let i = typeof args[0] === 'string' ? 1 : 0; i < args.length; i++) { + const arg = args[i] + if (arg && typeof arg === 'object' && !Array.isArray(arg)) { + Object.assign(entry, arg) + } + } + + return entry +} + +/** + * Structured key=value reporter for dev console output. + * One event a line, its context as key=value pairs: + * 2026-03-13T03:10:00.000Z [info ] fetch_completed module=Store count=5 + */ +const structuredReporter: ConsolaReporter = { + log(logObj: LogObject) { + const { line, level } = formatLogLine(logObj) + + console[CONSOLE_METHOD[level] || 'info'](line) + }, +} + +/** + * JSON reporter for production. Emits one JSON object per console.info call, + * with the event, level, module and context as fields. Lives only in + * the user's browser DevTools — there is no shipping pipeline. + */ +const jsonReporter: ConsolaReporter = { + log(logObj: LogObject) { + const entry = formatLogJson(logObj) + + console.info(JSON.stringify(entry)) + }, +} + +// --- Root consola instance --- + +const rootConsola = createConsola({ + level: isDev ? LogLevels.debug : LogLevels.info, + reporters: [isDev ? structuredReporter : jsonReporter], +}) + +// --- Public API --- + +export interface Logger { + debug: (event: string, context?: Record) => void + info: (event: string, context?: Record) => void + warning: (event: string, context?: Record) => void + error: (event: string, context?: Record) => void +} + +/** + * Create a module-scoped logger. + * + * Usage: + * const logger = createLogger('CountdownsStore') + * logger.info('fetch_completed', { count: 5, duration_ms: 123 }) + * + * Dev output: + * 2026-03-13T03:10:00.000Z [info ] fetch_completed module=CountdownsStore count=5 duration_ms=123 + * + * Production output: + * {"timestamp":"2026-03-13T03:10:00.000Z","level":"info","event":"fetch_completed","module":"CountdownsStore","count":5,"duration_ms":123} + */ +export function createLogger(module: string): Logger { + const scoped = rootConsola.withTag(module) + + return { + debug: (event: string, context?: Record) => (context ? scoped.debug(event, context) : scoped.debug(event)), + info: (event: string, context?: Record) => (context ? scoped.info(event, context) : scoped.info(event)), + warning: (event: string, context?: Record) => (context ? scoped.warn(event, context) : scoped.warn(event)), + error: (event: string, context?: Record) => (context ? scoped.error(event, context) : scoped.error(event)), + } +} + +/** + * Set the minimum log level at runtime. + * Useful for enabling debug logging via browser console: + * window.__setLogLevel && window.__setLogLevel('debug') + */ +export function setLogLevel(level: 'debug' | 'info' | 'warning' | 'error'): void { + const mapping: Record = { + debug: LogLevels.debug, + info: LogLevels.info, + warning: LogLevels.warn, + error: LogLevels.error, + } + rootConsola.level = mapping[level] ?? LogLevels.info +} + +// Expose setLogLevel globally in dev for debugging +if (isDev && typeof window !== 'undefined') { + ;(window as unknown as Record).__setLogLevel = setLogLevel +} diff --git a/web/src/views/ProjectsView.vue b/web/src/views/ProjectsView.vue new file mode 100644 index 0000000..49e7dab --- /dev/null +++ b/web/src/views/ProjectsView.vue @@ -0,0 +1,1328 @@ + + + + + diff --git a/web/src/views/__tests__/ProjectsView.test.ts b/web/src/views/__tests__/ProjectsView.test.ts new file mode 100644 index 0000000..ea45c3a --- /dev/null +++ b/web/src/views/__tests__/ProjectsView.test.ts @@ -0,0 +1,552 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { mount } from '@vue/test-utils' +import { createTestingPinia } from '@pinia/testing' +import ProjectsView from '../ProjectsView.vue' +import { useProjectsStore } from '@/stores/projects' +import type { ProjectWithItemCount, ProjectItemInProject } from '@/api/client' + +vi.mock('@/composables/useNotifications', () => ({ + useNotifications: () => ({ + show: vi.fn(), + close: vi.fn(), + closeAll: vi.fn(), + notifications: { value: [] }, + }), +})) + +const DraggableStub = { + template: '
', + props: ['list', 'itemKey', 'handle', 'ghostClass', 'animation'], + emits: ['end'], +} + +const PROJ_1_ID = 'aaaaaaaa-0000-0000-0000-000000000001' +const PROJ_2_ID = 'aaaaaaaa-0000-0000-0000-000000000002' +const ITEM_1_ID = 'bbbbbbbb-0000-0000-0000-000000000001' +const ITEM_2_ID = 'bbbbbbbb-0000-0000-0000-000000000002' +const ITEM_3_ID = 'bbbbbbbb-0000-0000-0000-000000000003' + +const testProjects: ProjectWithItemCount[] = [ + { + id: PROJ_1_ID, + name: 'Music Setup', + description: 'Setting up the studio', + kind: 'build', + status: 'active', + position: 0, + created_at: '2026-01-01T00:00:00Z', + // Matches testItems below: one open, one completed, one archived. + item_count: 3, + open_count: 1, + completed_count: 1, + }, + { + id: PROJ_2_ID, + name: 'Home Renovation', + description: undefined, + kind: 'chore', + status: 'active', + position: 1, + created_at: '2026-02-01T00:00:00Z', + item_count: 2, + open_count: 2, + completed_count: 0, + }, +] + +const testItems: ProjectItemInProject[] = [ + { + id: ITEM_1_ID, + title: 'Decide budget', + notes: 'Check forums', + completed: false, + archived: false, + created_at: '2026-01-01T00:00:00Z', + updated_at: '2026-01-01T00:00:00Z', + position: 0, + }, + { + id: ITEM_2_ID, + title: 'Research interfaces', + notes: null, + completed: true, + archived: false, + created_at: '2026-01-02T00:00:00Z', + updated_at: '2026-01-02T00:00:00Z', + position: 1, + }, + { + id: ITEM_3_ID, + title: 'Archived item', + notes: null, + completed: false, + archived: true, + created_at: '2026-01-03T00:00:00Z', + updated_at: '2026-01-03T00:00:00Z', + position: 2, + }, +] + +function createWrapper(storeState: Record = {}) { + return mount(ProjectsView, { + global: { + plugins: [ + createTestingPinia({ + initialState: { + projects: { + projects: [], + statusFilter: 'active', + items: [], + selectedProjectId: null, + // selectedProjectIds drives all template conditions — always provide it + selectedProjectIds: [], + isViewAll: false, + loading: false, + itemsLoading: false, + error: null, + itemBlockers: {}, + projectGroups: [], + itemTasks: {}, + ...storeState, + }, + }, + stubActions: true, + createSpy: vi.fn, + }), + ], + stubs: { + draggable: DraggableStub, + Teleport: true, + AddEditProjectModal: true, + DropProjectModal: true, + AddEditProjectItemModal: true, + ManageDependenciesModal: true, + ManageProjectsModal: true, + // ProjectItemTasks renders inline — stub it to avoid fetchItemTasks spy noise + ProjectItemTasks: true, + }, + }, + }) +} + +/** Convenience: state for a single selected project with items visible. */ +function selectedState(projectIndex = 0, extraItems: ProjectItemInProject[] = testItems) { + const project = testProjects[projectIndex]! + return { + projects: testProjects, + selectedProjectId: project.id, + selectedProjectIds: [project.id], + items: extraItems, + } +} + +describe('ProjectsView', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // --- Rendering states --- + + it('shows loading state', () => { + const wrapper = createWrapper({ loading: true }) + expect(wrapper.text()).toContain('Loading...') + }) + + it('shows empty state when no projects', () => { + const wrapper = createWrapper({ statusFilter: 'all' }) + expect(wrapper.find('[data-testid="project-list-empty"]').text()).toBe('No projects yet') + }) + + // An empty `someday` list says nothing about the active projects, so + // `No projects yet` there would be false. + it('names the status filter in an empty list', () => { + const wrapper = createWrapper({ statusFilter: 'someday' }) + expect(wrapper.find('[data-testid="project-list-empty"]').text()).toBe('No someday projects') + }) + + it('renders project list from store state', () => { + const wrapper = createWrapper({ projects: testProjects }) + const items = wrapper.findAll('[data-testid="project-item"]') + expect(items).toHaveLength(2) + expect(items[0]!.text()).toContain('Music Setup') + expect(items[0]!.text()).toContain('3') + expect(items[1]!.text()).toContain('Home Renovation') + }) + + it('labels each project row with its kind', () => { + const wrapper = createWrapper({ projects: testProjects }) + const kinds = wrapper.findAll('[data-testid="project-kind"]') + expect(kinds.map((k) => k.text())).toEqual(['build', 'chore']) + }) + + it('shows "Select a project" when none selected', () => { + const wrapper = createWrapper({ projects: testProjects }) + expect(wrapper.text()).toContain('Select a project to view items') + }) + + it('shows items pane when project is selected', () => { + const wrapper = createWrapper(selectedState()) + expect(wrapper.text()).toContain('Music Setup') + expect(wrapper.find('[data-testid="project-item-add-button"]').exists()).toBe(true) + const rows = wrapper.findAll('[data-testid="project-item-row"]') + expect(rows).toHaveLength(3) + expect(rows[0]!.text()).toContain('Decide budget') + expect(rows[0]!.text()).toContain('Check forums') + }) + + it('shows empty items message when project has no items', () => { + const wrapper = createWrapper({ ...selectedState(0, []) }) + expect(wrapper.text()).toContain('No items in this project') + }) + + it('shows items loading state', () => { + const wrapper = createWrapper({ ...selectedState(), itemsLoading: true }) + expect(wrapper.text()).toContain('Loading...') + }) + + // --- CSS classes for item states --- + + it('applies completed class to completed items', () => { + const wrapper = createWrapper(selectedState()) + const rows = wrapper.findAll('[data-testid="project-item-row"]') + expect(rows[1]!.classes()).toContain('projects-page__item--completed') + }) + + it('applies archived class to archived items', () => { + const wrapper = createWrapper(selectedState()) + const rows = wrapper.findAll('[data-testid="project-item-row"]') + expect(rows[2]!.classes()).toContain('projects-page__item--archived') + }) + + it('applies blocked class and shows blocker text', () => { + const wrapper = createWrapper({ + ...selectedState(), + itemBlockers: { + [ITEM_1_ID]: [{ id: ITEM_2_ID, title: 'Research interfaces', completed: false, archived: false, created_at: '', updated_at: '' }], + }, + }) + const rows = wrapper.findAll('[data-testid="project-item-row"]') + expect(rows[0]!.classes()).toContain('projects-page__item--blocked') + expect(rows[0]!.text()).toContain('Research interfaces') + }) + + it('applies selected class to the active project in sidebar', () => { + const wrapper = createWrapper({ + projects: testProjects, + selectedProjectId: PROJ_1_ID, + selectedProjectIds: [PROJ_1_ID], + }) + const items = wrapper.findAll('[data-testid="project-item"]') + expect(items[0]!.classes()).toContain('projects-page__project--selected') + expect(items[1]!.classes()).not.toContain('projects-page__project--selected') + }) + + // --- Store action wiring --- + + it('calls fetchItems when clicking a project row', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + + await wrapper.findAll('[data-testid="project-item"]')[0]!.trigger('click') + + expect(store.fetchItems).toHaveBeenCalledWith(PROJ_1_ID) + }) + + it('calls toggleProjectSelection when clicking the multi-select checkbox', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + + // The multi-select button is the second button inside the project row + const projectRow = wrapper.findAll('[data-testid="project-item"]')[0]! + const multiBtn = projectRow.find('.projects-page__multi-select-btn') + await multiBtn.trigger('click') + + expect(store.toggleProjectSelection).toHaveBeenCalledWith(PROJ_1_ID) + }) + + it('calls removeProject when clicking project delete button', async () => { + // Delete button is in the items header, only visible when a project is selected + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.find('[data-testid="project-delete-button"]').trigger('click') + + expect(store.removeProject).toHaveBeenCalledWith(PROJ_1_ID) + }) + + it('calls updateItem with completed toggle when clicking complete', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.findAll('[data-testid="project-item-complete-button"]')[0]!.trigger('click') + + expect(store.updateItem).toHaveBeenCalledWith(ITEM_1_ID, { completed: true }) + }) + + it('calls archiveItem when clicking archive button', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.findAll('[data-testid="project-item-archive-button"]')[0]!.trigger('click') + + expect(store.archiveItem).toHaveBeenCalledWith(ITEM_1_ID) + }) + + it('calls removeItem when clicking delete item button', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.findAll('[data-testid="project-item-delete-button"]')[0]!.trigger('click') + + expect(store.removeItem).toHaveBeenCalledWith(ITEM_1_ID) + }) + + // --- Modal wiring --- + + it('opens project add modal on add button click', async () => { + const wrapper = createWrapper() + + await wrapper.find('[data-testid="project-add-button"]').trigger('click') + + const modal = wrapper.findComponent({ name: 'AddEditProjectModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('editData')).toBeNull() + }) + + it('opens project edit modal with project data from items header', async () => { + // Edit button is in the items header, only visible when a project is selected + const wrapper = createWrapper(selectedState()) + + await wrapper.find('[data-testid="project-edit-button"]').trigger('click') + + const modal = wrapper.findComponent({ name: 'AddEditProjectModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('editData')).toMatchObject({ id: PROJ_1_ID, name: 'Music Setup' }) + }) + + it('hands the current kind to the edit modal so it is not reset to the default', async () => { + const wrapper = createWrapper(selectedState()) + + await wrapper.find('[data-testid="project-edit-button"]').trigger('click') + + const modal = wrapper.findComponent({ name: 'AddEditProjectModal' }) + expect(modal.props('editData')).toMatchObject({ kind: 'build' }) + }) + + it('opens item add modal on add item button click', async () => { + const wrapper = createWrapper(selectedState()) + + await wrapper.find('[data-testid="project-item-add-button"]').trigger('click') + + const modal = wrapper.findComponent({ name: 'AddEditProjectItemModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('editData')).toBeNull() + expect(modal.props('projectId')).toBe(PROJ_1_ID) + }) + + it('opens item edit modal with item data', async () => { + const wrapper = createWrapper(selectedState()) + + await wrapper.findAll('[data-testid="project-item-edit-button"]')[0]!.trigger('click') + + const modal = wrapper.findComponent({ name: 'AddEditProjectItemModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('editData')).toEqual({ id: ITEM_1_ID, title: 'Decide budget', notes: 'Check forums' }) + }) + + it('opens dependencies modal with item context', async () => { + const wrapper = createWrapper(selectedState()) + + await wrapper.findAll('[data-testid="project-item-deps-button"]')[0]!.trigger('click') + + const modal = wrapper.findComponent({ name: 'ManageDependenciesModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('itemId')).toBe(ITEM_1_ID) + expect(modal.props('itemTitle')).toBe('Decide budget') + }) + + it('opens projects modal with item context', async () => { + const wrapper = createWrapper(selectedState()) + + await wrapper.findAll('[data-testid="project-item-projects-button"]')[0]!.trigger('click') + + const modal = wrapper.findComponent({ name: 'ManageProjectsModal' }) + expect(modal.props('visible')).toBe(true) + expect(modal.props('itemId')).toBe(ITEM_1_ID) + expect(modal.props('itemTitle')).toBe('Decide budget') + }) + + // --- Search --- + + it('calls searchItems on search submit', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + vi.mocked(store.searchItems).mockResolvedValue([ + { id: ITEM_1_ID, title: 'Decide budget', notes: null, completed: false, archived: false, created_at: '', updated_at: '' }, + ]) + + await wrapper.find('[data-testid="project-search-input"]').setValue('budget') + await wrapper.find('form').trigger('submit') + await vi.dynamicImportSettled() + + expect(store.searchItems).toHaveBeenCalledWith('budget') + }) + + it('renders search results and hides normal items pane', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + vi.mocked(store.searchItems).mockResolvedValue([ + { id: ITEM_1_ID, title: 'Decide budget', notes: null, completed: false, archived: false, created_at: '', updated_at: '' }, + ]) + + await wrapper.find('[data-testid="project-search-input"]').setValue('budget') + await wrapper.find('form').trigger('submit') + await vi.dynamicImportSettled() + + expect(wrapper.findAll('[data-testid="project-search-result"]')).toHaveLength(1) + expect(wrapper.text()).toContain('1 result for "budget"') + expect(wrapper.find('[data-testid="project-item-row"]').exists()).toBe(false) + }) + + it('shows clear button during search and clears on click', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + vi.mocked(store.searchItems).mockResolvedValue([]) + + expect(wrapper.find('[data-testid="project-search-clear"]').exists()).toBe(false) + + await wrapper.find('[data-testid="project-search-input"]').setValue('test') + await wrapper.find('form').trigger('submit') + await vi.dynamicImportSettled() + + expect(wrapper.find('[data-testid="project-search-clear"]').exists()).toBe(true) + + await wrapper.find('[data-testid="project-search-clear"]').trigger('click') + + expect(wrapper.find('[data-testid="project-search-clear"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="project-search-result"]').exists()).toBe(false) + }) + + it('shows no results message for empty search', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + vi.mocked(store.searchItems).mockResolvedValue([]) + + await wrapper.find('[data-testid="project-search-input"]').setValue('nonexistent') + await wrapper.find('form').trigger('submit') + await vi.dynamicImportSettled() + + expect(wrapper.text()).toContain('No results found') + }) + + it('does not search when query is empty', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + + await wrapper.find('form').trigger('submit') + + expect(store.searchItems).not.toHaveBeenCalled() + }) + + // --- Project status --- + + const closedProject: ProjectWithItemCount = { + ...testProjects[0]!, + status: 'dropped', + status_reason: 'Superseded by the other one', + closed_at: '2026-08-01T12:00:00Z', + } + + /** State for a single selected project that has already been closed. */ + function closedState() { + return { + projects: [closedProject], + selectedProjectId: closedProject.id, + selectedProjectIds: [closedProject.id], + items: testItems, + statusFilter: 'all', + } + } + + it('does not label an active project row with a status', () => { + // Every row in the default list is active, so a badge on each says nothing. + const wrapper = createWrapper({ projects: testProjects }) + expect(wrapper.findAll('[data-testid="project-status"]')).toHaveLength(0) + }) + + it('labels a closed project row with its status', () => { + const wrapper = createWrapper({ projects: [closedProject], statusFilter: 'all' }) + expect(wrapper.find('[data-testid="project-status"]').text()).toBe('dropped') + }) + + it('changes the status filter through the store', async () => { + const wrapper = createWrapper({ projects: testProjects }) + const store = useProjectsStore() + + wrapper.findComponent({ name: 'NeuSelect' }).vm.$emit('update:modelValue', 'all') + + expect(store.setStatusFilter).toHaveBeenCalledWith('all') + }) + + it('completes the selected project', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.find('[data-testid="project-complete-button"]').trigger('click') + + expect(store.setProjectStatus).toHaveBeenCalledWith(PROJ_1_ID, 'completed', undefined) + }) + + it('shelves the selected project for someday', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.find('[data-testid="project-shelve-button"]').trigger('click') + + expect(store.setProjectStatus).toHaveBeenCalledWith(PROJ_1_ID, 'someday', undefined) + }) + + it('offers reopen instead of complete once a project is closed', () => { + const wrapper = createWrapper(closedState()) + + expect(wrapper.find('[data-testid="project-complete-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="project-shelve-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="project-drop-button"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="project-reopen-button"]').exists()).toBe(true) + }) + + it('reopens a closed project', async () => { + const wrapper = createWrapper(closedState()) + const store = useProjectsStore() + + await wrapper.find('[data-testid="project-reopen-button"]').trigger('click') + + expect(store.setProjectStatus).toHaveBeenCalledWith(closedProject.id, 'active', undefined) + }) + + it('drops the project with the reason the modal collected', async () => { + const wrapper = createWrapper(selectedState()) + const store = useProjectsStore() + + await wrapper.find('[data-testid="project-drop-button"]').trigger('click') + const modal = wrapper.findComponent({ name: 'DropProjectModal' }) + expect(modal.props('visible')).toBe(true) + modal.vm.$emit('drop', 'Go covers it') + await wrapper.vm.$nextTick() + + expect(store.setProjectStatus).toHaveBeenCalledWith(PROJ_1_ID, 'dropped', 'Go covers it') + }) + + it('names the closure and its reason in the project header', () => { + const wrapper = createWrapper(closedState()) + + const closure = wrapper.find('[data-testid="project-closure"]').text() + expect(closure).toContain('dropped') + expect(closure).toContain('Superseded by the other one') + }) + + it('shows no closure line while the project is active', () => { + const wrapper = createWrapper(selectedState()) + expect(wrapper.find('[data-testid="project-closure"]').exists()).toBe(false) + }) +}) diff --git a/web/tsconfig.app.json b/web/tsconfig.app.json new file mode 100644 index 0000000..380d247 --- /dev/null +++ b/web/tsconfig.app.json @@ -0,0 +1,19 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"], + "exclude": ["src/**/__tests__/**"] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..d32ff68 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,4 @@ +{ + "files": [], + "references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }] +} diff --git a/web/tsconfig.node.json b/web/tsconfig.node.json new file mode 100644 index 0000000..b3d71b7 --- /dev/null +++ b/web/tsconfig.node.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts", "vitest.config.ts", "playwright.config.ts"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..1b372dd --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,30 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' +import process from 'node:process' + +// The dev server sends /api to the dev stack's Caddy, which sends it on to the +// API, so the SPA calls it the same relative way it does in production. +const stack = process.env.PROJECTS_WEB_STACK_URL || 'http://localhost:8096' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + css: { + preprocessorOptions: { + scss: { + loadPaths: [fileURLToPath(new URL('./src/assets/sass', import.meta.url))], + }, + }, + }, + server: { + port: 3002, + proxy: { + '/api': { target: stack, changeOrigin: true }, + }, + }, +}) diff --git a/web/vitest.config.ts b/web/vitest.config.ts new file mode 100644 index 0000000..d79b31f --- /dev/null +++ b/web/vitest.config.ts @@ -0,0 +1,17 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [vue()], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)), + }, + }, + test: { + globals: true, + environment: 'jsdom', + include: ['src/**/*.{test,spec}.{js,ts,vue}'], + }, +})