diff --git a/.github/workflows/plugins-ci.yml b/.github/workflows/plugins-ci.yml index f4a6da27..a15e50f9 100644 --- a/.github/workflows/plugins-ci.yml +++ b/.github/workflows/plugins-ci.yml @@ -5,8 +5,12 @@ name: Plugins CI # • each plugin's tests, found through bin/plugins.php (plugin.json "tests" / "build", # or the conventions tests/run.php, **/*.test.mjs, e2e/*.spec.ts); # • front-end plugins ("build" in plugin.json): install, lint, unit tests, build; -# • Playwright (tests/e2e + plugins/*/e2e) against the full stack (docker-compose.stack.yml). +# • Playwright (tests/e2e + plugins/*/e2e) against the full stack (docker-compose.stack.yml); +# • a CLS over budget in the loading specs does not fail: the job "CLS warnings" posts it, with a screenshot, as a +# pull request comment. # Nothing here is plugin-specific: a new plugin is picked up from its manifest. +# A pull request that only changes files under plugins// checks only those plugins (their PHP, Node, front-end and +# Playwright tests); a change anywhere else (core, design system, tests/e2e, Docker, this workflow…) checks them all. # Make the "Plugins CI" check required in the branch protection of preprod and main. on: pull_request: @@ -25,26 +29,71 @@ jobs: name: Discover plugins runs-on: ubuntu-latest outputs: + ids: ${{ steps.plan.outputs.ids }} frontends: ${{ steps.plan.outputs.frontends }} + all_frontends: ${{ steps.plan.outputs.all_frontends }} + e2e: ${{ steps.plan.outputs.e2e }} + e2e_specs: ${{ steps.plan.outputs.e2e_specs }} steps: - uses: actions/checkout@v4 + with: + fetch-depth: 2 # the pull request's merge commit and its first parent, the base branch - uses: shivammathur/setup-php@v2 with: php-version: '8.4' coverage: none - id: plan + env: + EVENT: ${{ github.event_name }} run: | php bin/plugins.php plan | tee plan.json - echo "frontends=$(jq -c '[.[] | select(.build != null) | {id, dir, workdir: .build.workdir, output: .build.output, nvmrc: (.build.nvmrc // "")}]' plan.json)" >> "$GITHUB_OUTPUT" + # Files changed by the pull request; null (check every plugin) on a manual run. + if [ "$EVENT" = pull_request ]; then + git diff --name-only HEAD^1 HEAD | tee changed.txt + changed="$(jq -R . changed.txt | jq -sc .)" + else + changed=null + fi + # A file under plugins// belongs to that plugin; any other file is shared and affects every plugin. + jq --argjson changed "$changed" ' + [.[].id] as $ids + | def owner: (capture("^plugins/(?[^/]+)/").id // null) | select(. != null and IN($ids[])); + ($changed == null or any($changed[]; [owner] == [])) as $shared + | (if $shared then $ids else [$changed[] | owner] | unique end) as $affected + | {shared: $shared, plan: [.[] | .checked = (.id | IN($affected[]))]} + ' plan.json > affected.json + # Ids and e2e folders come from the manifests and end up as command arguments: allow only safe characters. + jq -e '[.plan[] | .id, (.e2e // "e2e")] | all(test("^[A-Za-z0-9_./-]+$"))' affected.json > /dev/null \ + || { echo "::error::A plugin id or e2e folder contains characters other than A-Z a-z 0-9 _ . / -"; exit 1; } + shared="$(jq '.shared' affected.json)" + frontend='{id, dir, workdir: .build.workdir, output: .build.output, nvmrc: (.build.nvmrc // "")}' + { + echo "ids=$(jq -r '[.plan[] | select(.checked) | .id] | join(" ")' affected.json)" + echo "frontends=$(jq -c "[.plan[] | select(.checked and .build != null) | $frontend]" affected.json)" + echo "all_frontends=$(jq -c "[.plan[] | select(.build != null) | $frontend]" affected.json)" + if [ "$shared" = true ]; then + # Shell specs (tests/e2e) and every plugin's specs: no filter. + echo "e2e=true" + echo "e2e_specs=" + else + specs="$(jq -r '[.plan[] | select(.checked and .e2e != null) | "\(.dir)/\(.e2e)/"] | join(" ")' affected.json)" + echo "e2e=$([ -n "$specs" ] && echo true || echo false)" + echo "e2e_specs=$specs" + fi + } | tee -a "$GITHUB_OUTPUT" { echo '### Plugins' - echo '| Plugin | PHP tests | Node tests | Front-end build | E2E |' - echo '|---|---|---|---|---|' - jq -r '.[] | "| \(.id) | \(.php_tests | length) | \(.node_tests | length) | \(if .build then "yes" else "—" end) | \(.e2e // "—") |"' plan.json + if [ "$shared" = true ]; then echo 'Shared files changed (or manual run): every plugin is checked.' + else echo 'Only plugin files changed: only those plugins are checked.'; fi + echo + echo '| Plugin | Checked | PHP tests | Node tests | Front-end build | E2E |' + echo '|---|---|---|---|---|---|' + jq -r '.plan[] | "| \(.id) | \(if .checked then "yes" else "—" end) | \(.php_tests | length) | \(.node_tests | length) | \(if .build then "yes" else "—" end) | \(.e2e // "—") |"' affected.json } >> "$GITHUB_STEP_SUMMARY" php: name: PHP lint and plugin PHP tests + needs: plan runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -60,10 +109,17 @@ jobs: - name: Core PHP tests (helpers, design system) run: php tests/run.php - name: Plugin PHP tests - run: php bin/plugins.php run php-tests + if: needs.plan.outputs.ids != '' + env: + IDS: ${{ needs.plan.outputs.ids }} + run: | + # shellcheck disable=SC2086 # one argument per id + php bin/plugins.php run php-tests $IDS node: name: Plugin Node tests + needs: plan + if: needs.plan.outputs.ids != '' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -74,7 +130,11 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - run: php bin/plugins.php run node-tests + - env: + IDS: ${{ needs.plan.outputs.ids }} + run: | + # shellcheck disable=SC2086 # one argument per id + php bin/plugins.php run node-tests $IDS frontend: name: Front-end ${{ matrix.plugin.id }} @@ -110,11 +170,13 @@ jobs: name: Playwright (shell and plugins) # Does not wait for the front-end jobs: it builds the bundles itself, while the stack starts. needs: [plan, php] + if: needs.plan.outputs.e2e == 'true' runs-on: ubuntu-latest timeout-minutes: 40 env: E2E_BASE_URL: http://localhost:8080 E2E_EVIDENCE_DIR: ${{ github.workspace }}/tests/e2e/evidence + E2E_CLS_WARNINGS_DIR: ${{ github.workspace }}/tests/e2e/cls-warnings COMPOSE: docker compose -f docker-compose.yml -f docker-compose.stack.yml steps: - uses: actions/checkout@v4 @@ -137,8 +199,10 @@ jobs: tests/e2e/package-lock.json - name: Build front-end plugins (background) # The web container mounts the checkout: the bundles only have to exist before the tests. + # Every front-end, not only the checked ones: a plugin's specs can go through another plugin's pages + # (core-altered-cards creates its decks through Re:Builder's relay). env: - FRONTENDS: ${{ needs.plan.outputs.frontends }} + FRONTENDS: ${{ needs.plan.outputs.all_frontends }} run: | cat > "$RUNNER_TEMP/frontends.sh" <<'EOF' set -euo pipefail @@ -151,20 +215,20 @@ jobs: nohup bash -c 'bash "$RUNNER_TEMP/frontends.sh"; echo $? > "$RUNNER_TEMP/frontends.exit"' > "$RUNNER_TEMP/frontends.log" 2>&1 & - uses: docker/setup-buildx-action@v3 - name: Build the stack images - # From the GitHub Actions cache (one scope per image): rebuilt only when the Dockerfile or - # the pinned decks API commit changes. + # From preprod's GitHub Actions cache (one scope per image, written by stack-images.yml): rebuilt only when + # the pull request changes the Dockerfile or the pinned decks API commit. Read only: a cache written here + # would be scoped to this pull request, unreadable by the others, and would fill the repository's 10 GB. uses: docker/bake-action@v6 with: source: . files: | docker-compose.yml docker-compose.stack.yml + targets: web,decks-api load: true set: | web.cache-from=type=gha,scope=stack-web - web.cache-to=type=gha,scope=stack-web,mode=max decks-api.cache-from=type=gha,scope=stack-decks-api - decks-api.cache-to=type=gha,scope=stack-decks-api,mode=max - name: Start the stack run: $COMPOSE up -d --no-build --wait --wait-timeout 600 - name: Wait for the front-end builds @@ -176,8 +240,13 @@ jobs: working-directory: tests/e2e run: npm ci && npx playwright install --with-deps chromium - name: Run Playwright + # Every spec when shared files changed, otherwise only the specs of the changed plugins. working-directory: tests/e2e - run: npx playwright test + env: + SPECS: ${{ needs.plan.outputs.e2e_specs }} + run: | + # shellcheck disable=SC2086 # one argument per folder + npx playwright test $SPECS - name: Stack logs if: failure() run: $COMPOSE logs --no-color --tail=200 @@ -190,6 +259,105 @@ jobs: tests/e2e/evidence tests/e2e/test-results retention-days: 14 + - uses: actions/upload-artifact@v4 + if: always() + with: + name: cls-warnings + path: tests/e2e/cls-warnings + if-no-files-found: ignore + retention-days: 14 + + cls: + # CLS over budget (tests/e2e/loading.ts, clsWarning): a warning on the run and one pull request comment, kept up to + # date, with the screenshots (pushed to the pr-screenshots branch). Runs no code of the pull request: it reads the + # artifact as data. Not a dependency of "Plugins CI": it never makes the pull request red. + name: CLS warnings + needs: e2e + if: always() && github.event_name == 'pull_request' && (needs.e2e.result == 'success' || needs.e2e.result == 'failure') + runs-on: ubuntu-latest + permissions: + contents: write # the screenshots, on the pr-screenshots branch + pull-requests: write # the comment + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + # A fork's token is read only: warnings on the run, no screenshots and no comment. + CAN_WRITE: ${{ github.event.pull_request.head.repo.full_name == github.repository }} + SHOTS: ${{ github.event.pull_request.number }}/cls-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/download-artifact@v4 + continue-on-error: true # no artifact: no CLS over budget + with: + name: cls-warnings + path: cls-warnings + - name: List the warnings + id: list + run: | + # Only .json with a safe slug: the artifact comes from the pull request's code. + slugs=() + for f in cls-warnings/*.json; do + [ -f "$f" ] || continue + slug="$(basename "$f" .json)" + [[ "$slug" =~ ^[a-z0-9-]+$ ]] && slugs+=("$slug") + done + echo "slugs=${slugs[*]}" >> "$GITHUB_OUTPUT" + for slug in "${slugs[@]}"; do + jq -r '"::warning title=CLS over budget::\(.project) · \(.title): CLS \(.cls) > \(.budget)"' "cls-warnings/$slug.json" + done + - uses: actions/checkout@v4 + if: steps.list.outputs.slugs != '' && env.CAN_WRITE == 'true' + with: + ref: pr-screenshots + path: shots + - name: Push the screenshots + if: steps.list.outputs.slugs != '' && env.CAN_WRITE == 'true' + env: + SLUGS: ${{ steps.list.outputs.slugs }} + working-directory: shots + run: | + mkdir -p "$SHOTS" + for slug in $SLUGS; do [ -f "../cls-warnings/$slug.png" ] && cp "../cls-warnings/$slug.png" "$SHOTS/"; done + git config user.name 'github-actions[bot]' + git config user.email '41898282+github-actions[bot]@users.noreply.github.com' + git add "$SHOTS" + git commit -qm "CLS warnings of #$PR (run $GITHUB_RUN_ID)" + for _ in 1 2 3; do git push -q origin HEAD:pr-screenshots && exit 0; git pull -q --rebase origin pr-screenshots; done + exit 1 + - name: Comment on the pull request + if: env.CAN_WRITE == 'true' + env: + SLUGS: ${{ steps.list.outputs.slugs }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + run: | + marker='' + existing="$(gh api "repos/$REPO/issues/$PR/comments" --paginate --jq ".[] | select(.body | startswith(\"$marker\")) | .id" | head -1)" + if [ -z "$SLUGS" ]; then + # Nothing over budget: update an earlier comment, never add one. + [ -n "$existing" ] || exit 0 + body="$marker"$'\n'"### ✅ Loading: no CLS over budget"$'\n\n'"Last run: $RUN_URL" + else + { + echo "$marker" + echo '### ⚠️ Loading: CLS over budget' + echo + echo "Not a failure: the CLS of a screen varies from one CI run to the next. Each screenshot is the settled page, the boxes that moved drawn on it (dashed: before, solid: after). Frame by frame: \`npm run loading:timeline -- \` in \`tests/e2e\`. Run: $RUN_URL" + for slug in $SLUGS; do + url="https://github.com/$REPO/blob/pr-screenshots/$SHOTS/$slug.png?raw=true" + jq -r --arg url "$url" ' + "\n#### \(.project) · \(.title)\n\nCLS **\(.cls)** (budget \(.budget))\n\n\n\n
Shifts\n\n```\n" + + ([.shifts[] | "\(.t) ms \(.value)\n" + ([.boxes[] | " " + gsub("`"; "")] | join("\n"))] | join("\n")) + + "\n```\n
" + ' "cls-warnings/$slug.json" + done + } > body.md + body="$(cat body.md)" + fi + if [ -n "$existing" ]; then + gh api -X PATCH "repos/$REPO/issues/comments/$existing" -f body="$body" > /dev/null + else + gh api -X POST "repos/$REPO/issues/$PR/comments" -f body="$body" > /dev/null + fi result: # Single check to require in branch protection. @@ -199,7 +367,10 @@ jobs: runs-on: ubuntu-latest steps: - name: All jobs passed + env: + NEEDS: ${{ toJSON(needs) }} run: | - echo '${{ toJSON(needs) }}' | jq -r 'to_entries[] | "\(.key): \(.value.result)"' - echo '${{ toJSON(needs) }}' | jq -e 'all(.[]; .result == "success" or .result == "skipped")' > /dev/null - [ "${{ needs.e2e.result }}" = "success" ] + jq -r 'to_entries[] | "\(.key): \(.value.result)"' <<< "$NEEDS" + jq -e 'all(.[]; .result == "success" or .result == "skipped")' <<< "$NEEDS" > /dev/null + # Playwright may be skipped only when the plan says so (no checked plugin has specs). + jq -e '.e2e.result == "success" or .plan.outputs.e2e == "false"' <<< "$NEEDS" > /dev/null diff --git a/.github/workflows/stack-images.yml b/.github/workflows/stack-images.yml new file mode 100644 index 00000000..ecf6ddea --- /dev/null +++ b/.github/workflows/stack-images.yml @@ -0,0 +1,42 @@ +name: Stack images + +# Builds the e2e stack images (web, decks-api) on preprod and writes their layers to the GitHub Actions cache. +# Pull requests only read that cache (plugins-ci.yml, job e2e): a cache written by a pull request is scoped to its +# own ref and no other pull request can read it, while every pull request against preprod reads preprod's. +# • push on preprod: a merged Dockerfile or decks API commit is cached for the next pull requests; +# • weekly: GitHub drops caches unused for 7 days, the schedule keeps this one when nothing is merged. +on: + push: + branches: [preprod] + schedule: + - cron: '0 4 * * 1' + workflow_dispatch: {} + +concurrency: + group: stack-images + cancel-in-progress: true + +permissions: + contents: read + +jobs: + cache: + name: Build and cache the stack images + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build the stack images + # Same targets and scopes as plugins-ci.yml (job e2e). + uses: docker/bake-action@v6 + with: + source: . + files: | + docker-compose.yml + docker-compose.stack.yml + targets: web,decks-api + set: | + web.cache-from=type=gha,scope=stack-web + web.cache-to=type=gha,scope=stack-web,mode=max + decks-api.cache-from=type=gha,scope=stack-decks-api + decks-api.cache-to=type=gha,scope=stack-decks-api,mode=max diff --git a/AGENTS.md b/AGENTS.md index f99b0ea7..6b886da8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,3 +35,21 @@ The rules that matter most: - PHP tests: `php tests/run.php` (in Docker: `docker compose exec web php tests/run.php`). - SPA plugins: `plugins//plugin.json` → `build` (lint, test, build); e2e with Playwright in `tests/e2e` and `plugins//e2e` against the full stack (`README.md` § Full stack). +- Loading (skeletons, layout shifts): after any change to what a screen shows while it loads (placeholder, skeletons, + data or images arriving after the first screen, the runtime), in `tests/e2e`: + - `npm run loading`: every Re:Builder screen on a slow phone network, first and second visit, desktop and mobile + (`plugins/rebuilder/e2e/loading.spec.ts`, also run by the CI). It fails on an empty frame, the app shown unstyled, + the skeleton losing the site's font or a « Chargement… » title. A CLS over the screen's budget + (`plugins/rebuilder/e2e/loading-budgets.json`: 0.01, or today's value for a known shift; lower a budget when a fix + lands, never raise one to pass) is a warning, not a failure: on the CI, a pull request comment with a screenshot of + the boxes that moved (job « CLS warnings »). + - `npm run loading:timeline -- [--viewport | --desktop] [--first-visit] [--frames] [--compare ]`: + the loading frame by frame (empty frames in red, shifts marked) with its report, for the PR's evidence; `--compare` + puts the base branch's stack (before) and this one (after) in one image. Header of `tests/e2e/loading-timeline.ts` + for the options. + - `npm run loading:matrix [-- --screens a,b --viewports a,b --runs n]`: the CLS of every screen at eight sizes (phones, + tablet, laptops, desktops: `VIEWPORTS` in `tests/e2e/loading.ts`), on both sides of the breakpoints, in one table + with what moved. Run it after a layout change that depends on the width (a breakpoint, a container query, a skeleton + whose size is set for one width). + - A new screen or a new loading state: add it to `plugins/rebuilder/e2e/screens.ts`; the recorder + (`tests/e2e/loading.ts`) works for any SPA plugin. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 46eafd38..a8525af1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -109,7 +109,7 @@ git push origin dev:main ## Code conventions -- **PHP 8.4.** Production runs PHP 8.4.27 (Docker image php:8.4.27-apache). Until preprod has switched too, the preprod CI also lints under 7.4; keep new code free of PHP 8.5-only features. +- **PHP 8.4.** Production and preprod run PHP 8.4.27 (Docker image php:8.4.27-apache). PHP 8 syntax is fine; keep new code free of PHP 8.5-only features. - **No framework, no Composer.** No build step, no external dependencies. The project is intentionally plain PHP — keep it that way. - **Comments in English.** - **Interface: the design system.** Every screen uses `design-system/` (read its `README.md`): colours, sizes, radii and shadows come from the `--ac-*` tokens (`design-system/tokens/tokens.css`, the only file with raw values), components are the `ac-*` classes, icons are Lucide (`ac_icon('name')`). Light/dark and pointer/touch are handled by the tokens: no `[data-theme]` rule in page, theme or plugin CSS. CSS files: (1) `design-system/`: tokens and shared components; (2) `css/style.css`: shell and core pages; (3) `themes/azure/`: header, menu and footer templates and their CSS; (4) `plugins/*/assets/*.css`: plugin-specific layout, built from tokens. `php tests/run.php` rejects hard-coded colours. Designing with Claude (`/design`): `design-system/WORKFLOW.md`. diff --git a/README.md b/README.md index ec583d85..722da043 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Pull requests that introduce external dependencies or non-standard server requir ## Requirements - Apache with `mod_rewrite` enabled -- PHP 8.4 (8.2 and 8.3 also run the code; 7.4 is no longer supported once both environments have switched) +- PHP 8.4 (8.2 and 8.3 also run the code) - MariaDB 10.x --- @@ -104,7 +104,15 @@ The core stays build-free. Front-end plugins (manifest v2, `"type": "spa"`, e.g. docker compose -f docker-compose.yml -f docker-compose.stack.yml up -d --build --wait ``` -Site on http://localhost:8080 (`WEB_PORT` to change it), Keycloak users `alice` / `bob` (password `TestPassword1234`), decks API on http://localhost:8001. Playwright: `cd tests/e2e && npm ci && npx playwright test`. CI runs the same on every pull request (`.github/workflows/plugins-ci.yml`). +Site on http://localhost:8080 (`WEB_PORT` to change it), Keycloak users `alice` / `bob` (password `TestPassword1234`), decks API on http://localhost:8001. Playwright: `cd tests/e2e && npm ci && npx playwright test`. CI runs the same on every pull request (`.github/workflows/plugins-ci.yml`). Loading checks (skeletons, layout shifts): `npm run loading` and `npm run loading:timeline -- ` in `tests/e2e` (`AGENTS.md` § Checks). + +**Alt arts with the real ownership service.** The stack's digital ownership is a mock (`docker/stack/ownership-mock`). To try the default alt arts with [AlteredOwnership](https://github.com/Altered-Re-Union/AlteredOwnership) itself, built from GitHub (alice and bob own Axiom alt arts and have default alt arts; alice is in « Global » mode, with a deck of plain prints that takes her default alt arts): + +```bash +docker compose -f docker-compose.yml -f docker-compose.stack.yml -f docker-compose.ownership.yml up -d --build --wait +``` + +Sign in again after its first start (the service needs the `read-collection` scope in the site's tokens). Details in `docker-compose.ownership.yml` and `docker/stack/ownership-real/`. **Like production.** To get the plugins active on https://altered.re and its public content (news, content pages, side menu, footer, fonts, logo, home page text, images): diff --git a/api/v1/services/proxy.php b/api/v1/services/proxy.php index e6d83ac5..26a0a1cf 100644 --- a/api/v1/services/proxy.php +++ b/api/v1/services/proxy.php @@ -117,6 +117,13 @@ function serviceProxySend(string $method, string $url, array $headers, string $b if ($res === null) serviceProxyReply(502, ['error' => 'service_unreachable', 'service' => $service]); [$status, $type, $out] = $res; +// A deck deleted: what the site keeps about it goes too (includes/func.alt-arts.php). +if ($service === 'decks' && $method === 'DELETE' && $status >= 200 && $status < 300 && preg_match('#^api/decks/([A-Za-z0-9-]{1,64})$#', $path, $m)) { + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + altArtForgetDeck($userId, $m[1]); +} +require_once dirname(__DIR__, 3) . '/includes/json-gzip.php'; +jsonGzipStart(); http_response_code($status); header('Content-Type: ' . ($type !== '' ? $type : 'application/json')); echo $out; diff --git a/assets/img/discover/day-poster.jpg b/assets/img/discover/day-poster.jpg new file mode 100644 index 00000000..22cddac6 Binary files /dev/null and b/assets/img/discover/day-poster.jpg differ diff --git a/assets/img/discover/day.mp4 b/assets/img/discover/day.mp4 new file mode 100644 index 00000000..7ccc5caf Binary files /dev/null and b/assets/img/discover/day.mp4 differ diff --git a/assets/img/discover/sets/beyond-the-gates.webp b/assets/img/discover/sets/beyond-the-gates.webp new file mode 100644 index 00000000..9c4bb88d Binary files /dev/null and b/assets/img/discover/sets/beyond-the-gates.webp differ diff --git a/assets/img/discover/sets/roots-of-corruption.webp b/assets/img/discover/sets/roots-of-corruption.webp new file mode 100644 index 00000000..ee760163 Binary files /dev/null and b/assets/img/discover/sets/roots-of-corruption.webp differ diff --git a/assets/img/discover/sets/seeds-of-unity.webp b/assets/img/discover/sets/seeds-of-unity.webp new file mode 100644 index 00000000..84753d0e Binary files /dev/null and b/assets/img/discover/sets/seeds-of-unity.webp differ diff --git a/assets/img/discover/sets/skybound-odyssey.webp b/assets/img/discover/sets/skybound-odyssey.webp new file mode 100644 index 00000000..a3457cd7 Binary files /dev/null and b/assets/img/discover/sets/skybound-odyssey.webp differ diff --git a/assets/img/discover/sets/trial-by-frost.webp b/assets/img/discover/sets/trial-by-frost.webp new file mode 100644 index 00000000..aa143ce1 Binary files /dev/null and b/assets/img/discover/sets/trial-by-frost.webp differ diff --git a/assets/img/discover/sets/whispers-from-the-maze.webp b/assets/img/discover/sets/whispers-from-the-maze.webp new file mode 100644 index 00000000..47b0c0d7 Binary files /dev/null and b/assets/img/discover/sets/whispers-from-the-maze.webp differ diff --git a/css/discover.css b/css/discover.css new file mode 100644 index 00000000..373354b2 --- /dev/null +++ b/css/discover.css @@ -0,0 +1,570 @@ +/* + * Discover page (pages/discover.php). Layout only: every value comes from the design system. + * The contents list itself is the design system's ac-toc. + */ +.discover { padding-bottom: var(--ac-space-10); } + +/* Header strip */ +.discover-header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--ac-space-1) var(--ac-space-5); + padding: var(--ac-space-6) 0 var(--ac-space-5); + border-bottom: 1px solid var(--ac-color-border); +} +.discover-header__title { + font: var(--ac-font-display); + letter-spacing: var(--ac-letter-spacing-display); + margin: 0; +} + +/* Body: contents + panels */ +.discover-body { + display: flex; + flex-direction: column; + gap: var(--ac-space-4); + padding: var(--ac-space-6) 0 var(--ac-space-8); +} +.discover-content { + display: flex; + flex-direction: column; + gap: var(--ac-space-6); + min-width: 0; + flex: 1; +} + +/* Section panels */ +.discover-section { + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + padding: var(--ac-space-8); +} +.discover-section__title { + font: var(--ac-font-title); + letter-spacing: var(--ac-letter-spacing-display); + color: inherit; + margin: 0 0 var(--ac-space-4); +} +.discover-section__title span { + display: inline-block; + padding-bottom: var(--ac-space-1); + border-bottom: 3px solid var(--ac-color-primary); +} +.discover-section__body:empty { display: none; } + +/* Contents */ +.discover-toc__toggle { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + min-height: var(--ac-control-md); + padding: 0 var(--ac-space-4); + font: var(--ac-font-body-strong); + color: var(--ac-color-text); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + cursor: pointer; +} +.discover-toc__toggle[aria-expanded='true'] .ac-icon { transform: rotate(180deg); } +.discover-toc__panel { display: none; margin-top: var(--ac-space-2); } +.discover-toc__toggle[aria-expanded='true'] + .discover-toc__panel { display: block; } +.discover-toc__outs { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ac-space-2); } +.discover-toc__out { + display: flex; + align-items: center; + gap: var(--ac-space-2); + font: var(--ac-font-body); + font-weight: 700; + color: var(--ac-color-link); + text-decoration: none; +} +@media (hover: hover) { .discover-toc__out:hover { text-decoration: underline; } } + +/* Tablet: contents become a sticky bar under the menu */ +@media (min-width: 768px) { + .discover-toc { + position: sticky; + top: var(--ac-sticky-top); + z-index: var(--ac-z-sticky); + background: var(--ac-color-bg-app); + padding: var(--ac-space-2) 0; + } + .discover-toc__panel { position: absolute; left: 0; right: 0; box-shadow: var(--ac-shadow-e2); } +} + +/* Desktop: sticky sidebar, list always open */ +@media (min-width: 1200px) { + .discover-body { flex-direction: row; align-items: flex-start; gap: var(--ac-space-8); } + .discover-toc { + flex: 0 0 236px; + padding: 0; + background: none; + top: calc(var(--ac-sticky-top) + var(--ac-space-4)); + } + .discover-toc__toggle { display: none; } + .discover-toc__panel { display: block; position: static; margin: 0; box-shadow: none; } +} + +/* ---------- Section 1: How a game plays ---------- */ +.discover-lead { font: var(--ac-font-prose); margin: 0 0 var(--ac-space-6); } + +.discover-diff { display: grid; gap: var(--ac-space-3); margin-bottom: var(--ac-space-6); } +.discover-diff__item { + background: var(--ac-color-bg-app); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); + padding: var(--ac-space-4) var(--ac-space-5); +} +.discover-diff__title { font: var(--ac-font-body-strong); margin: 0 0 var(--ac-space-2); } +.discover-diff__body { font: var(--ac-font-body); font-weight: 400; line-height: 1.5; color: var(--ac-color-text-2); margin: 0; } + +.discover-day { + list-style: none; + margin: 0 0 var(--ac-space-6); + padding: 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--ac-space-3); + counter-reset: day; +} +.discover-day__item { + counter-increment: day; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1-5); + padding: var(--ac-space-4) var(--ac-space-3); + text-align: center; + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); +} +.discover-day__item:last-child { grid-column: span 2; } +.discover-day__icon { width: 40px; height: 40px; object-fit: contain; } +.discover-day__name { font: var(--ac-font-body-strong); } +.discover-day__name::before { content: counter(day) ' · '; } +.discover-day__text { font: var(--ac-font-small); font-weight: 500; color: var(--ac-color-text-muted); } + +.discover-demo { display: grid; gap: var(--ac-space-4); margin-bottom: var(--ac-space-4); } +.discover-demo .discover-day { margin-bottom: 0; } +.discover-visual { margin: 0; } +.discover-visual__stage { position: relative; } +.discover-visual__video { + display: block; + width: 100%; + aspect-ratio: 1360 / 814; + object-fit: cover; + border-radius: var(--ac-radius-lg); + background: var(--ac-color-overlay-control); +} +/* What is happening, in words. Without the script (or under reduced motion) every line is listed under the video; + with it, one line at a time in a bar under the video (over it they would hide the hands and Reserves they describe). */ +.discover-visual__cues { list-style: none; margin: var(--ac-space-2) 0 0; padding: 0; display: grid; gap: var(--ac-space-1-5); } +.discover-visual__cue { font: var(--ac-font-body); font-weight: 500; color: var(--ac-color-text-2); } +.discover-day__cue { display: none; } +.discover-visual__cues.is-live { + display: block; + min-height: 4.5em; + padding: var(--ac-space-2) var(--ac-space-3); + border-radius: var(--ac-radius-lg); + background: var(--ac-color-primary-tint); +} +.discover-visual__cues.is-live .discover-visual__cue { display: none; } +.discover-visual__cues.is-live .discover-visual__cue[data-active] { display: block; } +/* A caption pops in when it changes: it rises a little and fades in. */ +@keyframes discover-cue-in { + from { opacity: 0; transform: translateY(var(--ac-space-2)); } + to { opacity: 1; transform: none; } +} +@media (prefers-reduced-motion: no-preference) { + .discover-visual__cues.is-live .discover-visual__cue[data-active], + .discover-demo .discover-day__item > .discover-day__cue[data-active] { + animation: discover-cue-in calc(var(--ac-duration-base) * 2) ease-out; + } +} +/* The phase the loop is showing. Morning and Noon are not in the loop, so they never get it. */ +.discover-day__item[aria-current='step'] { background: var(--ac-color-primary-soft); border-color: var(--ac-color-primary); } +.discover-visual__caption { + margin-top: var(--ac-space-2); + font: var(--ac-font-small); + font-style: italic; + font-weight: 500; + color: var(--ac-color-text-muted); +} + +.discover-more { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--ac-space-1) var(--ac-space-3); margin: 0; } +.discover-more__link { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); + font: var(--ac-font-body-strong); + color: var(--ac-color-link); + text-decoration: none; +} +@media (hover: hover) { .discover-more__link:hover { text-decoration: underline; } } + +@media (min-width: 768px) { + .discover-diff { grid-template-columns: repeat(2, 1fr); } + .discover-day { grid-template-columns: repeat(6, 1fr); } + .discover-day__item { grid-column: span 2; } + .discover-day__item:nth-child(n + 4) { grid-column: span 3; } + .discover-day__item:last-child { grid-column: span 3; } + .discover-day__name::before { content: none; } + .discover-visual { max-width: 760px; margin-inline: auto; } +} +@media (min-width: 1200px) { + .discover-diff { grid-template-columns: repeat(3, 1fr); } + .discover-day { grid-template-columns: repeat(5, 1fr); } + .discover-day__item, + .discover-day__item:nth-child(n + 4), + .discover-day__item:last-child { grid-column: span 1; } + /* Phases as a list beside the loop, so each one lights up as the video reaches it. */ + .discover-demo { grid-template-columns: minmax(0, 22rem) minmax(0, 1fr); align-items: start; } + .discover-demo .discover-day { grid-template-columns: 1fr; } + .discover-demo .discover-day__item { + display: grid; + grid-template-columns: auto 1fr; + column-gap: var(--ac-space-3); + align-items: center; + text-align: left; + padding: var(--ac-space-3); + } + .discover-demo .discover-day__icon { grid-row: span 2; } + .discover-demo .discover-visual { max-width: none; margin: 0; } + /* The caption moves from under the video to under the phase being shown. */ + .discover-demo .discover-day__item > .discover-day__cue[data-active] { + display: block; + grid-column: 1 / -1; + margin-top: var(--ac-space-2); + padding-top: var(--ac-space-2); + border-top: 1px solid var(--ac-color-border); + font: var(--ac-font-body); + font-weight: 500; + color: var(--ac-color-text-2); + } + .discover-visual__cues.is-live { + position: absolute; + width: 1px; + height: 1px; + min-height: 0; + padding: 0; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + } +} + +/* ---------- Section 2: The world and its art ---------- */ +/* Each faction brings its own colour (--ac-faction-*): the chip, the panel border and tint, the name and the link. */ +.discover-world [data-faction='intro'] { --discover-faction: var(--ac-color-primary); } +.discover-world [data-faction='axiom'] { --discover-faction: var(--ac-faction-axiom); } +.discover-world [data-faction='bravos'] { --discover-faction: var(--ac-faction-bravos); } +.discover-world [data-faction='lyra'] { --discover-faction: var(--ac-faction-lyra); } +.discover-world [data-faction='muna'] { --discover-faction: var(--ac-faction-muna); } +.discover-world [data-faction='ordis'] { --discover-faction: var(--ac-faction-ordis); } +.discover-world [data-faction='yzmir'] { --discover-faction: var(--ac-faction-yzmir); } + +.discover-chips { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--ac-space-2); margin-bottom: var(--ac-space-4); } +.discover-chip { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-1); + padding: var(--ac-space-2) var(--ac-space-1); + min-height: var(--ac-control-lg); + color: var(--ac-color-text); + background: var(--ac-color-bg-app); + border: 1px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-lg); + font: var(--ac-font-body-strong); + cursor: pointer; +} +.discover-chip[aria-selected='true'] { + background: color-mix(in srgb, var(--discover-faction) 14%, var(--ac-color-surface)); + border: 2px solid var(--discover-faction); +} +.discover-chip__icon { display: grid; place-items: center; width: var(--ac-space-8); height: var(--ac-space-8); font-size: var(--ac-space-6); line-height: 1; color: var(--discover-faction); } +.discover-chip__logo { width: var(--ac-space-8); height: var(--ac-space-8); object-fit: contain; } +@media (hover: hover) { + .discover-chip:hover { background: color-mix(in srgb, var(--discover-faction) 10%, var(--ac-color-surface)); } +} + +.discover-faction { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-5) var(--ac-space-8); + padding: var(--ac-space-8); + background: color-mix(in srgb, var(--discover-faction) 6%, var(--ac-color-surface)); + border: 1px solid color-mix(in srgb, var(--discover-faction) 35%, var(--ac-color-border)); + border-top: 4px solid var(--discover-faction); + border-radius: var(--ac-radius-lg); +} +.discover-faction[hidden] { display: none; } +.discover-faction__text { flex: 1 1 20rem; min-width: 0; } +.discover-faction__name { font: var(--ac-font-display); letter-spacing: var(--ac-letter-spacing-display); color: var(--discover-faction); margin: 0; } +.discover-faction__label { + display: flex; + align-items: center; + gap: var(--ac-space-3); + margin: var(--ac-space-6) 0 var(--ac-space-2); + font: var(--ac-font-overline); + font-weight: 800; + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + /* A muted faction colour: toned toward the muted text colour, still readable on the tinted panel. */ + color: color-mix(in srgb, var(--discover-faction) 60%, var(--ac-color-text-muted)); +} +/* A rule after the label, in the faction colour, so the two blocks read as two blocks. */ +.discover-faction__label::after { + content: ''; + flex: 1; + height: 2px; + border-radius: var(--ac-radius-pill); + background: color-mix(in srgb, var(--discover-faction) 30%, transparent); +} +.discover-faction__name + .discover-faction__label { margin-top: var(--ac-space-4); } +.discover-faction__text > .discover-faction__label:first-child { margin-top: 0; } +.discover-faction__copy { font: var(--ac-font-prose); line-height: 1.7; color: var(--ac-color-text); margin: 0; } +.discover-faction__link, +.discover-world__link { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); + margin-top: var(--ac-space-6); + font: var(--ac-font-body-strong); + color: var(--ac-color-link); + text-decoration: none; +} +.discover-faction__link { color: var(--discover-faction); } +@media (hover: hover) { + .discover-faction__link:hover, + .discover-world__link:hover { text-decoration: underline; } +} + +/* Introduction tab: key art of a set (729x409 source images) */ +.discover-set { flex: 0 1 24rem; align-self: center; margin: 0; } +.discover-set__img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; border-radius: var(--ac-radius-lg); background: var(--ac-color-track); } +.discover-set__caption { margin-top: var(--ac-space-1-5); text-align: center; font: var(--ac-font-small); color: var(--ac-color-text-muted); } + +/* The two illustrations: a Hero (standard version) and another card, side by side */ +.discover-faction__art { display: flex; gap: var(--ac-space-3); align-items: flex-start; justify-content: center; align-self: center; flex: 0 1 28.25rem; } +.discover-art { flex: 1 1 0; max-width: 16.67rem; margin: 0; } +.discover-art__frame { + aspect-ratio: 63 / 88; + border-radius: var(--ac-radius-md); + background: var(--ac-color-track); + overflow: hidden; +} +.discover-art__frame:not(.is-loaded) { animation: ac-skeleton-pulse 1.4s ease-in-out infinite; } +.discover-art__img { display: block; width: 100%; height: 100%; object-fit: cover; } +.discover-art__credit { text-align: center; margin-top: var(--ac-space-1-5); font: var(--ac-font-small); color: var(--ac-color-text-muted); } +.discover-art__credit:empty { display: none; } +.discover-faction__art.is-empty { display: none; } + +.discover-world__human { font: var(--ac-font-prose); margin: var(--ac-space-5) 0 0; } +.discover-world__links { display: flex; flex-wrap: wrap; gap: var(--ac-space-1) var(--ac-space-5); margin: 0; } + +@media (min-width: 1200px) { + .discover-chips { grid-template-columns: repeat(7, 1fr); } +} + +/* ---------- Section 3: Ways to play ---------- */ +.discover-tracks { display: grid; gap: var(--ac-space-4); align-items: stretch; } +.discover-track { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); + padding: var(--ac-space-5); + background: var(--ac-color-bg-app); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); +} +.discover-track__title { font: var(--ac-font-body-strong); font-size: var(--ac-font-size-prose); margin: 0; } +.discover-track__steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ac-space-3); counter-reset: step; } +.discover-track__steps li { + counter-increment: step; + display: grid; + grid-template-columns: var(--ac-space-6) 1fr; + gap: var(--ac-space-3); + align-items: start; + font: var(--ac-font-body); + font-weight: 400; + line-height: 1.5; + color: var(--ac-color-text-2); +} +/* The number is generated content: the list already says the order. Outlined, except on the recommended track. */ +.discover-track__steps li::before { + content: counter(step); + display: grid; + place-items: center; + width: var(--ac-space-6); + height: var(--ac-space-6); + border: 1px solid var(--ac-color-primary); + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + color: var(--ac-color-primary); + font: var(--ac-font-caption); +} +.discover-track--primary .discover-track__steps li::before { background: var(--ac-color-primary); color: var(--ac-color-on-primary); } +.discover-track__note { font: var(--ac-font-caption); font-weight: 500; color: var(--ac-color-text-muted); margin: 0; } +/* Pinned to the bottom: the buttons line up whatever the content length. */ +.discover-track__cta { margin-top: auto; width: 100%; white-space: normal; text-align: center; } + +@media (min-width: 768px) { + .discover-tracks { grid-template-columns: repeat(2, 1fr); } +} + +/* ---------- Section 4: Build your own deck ---------- */ +.discover-decks__top { margin-bottom: var(--ac-space-8); } +.discover-decks__main .discover-lead { margin-bottom: var(--ac-space-5); } + +.discover-features { margin: 0 0 var(--ac-space-5); display: flex; flex-direction: column; gap: var(--ac-space-3); } +.discover-features__row { display: grid; grid-template-columns: 11rem 1fr; gap: var(--ac-space-1) var(--ac-space-4); align-items: baseline; } +.discover-features dt { font: var(--ac-font-body-strong); font-weight: 800; } +.discover-features dd { font: var(--ac-font-body); font-weight: 400; color: var(--ac-color-text-2); margin: 0; } + +.discover-figures { + list-style: none; + margin: 0 0 var(--ac-space-5); + padding: var(--ac-space-4) 0; + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--ac-space-4); + border-top: 1px solid var(--ac-color-border); + border-bottom: 1px solid var(--ac-color-border); +} +.discover-figures__item { display: flex; flex-direction: column; gap: var(--ac-space-0-5); } +.discover-figures__value { font: var(--ac-font-display); letter-spacing: var(--ac-letter-spacing-display); color: var(--ac-color-primary-strong); } +.discover-figures__label { font: var(--ac-font-small); color: var(--ac-color-text-muted); } + +/* The four steps, from account to play: two groups ("once", "every time"), a chevron between the steps of a group */ +.discover-flow { display: grid; gap: var(--ac-space-5); } +.discover-flow__group { display: flex; flex-direction: column; gap: var(--ac-space-2); min-width: 0; } +.discover-flow__label { + margin: 0; + font: var(--ac-font-micro); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--ac-color-text-muted); +} +.discover-flow__steps { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--ac-space-3); counter-reset: flow; } +.discover-flow__group:nth-child(2) .discover-flow__steps { counter-reset: flow 2; } +.discover-flow__step { + position: relative; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ac-space-2); + padding: var(--ac-space-4); + background: var(--ac-color-bg-app); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); +} +/* The number is generated content (the list already says the order); the second group carries on from 2. */ +.discover-flow__step { counter-increment: flow; } +.discover-flow__step::before { + content: counter(flow); + display: grid; + place-items: center; + width: var(--ac-space-6); + height: var(--ac-space-6); + border-radius: var(--ac-radius-pill); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); + font: var(--ac-font-caption); +} +.discover-flow__title { font: var(--ac-font-body-strong); font-weight: 800; margin: 0; } +.discover-flow__text { font: var(--ac-font-body); font-weight: 400; line-height: 1.5; color: var(--ac-color-text-2); margin: 0; } +.discover-flow__cta { width: 100%; white-space: normal; text-align: center; margin-top: var(--ac-space-1); } +.discover-flow__aside { margin: 0; font: var(--ac-font-small); color: var(--ac-color-text-muted); align-self: center; text-align: center; } +.discover-flow__aside a, +.discover-flow__link { font-weight: 800; color: var(--ac-color-link); } +.discover-flow__link { display: inline-flex; align-items: center; gap: var(--ac-space-1-5); font: var(--ac-font-body-strong); font-weight: 800; text-decoration: underline; } +@media (hover: hover) { .discover-flow__aside a:hover, .discover-flow__link:hover { color: var(--ac-color-link-hover); } } + +/* Formats picker: the faction picker's pattern, in the primary colour */ +.discover-formats { --discover-faction: var(--ac-color-primary); } +.discover-formats .discover-chips { grid-template-columns: repeat(2, 1fr); } +.discover-format__links { display: flex; flex-direction: column; gap: var(--ac-space-1); margin: 0; } +.discover-table { + flex: 0 1 17.5rem; + align-self: flex-start; + border-collapse: collapse; + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-md); + overflow: hidden; + font: var(--ac-font-body); +} +.discover-table th, +.discover-table td { padding: var(--ac-space-2-5) var(--ac-space-3); text-align: left; border-bottom: 1px solid var(--ac-color-divider); vertical-align: top; } +.discover-table tr:last-child :is(th, td) { border-bottom: 0; } +.discover-table th { font: var(--ac-font-small); font-weight: 700; color: var(--ac-color-text-muted); width: 45%; } +.discover-table td { font-weight: 700; } +.discover-formats__closing { display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--ac-space-1) var(--ac-space-4); margin: var(--ac-space-5) 0 0; font: var(--ac-font-body); color: var(--ac-color-text-muted); } +.discover-formats__closing .discover-world__link { margin-top: 0; } + +@media (min-width: 768px) { + .discover-figures { grid-template-columns: repeat(4, 1fr); } + .discover-formats .discover-chips { grid-template-columns: repeat(3, 1fr); } +} +@media (min-width: 1200px) { + .discover-formats .discover-chips { grid-template-columns: repeat(6, 1fr); } +} +@media (max-width: 767.98px) { + .discover-features__row { grid-template-columns: 1fr; } + .discover-table { flex-basis: 100%; } +} + +/* ---------- Section 5: Who runs Altered now ---------- */ +.discover-project { display: grid; gap: var(--ac-space-6); margin-bottom: var(--ac-space-5); } +.discover-project__title { font: var(--ac-font-body-strong); font-size: var(--ac-font-size-prose); margin: 0 0 var(--ac-space-3); } +.discover-project__list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--ac-space-2); } +.discover-project__list li { + display: grid; + grid-template-columns: var(--ac-space-5) 1fr; + gap: var(--ac-space-3); + align-items: start; + padding: var(--ac-space-3) var(--ac-space-4); + border-radius: var(--ac-radius-lg); + font: var(--ac-font-body); + font-weight: 500; +} +.discover-project__list .ac-icon { margin-top: var(--ac-space-0-5); } +/* Shipped: filled cards. In progress: dashed outline. The heading above each list says which is which. */ +.discover-project__list--shipped li { background: var(--ac-color-bg-app); border: 1px solid var(--ac-color-border); } +.discover-project__list--shipped .ac-icon { color: var(--ac-color-success); } +.discover-project__list--wip li { background: transparent; border: 1px dashed var(--ac-color-border-dashed); color: var(--ac-color-text-2); } +.discover-project__list--wip .ac-icon { color: var(--ac-color-text-muted); } +.discover-project__updated { margin: var(--ac-space-3) 0 0; font: var(--ac-font-small); color: var(--ac-color-text-muted); } +.discover-project__horizon { margin: var(--ac-space-4) 0 0; font: var(--ac-font-body); font-weight: 500; line-height: 1.6; color: var(--ac-color-text-2); } +.discover-project__links { display: flex; flex-wrap: wrap; gap: var(--ac-space-2) var(--ac-space-6); margin: 0; } +.discover-project__links .discover-world__link { margin-top: 0; } + +@media (min-width: 1200px) { + .discover-project { grid-template-columns: 1fr 1fr; gap: var(--ac-space-8); } +} + +@media (min-width: 768px) { + .discover-flow__steps { grid-template-columns: repeat(2, 1fr); gap: var(--ac-space-6); } + /* A chevron in the gap after the first step of each group */ + .discover-flow__step:first-child::after { + content: ''; + position: absolute; + top: 50%; + right: calc(var(--ac-space-6) / -2 - 4px); + width: 8px; + height: 8px; + border-top: 2px solid var(--ac-color-primary-border); + border-right: 2px solid var(--ac-color-primary-border); + transform: translateY(-50%) rotate(45deg); + } +} +@media (min-width: 1200px) { + .discover-flow { grid-template-columns: 1fr 1fr; gap: var(--ac-space-6); } +} diff --git a/design-system/AGENTS.md b/design-system/AGENTS.md index c56fe20d..d78cc09e 100644 --- a/design-system/AGENTS.md +++ b/design-system/AGENTS.md @@ -19,6 +19,6 @@ before changing anything under `design-system/` or building a screen. or the script, then `npm ci && npm run build` in `design-system/icons/`, never the outputs. - Bridges (`css/bridges/`) exist for pages not migrated yet. Do not add new legacy names; when a page is migrated, it uses `ac-*` classes and `--ac-*` tokens directly. -- PHP helpers (`php/ui.php`) must stay PHP 7.4 compatible until preprod has switched to PHP 8.4 (production runs php:8.4.27). +- PHP helpers (`php/ui.php`) run on PHP 8.4 (production and preprod run php:8.4.27). - After a change: `php tests/run.php` (Docker: `docker compose exec web php tests/run.php`), then look at `/pages/design-system` in both themes. diff --git a/design-system/css/base.css b/design-system/css/base.css index 55fe4ddc..f49d9f94 100644 --- a/design-system/css/base.css +++ b/design-system/css/base.css @@ -4,8 +4,18 @@ * (js/altered-core-host.js), so .ac-plugin-root gets the same defaults as the page. */ +/* What the browser scrolls to the top (keyboard focus, scrollIntoView, #anchors, find in page) stops under the + sticky site header and a plugin's sticky bar instead of behind them (WCAG 2.4.11 Focus Not Obscured). */ +html { + scroll-padding-top: calc(var(--ac-page-top) + var(--ac-scroll-top-offset)); +} + +/* .ac-spa-placeholder: the server's skeleton of an SPA page (includes/spa.php), shown through a slot of the plugin's + shadow root while the plugin loads. A plugin stylesheet that resets its host (`:host { all: initial }`) cuts what the + skeleton inherits from the page: without its own font and colour it turns serif once that stylesheet loads. */ body, -.ac-plugin-root { +.ac-plugin-root, +.ac-spa-placeholder { background: var(--ac-color-bg-app); color: var(--ac-color-text); font: var(--ac-font-body); @@ -14,7 +24,10 @@ body, -webkit-tap-highlight-color: transparent; } -.ac-plugin-root { +/* The page fills the window under the site header, and so does its skeleton: the site footer is never on the first + screen while the page loads, where the page's content would push it down (a layout shift). */ +.ac-plugin-root, +.ac-spa-placeholder { display: block; min-height: calc(100dvh - var(--ac-page-top)); } diff --git a/design-system/css/components/chips.css b/design-system/css/components/chips.css index af1ffb94..97800c71 100644 --- a/design-system/css/components/chips.css +++ b/design-system/css/components/chips.css @@ -53,7 +53,7 @@ /* Badge: status or category. Public */ .ac-badge { - height: 24px; + height: var(--ac-badge-height); padding: 0 9px; border-radius: var(--ac-radius-pill); font: var(--ac-font-caption); @@ -67,6 +67,8 @@ text-decoration: none; } .ac-badge--lg { height: 28px; } +/* Dense: tighter padding and gap, for a row of badges where room is short (a phone's app bar). */ +.ac-badge--dense { padding: 0 var(--ac-space-1-5); gap: 3px; } .ac-badge--blue { background: var(--ac-color-primary-soft); color: var(--ac-color-primary-strong); } .ac-badge--green { background: var(--ac-color-success-soft); color: var(--ac-color-success); } .ac-badge--violet { background: var(--ac-color-accent-soft); color: var(--ac-color-accent); } diff --git a/design-system/css/components/feedback.css b/design-system/css/components/feedback.css index eed01874..210ae79a 100644 --- a/design-system/css/components/feedback.css +++ b/design-system/css/components/feedback.css @@ -82,6 +82,26 @@ animation: ac-icon-spin 0.8s linear infinite; } +/* ---- Skeleton: the shape of content that is loading ---- */ +.ac-skeleton { + display: block; + width: var(--ac-skeleton-width, 100%); + min-height: 1px; + background: var(--ac-color-track); + border-radius: var(--ac-radius-md); + animation: ac-skeleton-pulse 1.4s ease-in-out infinite; +} +.ac-skeleton--text { height: 0.8em; margin: 0.1em 0; border-radius: var(--ac-radius-pill); } +.ac-skeleton--title { height: 1.1em; border-radius: var(--ac-radius-pill); } +.ac-skeleton--control { height: var(--ac-control-md); border-radius: var(--ac-radius-control); } +.ac-skeleton--control-sm { height: var(--ac-control-sm); border-radius: var(--ac-radius-control); } +.ac-skeleton--badge { height: var(--ac-badge-height); border-radius: var(--ac-radius-pill); } +.ac-skeleton--circle { width: var(--ac-skeleton-width, var(--ac-control-md)); aspect-ratio: 1; border-radius: 50%; } +.ac-skeleton--card { aspect-ratio: 5 / 7; border-radius: var(--ac-radius-lg); } +.ac-skeleton--panel { border-radius: var(--ac-radius-card); } +@keyframes ac-skeleton-pulse { 50% { opacity: 0.55; } } +@media (prefers-reduced-motion: reduce) { .ac-skeleton { animation: none; } } + /* ---- Blocking loader: scrim + panel with spinner, label and optional progress ---- */ .ac-loader { display: flex; align-items: center; justify-content: center; z-index: var(--ac-z-toast); } .ac-loader__panel { @@ -100,6 +120,9 @@ /* ---- Progress bar ---- */ .ac-progress { height: 6px; border-radius: var(--ac-radius-pill); background: var(--ac-color-track); overflow: hidden; } .ac-progress__bar { height: 100%; width: 0%; border-radius: var(--ac-radius-pill); background: var(--ac-color-primary); transition: width 0.3s ease; } +/* A bar measured against a cap: green when it is reached, red when it is exceeded. */ +.ac-progress--success .ac-progress__bar { background: var(--ac-color-success); } +.ac-progress--danger .ac-progress__bar { background: var(--ac-color-danger); } /* ---- Table ---- */ .ac-table-wrap { diff --git a/design-system/css/components/key.css b/design-system/css/components/key.css new file mode 100644 index 00000000..6b3b4630 --- /dev/null +++ b/design-system/css/components/key.css @@ -0,0 +1,100 @@ +/* + * Keys — a row of toggle buttons drawn as keys: raised when off, pressed down and filled when on, flat and faded + * when disabled. + * Used for the 1st, 2nd and 3rd card that take an illustration (alt arts), one row above each illustration. + *
+ * + * … + *
+ * Docs: design-system/docs/components/key.md + */ +.ac-keys { + display: grid; + grid-template-columns: repeat(var(--ac-keys-count, 3), minmax(0, 1fr)); + gap: var(--ac-space-1-5); +} +.ac-key { + --ac-key-height: var(--ac-control-lg); + --ac-key-border: 1.5px; + --ac-key-edge: 4px; + box-sizing: border-box; + height: var(--ac-key-height); + min-width: 0; + margin: 0; + padding: 0 var(--ac-space-1); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--ac-space-0-5); + border: var(--ac-key-border) solid var(--ac-color-border-control); + border-bottom: var(--ac-key-edge) solid var(--ac-color-border-dashed); + border-radius: var(--ac-radius-control); + background: var(--ac-color-surface); + color: var(--ac-color-text-2); + font-family: var(--ac-font-family); + cursor: pointer; + transition: + opacity var(--ac-duration-fast), + color var(--ac-duration-fast), + border-color var(--ac-duration-fast), + transform var(--ac-duration-fast); +} +.ac-key--sm { + --ac-key-height: var(--ac-control-md); +} +/* Pressed: the key goes down by its edge, filled. */ +.ac-key[aria-pressed='true'] { + height: calc(var(--ac-key-height) - var(--ac-key-edge) + var(--ac-key-border)); + margin-top: calc(var(--ac-key-edge) - var(--ac-key-border)); + border-color: var(--ac-color-primary-strong); + border-bottom-width: var(--ac-key-border); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); + box-shadow: inset 0 2px 4px var(--ac-color-overlay-label); + opacity: 1; + cursor: default; +} +.ac-key:not([aria-pressed='true']):not(:disabled):active { + transform: translateY(2px); +} +/* Disabled: flat (no edge), no fill, faded: it cannot be pressed. */ +.ac-key:disabled:not([aria-pressed='true']) { + border-bottom-width: var(--ac-key-border); + border-color: var(--ac-color-border); + background: transparent; + color: var(--ac-color-text-disabled); + opacity: 0.6; + cursor: not-allowed; +} +.ac-key:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + opacity: 1; +} +@media (hover: hover) { + .ac-key:not([aria-pressed='true']):not(:disabled):hover { + border-color: var(--ac-color-primary); + color: var(--ac-color-primary); + opacity: 1; + transform: translateY(-1px); + } +} +.ac-key__num { + font-size: var(--ac-font-size-body-strong); + font-weight: 800; + line-height: 1; +} +.ac-key__num sup { + font-size: 0.62em; + line-height: 0; +} +.ac-key__word { + font: var(--ac-font-micro); +} +/* A single key with a word only (« Choisir » / « Choisie »). */ +.ac-key__label { + font: var(--ac-font-body-strong); +} diff --git a/design-system/css/components/navigation.css b/design-system/css/components/navigation.css index 92c620cd..400219f2 100644 --- a/design-system/css/components/navigation.css +++ b/design-system/css/components/navigation.css @@ -72,7 +72,9 @@ color: var(--ac-color-on-inverse); font-weight: 700; } -.ac-tabs__count { color: var(--ac-color-text-muted); font-weight: 600; } +.ac-tabs__count { color: var(--ac-color-text-muted); font-weight: 600; font-variant-numeric: tabular-nums; } +/* A count still loading (« … ») and a count of up to three digits take the same width: the next tabs do not move. */ +.ac-tabs__count-value { display: inline-block; min-width: 3ch; } /* ---- Breadcrumb ---- */ .ac-breadcrumb { @@ -166,3 +168,76 @@ background: var(--ac-color-divider); border: 0; } + +/* + * Contents (table of contents): panel with a list of in-page links, one level of sub-links. + * + * Docs: design-system/docs/components/navigation.md + */ +.ac-toc { + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); + padding: var(--ac-space-5) var(--ac-space-6); +} +.ac-toc--sticky { + position: sticky; + top: calc(var(--ac-sticky-top) + var(--ac-space-4)); +} +.ac-toc__title { + display: flex; + align-items: center; + justify-content: center; + gap: var(--ac-space-2); + margin: 0 0 var(--ac-space-3); + font: var(--ac-font-overline); + text-transform: uppercase; + letter-spacing: var(--ac-letter-spacing-overline); + color: var(--ac-color-text-muted); +} +.ac-toc__list, +.ac-toc__sub { + list-style: none; + margin: 0; + padding: 0; +} +.ac-toc__list > li { margin-bottom: var(--ac-space-1-5); } +.ac-toc__list a { + color: var(--ac-color-text-2); + font: var(--ac-font-body); + font-weight: 500; + text-decoration: none; +} +.ac-toc__list a:focus-visible { color: var(--ac-color-primary-strong); text-decoration: underline; } +@media (hover: hover) { + .ac-toc__list a:hover { color: var(--ac-color-primary-strong); text-decoration: underline; } +} +/* Section being read. */ +.ac-toc__list a[aria-current] { color: var(--ac-color-primary-strong); font-weight: 800; } + +/* Sub-links under an item (the phases of a turn). */ +.ac-toc__sub { padding: var(--ac-space-1-5) 0 var(--ac-space-0-5); } +.ac-toc__sub > li { margin-bottom: var(--ac-space-1); } +.ac-toc__sub a { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); + color: var(--ac-color-text-muted); + font: var(--ac-font-small); +} + +/* Numbered items (rules, where "see §8" is a real reference). */ +.ac-toc--numbered .ac-toc__list { counter-reset: ac-toc; } +.ac-toc--numbered .ac-toc__list > li { + counter-increment: ac-toc; + position: relative; + padding-left: var(--ac-space-8); +} +.ac-toc--numbered .ac-toc__list > li::before { + content: counter(ac-toc, decimal-leading-zero); + position: absolute; + left: 0; + top: var(--ac-space-0-5); + font: var(--ac-font-micro); + color: var(--ac-color-primary); +} diff --git a/design-system/css/components/tooltip.css b/design-system/css/components/tooltip.css new file mode 100644 index 00000000..164571cc --- /dev/null +++ b/design-system/css/components/tooltip.css @@ -0,0 +1,82 @@ +/* + * AlteredCore design system — tooltip and masked value. + * + * ac-tooltip: a short text shown next to its host on hover (devices that can hover), and on focus (keyboard, or a tap + * on touch screens, where the host takes focus because it has tabindex="0"). + * ac-masked: a value shown partly hidden (an e-mail address), the full value in a tooltip. + */ + +.ac-tooltip-host { + position: relative; +} + +.ac-tooltip { + position: absolute; + left: 50%; + bottom: calc(100% + var(--ac-space-2)); + z-index: var(--ac-z-overlay); + transform: translateX(-50%); + padding: var(--ac-space-1-5) var(--ac-space-2-5); + border-radius: var(--ac-radius-sm); + background: var(--ac-color-inverse); + color: var(--ac-color-on-inverse); + box-shadow: var(--ac-shadow-e2); + font: var(--ac-font-small); + font-weight: 400; + white-space: nowrap; + pointer-events: none; + opacity: 0; + visibility: hidden; + transition: opacity var(--ac-duration-fast), visibility var(--ac-duration-fast); +} + +/* Arrow pointing at the host. */ +.ac-tooltip::after { + content: ''; + position: absolute; + top: 100%; + left: 50%; + transform: translateX(-50%); + border: var(--ac-space-1-5) solid transparent; + border-top-color: var(--ac-color-inverse); +} + +/* Aligned on the host's start edge instead of centred: a host near the left edge of a narrow box. */ +.ac-tooltip--start { + left: 0; + transform: none; +} +.ac-tooltip--start::after { + left: var(--ac-space-3); +} + +/* Under the host: a host at the top of a box that clips its content (a scrolling menu). */ +.ac-tooltip--below { + top: calc(100% + var(--ac-space-2)); + bottom: auto; +} +.ac-tooltip--below::after { + top: auto; + bottom: 100%; + border-top-color: transparent; + border-bottom-color: var(--ac-color-inverse); +} + +.ac-tooltip-host:focus .ac-tooltip, +.ac-tooltip-host:focus-within .ac-tooltip { + opacity: 1; + visibility: visible; +} + +@media (hover: hover) { + .ac-tooltip-host:hover .ac-tooltip { + opacity: 1; + visibility: visible; + } +} + +.ac-masked { + display: inline-block; + border-bottom: 1px dashed var(--ac-color-border-dashed); + cursor: help; +} diff --git a/design-system/docs/components/README.md b/design-system/docs/components/README.md index 18390faf..b37baf8d 100644 --- a/design-system/docs/components/README.md +++ b/design-system/docs/components/README.md @@ -11,11 +11,13 @@ One page per component family. Each page matches one file of `css/components/` ( | [field.md](field.md) | `field.css` | Field wrapper, label, hint, error, input, textarea, input with icon, select, checkbox / radio, radio card, file input, switch | | [listbox.md](listbox.md) | `listbox.css`, `js/ac.js` | Select drawn by the design system: search, groups, colour dots | | [segmented.md](segmented.md) | `segmented.css` | Segmented control | +| [key.md](key.md) | `key.css` | Keys: toggle buttons raised when off, pressed down when on (alt-art cards) | | [chips.md](chips.md) | `chips.css` | Chip, badge, tag, count, avatar | | [card.md](card.md) | `card.css` | Card, list, empty state, collapsible | | [drawer.md](drawer.md) | `drawer.css` | Drawer handle and tab: hide and show a side panel | -| [navigation.md](navigation.md) | `navigation.css` | Tabs, breadcrumb, pagination, menu | +| [navigation.md](navigation.md) | `navigation.css` | Tabs, breadcrumb, pagination, menu, contents (`ac-toc`) | | [feedback.md](feedback.md) | `feedback.css` | Notice, toast, scrim, dialog, spinner, blocking loader, progress bar, table | +| [tooltip.md](tooltip.md) | `tooltip.css` | Tooltip, masked value (e-mail address shown partly hidden); `ac_masked_email()` | | [bootstrap-bridge.md](bootstrap-bridge.md) | `bridges/bootstrap.css` | Which Bootstrap classes are restyled, and when to use them | Tokens (colours, type scale, spacing, radii, shadows, density, layout, z-index): diff --git a/design-system/docs/components/chips.md b/design-system/docs/components/chips.md index 9f7d68fd..0edc572e 100644 --- a/design-system/docs/components/chips.md +++ b/design-system/docs/components/chips.md @@ -51,7 +51,8 @@ Active filter with a remove button (the whole chip removes the filter): ## Badge — `ac-badge` Pill, 24 px tall (`ac-badge--lg`: 28 px), 12 px bold text, soft background with the matching -text colour. +text colour. `ac-badge--dense` tightens the padding and the gap, for a row of badges where room is short +(the rarities in a phone's app bar). ```php diff --git a/design-system/docs/components/feedback.md b/design-system/docs/components/feedback.md index 1afa7aa6..b9612a37 100644 --- a/design-system/docs/components/feedback.md +++ b/design-system/docs/components/feedback.md @@ -1,7 +1,7 @@ # Feedback and data -`css/components/feedback.css`: notice, toast, scrim, dialog, spinner, blocking loader, progress -bar, table. +`css/components/feedback.css`: notice, toast, scrim, dialog, spinner, skeleton, blocking loader, +progress bar, table. | Component | Use | Not for | |---|---|---| @@ -10,6 +10,7 @@ bar, table. | Dialog `ac-dialog` | A task or a confirmation that needs an answer before going on. | Long content or navigation (use a page). | | Scrim `ac-scrim` | The dimmed layer behind a dialog, drawer or loader. | — | | Spinner `ac-spinner` | A short wait inside a component (button, panel). | Waits the user should not interrupt (use the loader). | +| Skeleton `ac-skeleton` | Content that is loading, in the place and shape it will take: the page stays readable and nothing jumps when it arrives. | An empty state or a « 0 »: never show them before the data is there. | | Loader `ac-loader` | A blocking wait with a message (import, generation), optionally with progress. | Background loading of part of a page. | | Progress `ac-progress` | Known progress of a task (0–100 %). | Unknown duration (use the spinner). | | Table `ac-table` | Tabular data with columns to compare. | Layout; lists of items with one or two values (use a [list](card.md)). | @@ -137,6 +138,41 @@ openConfirm(this.overlay, { ``` +## Skeleton — `ac-skeleton` + +A track-coloured shape that pulses (still with `prefers-reduced-motion`). It takes the size the +layout gives it: width 100 % by default (or `--ac-skeleton-width`), height from the variant, the +layout or the content it replaces. Mark the loading region `aria-busy="true"` and give it a text +for screen readers; the shapes themselves are `aria-hidden`. + +```php +
+ + + + +
+ + +
+
+``` + +| Variant | Shape | +|---|---| +| (none) | A block, 8 px radius; the layout sets its height. | +| `ac-skeleton--text` | A line of body text (0.8 em, pill). | +| `ac-skeleton--title` | A heading line (1.1 em, pill). | +| `ac-skeleton--control` | A field or a button (`--ac-control-md`). | +| `ac-skeleton--control-sm` | A small button (`--ac-control-sm`). | +| `ac-skeleton--badge` | A badge (`ac-badge`: `--ac-badge-height`, pill); `--ac-skeleton-width` sets its width. | +| `ac-skeleton--circle` | An avatar or an icon button (`--ac-control-md` square, round). | +| `ac-skeleton--card` | A card illustration (5 : 7, 12 px radius). | +| `ac-skeleton--panel` | A card or panel (14 px radius); the layout sets its height. | + +SPA pages: the plugin's manifest `placeholder` renders a skeleton of the page from the server, +shown until the plugin draws its first screen (see `plugins/README.html`). + ## Blocking loader — `ac-loader` A scrim with a centred panel: spinner, label and an optional progress bar. Put `ac-loader` on @@ -183,6 +219,14 @@ over 0.3 s. ``` +A bar measured against a cap (cards of a rarity against the format's maximum) takes a tone: +`ac-progress--success` when the cap is reached, `ac-progress--danger` when it is exceeded. + +| Class | Use | +|---|---| +| `ac-progress--success` | Green bar: the cap is reached. | +| `ac-progress--danger` | Red bar: the cap is exceeded. | + ## Table — `ac-table` Wrap the table in `ac-table-wrap` (border, 14 px radius, surface, horizontal scroll on narrow diff --git a/design-system/docs/components/key.md b/design-system/docs/components/key.md new file mode 100644 index 00000000..6bc0725d --- /dev/null +++ b/design-system/docs/components/key.md @@ -0,0 +1,57 @@ +# Keys + +`css/components/key.css`. + +A row of toggle buttons drawn as keys. A key that is off is raised (thick bottom edge); a key that +is on is pressed down and filled in primary; a disabled key is flat and faded. Several keys of a row +can be on at once. + +Used for alt arts: above each illustration of a card, its 1st, 2nd and 3rd card. A key on is a +card that takes this illustration; pressing another key gives it that card. A token has a single +key (« Choisir » / « Choisie »). + +Use a [segmented control](segmented.md) instead for one choice among a few options, and a +[switch](field.md) for a setting that is on or off. + +## Markup + +```php +
+ + + +
+ +
+ +
+``` + +| Class / attribute | Role | +|---|---| +| `ac-keys` | Grid of equal keys, `--ac-space-1-5` apart. `--ac-keys-count` sets the number of columns (3 by default). | +| `ac-key` | A key, `--ac-control-lg` tall (52 px in touch density), raised by a 4 px bottom edge in `--ac-color-border-dashed`, text in `--ac-color-text-2`. | +| `ac-key--sm` | Key at `--ac-control-md` (lists with many rows). | +| `aria-pressed="true"` | Pressed down by its edge, `--ac-color-primary` with an inner shadow, full opacity. | +| `:disabled` | Flat (no edge), no fill, `--ac-color-border` and `--ac-color-text-disabled` at 60 % opacity: clearly apart from a key that can be pressed. | +| `ac-key__num` | Number, `--ac-font-size-body-strong`, its `` (ordinal suffix) at 0.62 em. | +| `ac-key__word` | Word under the number (« carte »), `--ac-font-micro`. | +| `ac-key__label` | Single word instead of number and word (« Choisir »), `--ac-font-body-strong`. | + +Hover (pointer devices only): border and text in primary, the key rises by 1 px. +A pressed key goes down by 2 px while the button is held. + +## Accessibility + +- Real ` + +
+ +
+

diff --git a/includes/page-lang.php b/includes/page-lang.php new file mode 100644 index 00000000..1fd3ed1c --- /dev/null +++ b/includes/page-lang.php @@ -0,0 +1,33 @@ +.json` next to `en.json`; no code change. + +/** + * Texts of a page in the visitor's language. + * + * @param string $dir Folder holding en.json, fr.json… + * @return array key => text + */ +function pageTexts(string $dir): array { + $load = function (string $lang) use ($dir): array { + $file = $dir . '/' . $lang . '.json'; + if (!preg_match('/^[a-z]{2}$/', $lang) || !is_file($file)) return []; + $data = json_decode((string)file_get_contents($file), true); + return is_array($data) ? $data : []; + }; + $texts = $load('en'); + $lang = getLang(); + if ($lang !== 'en') $texts = array_merge($texts, $load($lang)); + return $texts; +} + +/** Escaped text for a key; the key itself when it exists in no file (makes a typo visible). */ +function pageText(array $texts, string $key): string { + return h($texts[$key] ?? $key); +} + +/** Escaped text where **word** becomes word (the only markup a text file may carry). */ +function pageRich(array $texts, string $key): string { + return preg_replace('/\*\*(.+?)\*\*/u', '$1', h($texts[$key] ?? $key)); +} diff --git a/includes/spa.php b/includes/spa.php index ea0bf40b..a403cc27 100644 --- a/includes/spa.php +++ b/includes/spa.php @@ -13,7 +13,12 @@ // "base": "browser/", directory of the files below, relative to the manifest // "js": ["main-HASH.js"], ES modules, loaded in order // "css": ["embed-HASH.css"], loaded inside the shadow root -// "documentCss": ["doc-HASH.css"] loaded in (@font-face does not work in a shadow root) +// "documentCss": ["doc-HASH.css"], loaded in (@font-face does not work in a shadow root) +// "preload": { optional: modules the page needs before it can draw, requested together +// "*": ["chunk-HASH.js"], () so the browser fetches them with the entry +// "{slug}": [...], modules instead of discovering them import after import: "*" on every +// "lang:{en|fr}": [...] page, "{slug}" on the page served at that slug, "lang:…" in that language +// } // } const SPA_HOST_CONTRACT_VERSION = 1; @@ -48,7 +53,16 @@ function spaReadBuildManifest(string $pluginDir, string $entry, ?string &$error $files[$key][] = $f; } } - return ['base' => $baseRel, 'files' => $files]; + // Preloads are hints: an unusable entry is dropped, never an error. + $preload = []; + foreach ((is_array($m['preload'] ?? null) ? $m['preload'] : []) as $key => $list) { + if (!is_string($key) || !is_array($list)) continue; + foreach ($list as $f) { + $f = is_string($f) && substr($f, -3) === '.js' ? spaSafeRelPath($f) : null; + if ($f !== null) $preload[$key][] = $f; + } + } + return ['base' => $baseRel, 'files' => $files, 'preload' => $preload]; } /** @@ -63,13 +77,14 @@ function spaResolvePage(array $plugin, array $page): array { if (empty($page['entry'])) $error = 'missing "entry" in plugin.json'; $spa = ['mount' => ($page['mount'] ?? 'shadow') === 'light' ? 'light' : 'shadow', 'error' => $error, - 'assets_url' => '', 'js' => [], 'css' => [], 'document_css' => []]; + 'assets_url' => '', 'js' => [], 'css' => [], 'document_css' => [], 'preload' => []]; if ($build !== null) { $assets = BASE_URL . '/plugins/' . rawurlencode($id) . '/' . ($build['base'] !== '' ? $build['base'] . '/' : ''); $spa['assets_url'] = $assets; $spa['js'] = array_map(fn($f) => $assets . $f, $build['files']['js']); $spa['css'] = array_map(fn($f) => $assets . $f, $build['files']['css']); $spa['document_css'] = array_map(fn($f) => $assets . $f, $build['files']['documentCss']); + $spa['preload'] = array_map(fn($list) => array_map(fn($f) => $assets . $f, $list), $build['preload']); } return [ @@ -84,6 +99,7 @@ function spaResolvePage(array $plugin, array $page): array { 'title_fr' => $page['title_fr'] ?? '', 'fullwidth' => !array_key_exists('fullwidth', $page) || !empty($page['fullwidth']), 'meta_file' => spaMetaFile($plugin, $page), + 'placeholder_file' => spaPluginFile($plugin, $page['placeholder'] ?? null), // Base href of the client routes (pluginFindBetaPage(): /pages/, the routes being the slugs it takes over) 'base_path' => BASE_URL . '/pages/' . $slug . '/', 'spa' => $spa, @@ -95,7 +111,12 @@ function spaResolvePage(array $plugin, array $page): array { * before the header, so a client route can set its title and link preview ($pageTitle, $pageDescription, $pageImage). */ function spaMetaFile(array $plugin, array $page): ?string { - $rel = isset($page['meta']) && is_string($page['meta']) ? spaSafeRelPath($page['meta']) : null; + return spaPluginFile($plugin, $page['meta'] ?? null); +} + +/** Absolute path of a file named by the manifest, relative to the plugin; null when absent or outside the plugin. */ +function spaPluginFile(array $plugin, $path): ?string { + $rel = is_string($path) ? spaSafeRelPath($path) : null; if ($rel === null) return null; $abs = $plugin['_dir'] . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $rel); return is_file($abs) ? $abs : null; @@ -178,6 +199,66 @@ function spaHostConfig(array $page): array { ]; } +/** + * Manifest `placeholder`: what the page shows while its scripts load (a skeleton of the page), rendered by the server + * inside the mount point with $slug (the requested page) and $subPath (client route) in scope, the query in $_GET. The + * runtime keeps it until the plugin draws its first screen (js/altered-core-host.js). Without one, a generic skeleton. + */ +function spaRenderPlaceholder(array $page): void { + $label = getUiLang() === 'fr' ? 'Chargement…' : 'Loading…'; + echo '

' . h($label) . ''; + if (!empty($page['placeholder_file'])) { + (function (string $slug, string $subPath) use ($page) { + include $page['placeholder_file']; + })($page['slug'], (string)($page['sub_path'] ?? '')); + } else { + echo ''; + } + echo '
'; +} + +/** + * What the page requests before the runtime or a module asks for it (spaRenderPage(), right after the placeholder): the + * entry modules and the manifest's `preload` modules for this page (slug and language) as , + * the shadow root's stylesheets as . + * Returns [['href' => …, 'as' => 'module' | 'style'], …], empty for a page that cannot render. + */ +function spaPreloads(array $page): array { + $spa = $page['spa']; + if ($spa['error'] !== null) return []; + $modules = $spa['js']; + foreach (['*', $page['slug'], 'lang:' . getUiLang()] as $key) { + $modules = array_merge($modules, $spa['preload'][$key] ?? []); + } + $out = []; + foreach (array_values(array_unique($modules)) as $href) $out[] = ['href' => $href, 'as' => 'module']; + if ($spa['mount'] === 'shadow') { + foreach ($spa['css'] as $href) $out[] = ['href' => $href, 'as' => 'style']; + } + return $out; +} + +/** Id of the page's mount point (spaRenderPage()), which holds the server's placeholder; null for a page that cannot render. */ +function spaMountId(array $page): ?string { + return $page['spa']['error'] === null ? 'ac-spa-' . $page['plugin_id'] : null; +} + +/** + * Id of an empty element right after the mount point (spaRenderPage()), named by the header's + * : the browser paints nothing before it has parsed it, so the first frame is the + * page's skeleton, not the site header over an empty page (the site's menus come first in the HTML). After the mount + * point, not the mount point itself: Chromium paints as soon as the expected element is inserted, and the parser may + * stop inside the placeholder (about 1 load in 25 showed the header alone). Null for a page that cannot render. + */ +function spaFirstPaintId(array $page): ?string { + $mount = spaMountId($page); + return $mount === null ? null : $mount . '-parsed'; +} + /** Body of an SPA page: mount point, host contract, runtime and plugin modules. */ function spaRenderPage(array $page): void { $spa = $page['spa']; @@ -195,12 +276,21 @@ function spaRenderPage(array $page): void { $noscript = $lang === 'fr' ? 'Cette page nécessite JavaScript.' : 'This page requires JavaScript.'; ?>
-
+
+
+ + + - + .ac-spa-placeholder').forEach(function (placeholder) { + var alert = document.createElement('div'); + alert.className = 'ac-notice ac-notice--warning'; + alert.setAttribute('role', 'alert'); + alert.textContent = host.lang === 'fr' ? 'Cette page n’est pas disponible pour le moment.' : 'This page is not available right now.'; + placeholder.replaceWith(alert); + }); + }, true); + // Sticky offset for plugin layouts: the site header is sticky at the top of the page. var header = document.querySelector('.site-header'); function syncHeaderHeight() { diff --git a/js/discover.js b/js/discover.js new file mode 100644 index 00000000..73744f8e --- /dev/null +++ b/js/discover.js @@ -0,0 +1,331 @@ +// Discover page: contents disclosure (narrow screens) and highlight of the section being read. +(function () { + 'use strict'; + + var toc = document.getElementById('discover-toc'); + if (!toc) return; + + var toggle = toc.querySelector('.discover-toc__toggle'); + var links = Array.prototype.slice.call(toc.querySelectorAll('.ac-toc__list a')); + + if (toggle) { + toggle.addEventListener('click', function () { + toggle.setAttribute('aria-expanded', toggle.getAttribute('aria-expanded') === 'true' ? 'false' : 'true'); + }); + // Picking a section closes the disclosure + links.forEach(function (a) { + a.addEventListener('click', function () { toggle.setAttribute('aria-expanded', 'false'); }); + }); + } + + function setCurrent(id) { + links.forEach(function (a) { + if (a.getAttribute('href') === '#' + id) a.setAttribute('aria-current', 'true'); + else a.removeAttribute('aria-current'); + }); + } + + var sections = links + .map(function (a) { return document.getElementById(a.getAttribute('href').slice(1)); }) + .filter(Boolean); + if (!sections.length || !('IntersectionObserver' in window)) return; + + // The current section is the first one crossing the band between 25% and 40% from the top. + var visible = {}; + var observer = new IntersectionObserver(function (entries) { + entries.forEach(function (e) { visible[e.target.id] = e.isIntersecting; }); + for (var i = 0; i < sections.length; i++) { + if (visible[sections[i].id]) { setCurrent(sections[i].id); return; } + } + }, { rootMargin: '-25% 0px -60% 0px' }); + sections.forEach(function (s) { observer.observe(s); }); +})(); + +// Gameplay loop: lights up the phase of the Day the video is showing; still image under reduced motion. +(function () { + 'use strict'; + + var video = document.querySelector('.discover-visual__video'); + if (!video) return; + + if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + video.removeAttribute('autoplay'); + video.pause(); + video.controls = false; + return; + } + + // data-phases="afternoon:0,dusk:12,night:18": where each phase starts, in seconds + var marks = (video.getAttribute('data-phases') || '').split(',').map(function (m) { + var p = m.split(':'); + return { key: p[0], at: parseFloat(p[1]) }; + }).filter(function (m) { return m.key && !isNaN(m.at); }); + var items = {}; + Array.prototype.forEach.call(document.querySelectorAll('.discover-day__item[data-phase]'), function (li) { + items[li.getAttribute('data-phase')] = li; + }); + + // data-cues="0,2,10,...": where each caption starts, in seconds (the captions are the list items after the video) + var cues = document.querySelector('.discover-visual__cues'); + var starts = (video.getAttribute('data-cues') || '').split(',').map(parseFloat); + var lines = cues ? Array.prototype.slice.call(cues.children) : []; + if (lines.length) cues.classList.add('is-live'); + var line = -1; + + var current = null; + var holding = false; // the hold on the first frame: no caption, no phase lit yet + function update() { + if (holding) return; + var n = -1; + for (var j = 0; j < starts.length; j++) if (video.currentTime >= starts[j]) n = j; + if (n !== line) { + if (lines[line]) lines[line].removeAttribute('data-active'); + if (lines[n]) lines[n].setAttribute('data-active', ''); + // The same caption beside the video, under its phase (shown on wide screens only) + Array.prototype.forEach.call(document.querySelectorAll('.discover-day__cue'), function (c) { + if (parseInt(c.getAttribute('data-cue'), 10) === n + 1) c.setAttribute('data-active', ''); + else c.removeAttribute('data-active'); + }); + line = n; + } + var key = null; + for (var i = 0; i < marks.length; i++) if (video.currentTime >= marks[i].at) key = marks[i].key; + if (key === current) return; + if (current && items[current]) items[current].removeAttribute('aria-current'); + if (key && items[key]) items[key].setAttribute('aria-current', 'step'); + current = key; + } + video.addEventListener('timeupdate', update); + + // Slower than the capture and a hold on the first and last frames, so the captions can be read. + // Without the script the video simply loops (the loop attribute). + var rate = parseFloat(video.getAttribute('data-rate')) || 1; + var holdStart = (parseFloat(video.getAttribute('data-hold-start')) || 0) * 1000; + var holdEnd = (parseFloat(video.getAttribute('data-hold-end')) || 0) * 1000; + var timer = null; + video.loop = false; + video.playbackRate = rate; + function clear() { + if (current && items[current]) items[current].removeAttribute('aria-current'); + if (lines[line]) lines[line].removeAttribute('data-active'); + Array.prototype.forEach.call(document.querySelectorAll('.discover-day__cue'), function (c) { c.removeAttribute('data-active'); }); + current = null; + line = -1; + } + function restart() { + holding = true; + video.currentTime = 0; + clear(); + clearTimeout(timer); + timer = setTimeout(function () { + holding = false; + update(); + video.playbackRate = rate; + video.play(); + }, holdStart); + } + video.addEventListener('ended', function () { + clearTimeout(timer); + timer = setTimeout(restart, holdEnd); + }); + // The first pass starts with the same hold + video.pause(); + restart(); +})(); + +// The world: faction picker (tabs, #world- hash) and the two illustrations of each faction, from the cards API: +// a Hero in its standard version, and any other card that is not a Hero, a Token or a Unique. Credits are always shown. +(function () { + 'use strict'; + + var root = document.getElementById('discover-world'); + if (!root) return; + + var API = (root.getAttribute('data-api') || '').replace(/\/$/, ''); + var CDN = (root.getAttribute('data-cdn') || '').replace(/\/$/, ''); + var LOCALE = root.getAttribute('data-locale') || 'en'; + var CREDIT = root.getAttribute('data-credit') || ''; + var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]')); + var panels = {}; + Array.prototype.forEach.call(root.querySelectorAll('[role="tabpanel"]'), function (p) { panels[p.getAttribute('data-faction')] = p; }); + + // ---- Tabs ---- + var current = ''; + function select(slug, focus) { + if (!panels[slug]) return; + var changed = slug !== current; + current = slug; + tabs.forEach(function (t) { + var on = t.getAttribute('data-faction') === slug; + t.setAttribute('aria-selected', on ? 'true' : 'false'); + t.tabIndex = on ? 0 : -1; + if (on && focus) t.focus(); + }); + Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== slug; }); + if (changed) load(slug); + } + tabs.forEach(function (t, i) { + t.addEventListener('click', function () { + select(t.getAttribute('data-faction')); + history.replaceState(null, '', '#world-' + t.getAttribute('data-faction')); + }); + t.addEventListener('keydown', function (e) { + var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (e.key === 'Home') d = -i; + if (e.key === 'End') d = tabs.length - 1 - i; + if (!d) return; + e.preventDefault(); + var n = tabs[(i + d + tabs.length) % tabs.length].getAttribute('data-faction'); + select(n, true); + history.replaceState(null, '', '#world-' + n); + }); + }); + + // A faction can be linked to directly: #world-axiom selects it and scrolls to the section. + function fromHash(scroll) { + var m = /^#world-([a-z]+)$/.exec(location.hash); + if (!m || !panels[m[1]]) return; + select(m[1]); + if (scroll) document.getElementById('world').scrollIntoView(); + } + window.addEventListener('hashchange', function () { fromHash(true); }); + + // ---- Illustrations ---- + var TYPES = ['CHARACTER', 'SPELL', 'PERMANENT', 'LANDMARK_PERMANENT', 'EXPEDITION_PERMANENT']; + var RARITIES = ['COMMON', 'RARE', 'EXALTED']; // never UNIQUE + var pending = {}; + + function url(code, extra) { + return API + '/api/cards?locale=' + LOCALE + '&page=1&faction.code[]=' + code + '&variation[]=standard&' + extra; + } + function usable(c, code) { + return c && c.reference && c.faction && c.faction.code === code && c.artists && c.artists.length && c.artists[0].name; + } + function fetchList(u) { + return fetch(u).then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(function (d) { return d.member || []; }); + } + function pick(list) { return list.length ? list[Math.floor(Math.random() * list.length)] : null; } + + // Heroes: the cards API is slow on a type filter (30 s or more) but answers an exact reference in well under a second. + // The standard Hero cards of the core set are ALT_CORE_B__01_C to _03_C, three per faction: fetch the three, + // keep those with an artist and an effect. The other card: one request in random order, pick here. + function hero(code) { + return Promise.all(['01', '02', '03'].map(function (n) { + return fetchList(API + '/api/cards?locale=' + LOCALE + '&reference=ALT_CORE_B_' + code + '_' + n + '_C') + .then(function (l) { return l[0]; }, function () { return null; }); + })).then(function (l) { + return l.filter(function (c) { return usable(c, code) && c.cardType && c.cardType.reference === 'HERO' && c.mainEffect; }); + }); + } + function candidates(code) { + if (!pending[code]) { + var others = fetchList(url(code, 'itemsPerPage=40&random=true&' + TYPES.map(function (t) { return 'cardType[]=' + t; }).concat(RARITIES.map(function (r) { return 'rarity[]=' + r; })).join('&'))) + .then(function (l) { + return l.filter(function (c) { + return usable(c, code) && c.cardType && TYPES.indexOf(c.cardType.reference) >= 0 && c.rarity && c.rarity.reference !== 'UNIQUE'; + }); + }); + pending[code] = Promise.all([hero(code), others]).then(function (r) { return { heroes: r[0], cards: r[1] }; }); + } + return pending[code]; + } + + function draw(figure, card) { + if (!card) { figure.hidden = true; return; } + figure.hidden = false; + var img = figure.querySelector('img'); + var frame = figure.querySelector('.discover-art__frame'); + frame.classList.remove('is-loaded'); + img.hidden = true; + img.alt = card.name || ''; + img.onload = function () { frame.classList.add('is-loaded'); img.hidden = false; }; + // The site's card image CDN (as the cards page), not the API's imagePath, which points at a dev S3 bucket. + img.src = CDN + '/cards/' + LOCALE + '/' + card.reference.split('_')[1] + '/' + card.reference + '.webp'; + figure.querySelector('figcaption').textContent = CREDIT.replace('{artist}', card.artists[0].name); + } + + // Introduction tab: key art of a set, another one each time the tab is shown. + function setArt(panel) { + var fig = panel.querySelector('.discover-set'); + if (!fig) return; + var list; + try { list = JSON.parse(fig.getAttribute('data-images')); } catch (e) { return; } + var img = fig.querySelector('img'); + var others = list.filter(function (s) { return img.src.indexOf(s.src) < 0; }); + var s = pick(others.length ? others : list); + if (!s) return; + img.src = s.src; + img.alt = (fig.getAttribute('data-alt') || '{set}').replace('{set}', s.name); + fig.querySelector('figcaption').textContent = s.name; + } + + // The candidates are fetched once per faction; a new pair is drawn each time the faction is shown, + // so leaving a faction and coming back shows other cards. + function load(slug) { + var panel = panels[slug]; + if (slug === 'intro') { setArt(panel); return; } + var art = panel.querySelector('.discover-faction__art'); + candidates(panel.getAttribute('data-code')).then(function (r) { + var hero = pick(r.heroes), card = pick(r.cards); + draw(panel.querySelector('[data-slot="hero"]'), hero); + draw(panel.querySelector('[data-slot="card"]'), card); + art.classList.toggle('is-empty', !hero && !card); + }).catch(function () { + delete pending[panel.getAttribute('data-code')]; + art.classList.add('is-empty'); + }); + } + + fromHash(true); + // No hash: the faction selected in the HTML (the first one) + if (!current && tabs.length) select((document.querySelector('#discover-world [role="tab"][aria-selected="true"]') || tabs[0]).getAttribute('data-faction')); +})(); + +// Formats picker (section 4): same tab pattern as the factions; the format is kept in the URL hash (#decks-standard). +(function () { + 'use strict'; + + var root = document.getElementById('discover-formats'); + if (!root) return; + + var tabs = Array.prototype.slice.call(root.querySelectorAll('[role="tab"]')); + var panels = {}; + Array.prototype.forEach.call(root.querySelectorAll('[role="tabpanel"]'), function (p) { panels[p.getAttribute('data-format')] = p; }); + + function select(slug, focus) { + if (!panels[slug]) return; + tabs.forEach(function (t) { + var on = t.getAttribute('data-format') === slug; + t.setAttribute('aria-selected', on ? 'true' : 'false'); + t.tabIndex = on ? 0 : -1; + if (on && focus) t.focus(); + }); + Object.keys(panels).forEach(function (k) { panels[k].hidden = k !== slug; }); + } + tabs.forEach(function (t, i) { + t.addEventListener('click', function () { + select(t.getAttribute('data-format')); + history.replaceState(null, '', '#decks-' + t.getAttribute('data-format')); + }); + t.addEventListener('keydown', function (e) { + var d = e.key === 'ArrowRight' ? 1 : e.key === 'ArrowLeft' ? -1 : 0; + if (e.key === 'Home') d = -i; + if (e.key === 'End') d = tabs.length - 1 - i; + if (!d) return; + e.preventDefault(); + var n = tabs[(i + d + tabs.length) % tabs.length].getAttribute('data-format'); + select(n, true); + history.replaceState(null, '', '#decks-' + n); + }); + }); + + function fromHash(scroll) { + var m = /^#decks-([a-z_]+)$/.exec(location.hash); + if (!m || !panels[m[1]]) return; + select(m[1]); + if (scroll) document.getElementById('decks').scrollIntoView(); + } + window.addEventListener('hashchange', function () { fromHash(true); }); + fromHash(true); +})(); diff --git a/pages/_discover/lang/en.json b/pages/_discover/lang/en.json new file mode 100644 index 00000000..2f2c5d76 --- /dev/null +++ b/pages/_discover/lang/en.json @@ -0,0 +1,210 @@ +{ + "meta.title": "Discover Altered — free trading card game", + "meta.description": "A card game, free to play online, maintained by a community.", + "header.title": "Discover Altered", + "toc.label": "Contents", + "toc.toggle": "View contents", + "toc.rules": "Quick rules", + "nav.gameplay": "How a game plays", + "nav.world": "The world and its art", + "nav.play": "Ways to play", + "nav.decks": "Build your own deck", + "nav.project": "Who runs Altered now", + "gameplay.lead": "Each player leads a Hero and a Companion expedition, starting at opposite ends of a route built from Adventure cards. Play characters into an expedition, and at the end of the Day the stronger expedition moves forward. Bring your Hero and Companion together first and you win.", + "gameplay.diff.1.title": "Packed with decisions, no downtime", + "gameplay.diff.1.body": "You play a single card, then it is your opponent's turn. Take the initiative or hold back to react, thwart their plan or build your own: decisions come constantly and games stay short (20–40 minutes).", + "gameplay.diff.2.title": "Nothing is decided until the end", + "gameplay.diff.2.body": "Characters leave play at Night rather than piling up, so no one builds an unassailable lead. You can be behind in the race and still win it on the last Day.", + "gameplay.diff.3.title": "When you play a card matters as much as which", + "gameplay.diff.3.body": "Cards move to your Reserve at Night and can come back from there, possibly at a different cost and with different effects. Holding a card back, or spending it early to set up its return, changes what you can do in the coming Days.", + "gameplay.diff.4.title": "Every card is also mana", + "gameplay.diff.4.body": "Any card in hand can be placed as a Mana Orb. Every draw is a real choice, and no hand is ever dead.", + "gameplay.diff.5.title": "Unique cards", + "gameplay.diff.5.body": "Alongside the standard cards, Altered generates one-of-a-kind versions with their own abilities. No two collections look the same.", + "gameplay.diff.6.title": "Easy to learn, hard to master", + "gameplay.diff.6.body": "You will understand Altered in one game. It takes a lot longer to know which card to place as mana, anticipate what will be added to the Reserve before it overflows, and when to let a Day go.", + "gameplay.day.label": "The phases of a Day", + "gameplay.day.dawn.name": "Morning", + "gameplay.day.dawn.text": "Draw two, bury one as mana", + "gameplay.day.noon.name": "Noon", + "gameplay.day.noon.text": "Noon effects wake up", + "gameplay.day.afternoon.name": "Afternoon", + "gameplay.day.afternoon.text": "One card each, until both pass", + "gameplay.day.dusk.name": "Dusk", + "gameplay.day.dusk.text": "Compare terrain. The stronger advances", + "gameplay.day.night.name": "Night", + "gameplay.day.night.text": "The board empties. New Day", + "gameplay.visual.alt": "The Altered board: two expeditions start at opposite ends of the route and move toward each other.", + "gameplay.cue.label": "What happens in this Day", + "gameplay.cue.1": "The top player plays Reka Fisherman from their hand into the Hero expedition.", + "gameplay.cue.2": "The bottom player can't challenge the Hero expedition, so plays Kelon Elementary from their Reserve into the Companion expedition.", + "gameplay.cue.3": "Both players pass.", + "gameplay.cue.4": "Each player advances in one expedition, and could not block the opponent in the other.", + "gameplay.cue.5": "Night: the expeditions are cleared. Kelon Elementary was played from the Reserve, so it is discarded.", + "gameplay.cue.6": "Reka Fisherman was played from the hand, so it goes to the Reserve for the next Days.", + "gameplay.visual.caption": "Your Hero and your Companion start on either side of the Tumult. Bring them back together before your opponent does and the game is yours.", + "gameplay.rules.link": "Read the quick rules", + "world.lead": "A cataclysm called the Tumult reshaped the world and scattered the people who lived in it. We thought the world was lost; it had merely been transformed. Six factions have set out to rediscover it, and none of them is seeking the same thing.", + "world.chips.label": "Factions", + "world.label.lore": "Who they are", + "world.label.play": "Their playing style", + "world.link.cards": "See every {faction} card", + "world.credit": "Illustration by {artist}", + "world.human": "Every illustration in Altered is made by a human artist. We intend to keep it that way.", + "world.links.browse": "Browse all the cards", + "world.links.wiki": "Read more about the world", + "world.faction.ax.lore": "No other Faction is as keen on innovation as the Axiom. For these machinists and engineers, researchers and inventors, the world is full of wonders and opportunities to be explored and harnessed. Its members are highly pragmatic: they firmly believe that through progress — whether technical, economic, or social — and the achievements of science, humanity will continue to improve its living conditions, so harshly tested by the Tumult.", + "world.faction.ax.play": "The Axiom build. You lay down permanents turn after turn, you recycle whatever passes through your Reserve, and what looked like a laborious setup becomes a machine that no longer stops. This is the faction that wins late, provided it survives the first few Days: cut off its permanents or empty its Reserve, and the whole structure collapses. Pick it if you like placing pieces and watching them lock together.", + "world.faction.ax.name": "Axiom", + "world.faction.br.lore": "The philosophy of the Bravos is to confront the obstacles that litter their path, to meet the challenges that present themselves or that they impose on themselves in order to overcome them. They embrace danger, eagerly anticipating the opportunity to confront it and triumph. To be a Bravos is above all to break the status quo, to become better, to constantly push the limits: the boundaries and barriers that exist around them, but also their own limitations.", + "world.faction.br.play": "The Bravos charge. Two ways to do it: cheap characters with high statistics, pushing both expeditions forward at once, or extra Mana Orbs to bring out giants well ahead of schedule. Either way you set the pace of the Day and your opponent follows it. The drawback is well known: your biggest cards are ideal targets for removal, and if the game drags on, you are the one who runs out of steam.", + "world.faction.br.name": "Bravos", + "world.faction.ly.lore": "When they contemplate the world, the Lyra understand how it is expression above all else. Every living being, every thing, exhales a cohort of ideas and emotions just by existing, like fragrances, like sensory explosions that must be felt to be truly understood and appreciated... Lyra Alterers rejoice in traversing such a cosmos, as vibrant as it is intense; to be pierced by its beauty, as moving as it is relentless.", + "world.faction.ly.play": "The Lyra play with the rules. Dice rolls, characters specialised in one region type, cards that bend the shape of a Day: there is always a move your opponent did not expect, and rarely the one they were afraid of. In exchange you accept some randomness — the dice and the face-down Adventure cards can both turn on you — and you defend poorly. The faction for people who would rather surprise than control.", + "world.faction.ly.name": "Lyra", + "world.faction.mu.lore": "The Muna aspire to total alignment with their environment: a unity of mind, soul, and body that surpasses the prism of thought or reason. Only in this way can they embrace the flow of the world, becoming one with its currents... For members of the Faction, existence stems from the moment, and reality is a wave that must be ridden. For the Muna, the Tumult is a reflection of the living beings' disruption of harmony; a symptom, not a cause.", + "world.faction.mu.play": "The Muna grow. You summon Plants, you anchor them so they stay for the following Days, then you make them bigger: after a few Days your opponent runs into a wall of statistics they can no longer cross. The plan is simple and readable, which makes this an excellent first faction. Its limit is there too: you barely interfere with your opponent, and one well-timed disruption cracks the whole structure.", + "world.faction.mu.name": "Muna", + "world.faction.or.lore": "The Ordis stands as a champion of order: for its members, everything must have its place and role; every being must be an essential link in a homogeneous and efficient whole, while flourishing in this function. Similarly, for the world to regain its stability and unity of old, it must once again be governed by immutable physical laws, without fearing the permanent inconsistency of the Tumult.", + "world.faction.or.play": "The Ordis line things up. Where others look for the decisive card, you accumulate: recruits produced in series, characters whose passive effects quietly jam your opponent’s plan, and a position that solidifies Day after Day. Fast, by boosting your recruits, or slow, by stacking them up. A precise, almost surgical game, where a single point of statistics decides a Day, provided you accept the price.", + "world.faction.or.name": "Ordis", + "world.faction.yz.lore": "For the Yzmir mystics, the mind is the foundation of all things: it is the seat of ideas, and it is through it that they must be tamed. Like a blade that is sharpened, it must be honed to remain keen and penetrating. That is why the Yzmir Initiates practice meditation, develop philosophy, and spirituality. It is through them that their intellect and intuition will see beyond what the senses reveal, that their consciousness will pierce through the murky veils of reality.", + "world.faction.yz.play": "The Yzmir answer. The best removal and draw spells in the game, characters that set the ground up for them, and synergies where you sacrifice your own to reach forbidden magic. You let your opponent commit first, then take their Day apart piece by piece. The price for that: a reactive plan is only worth anything if you are holding the right answer at the right moment. Anticipation is not optional here.", + "world.faction.yz.name": "Yzmir", + "world.intro.name": "The world", + "world.intro.1.title": "The world", + "world.intro.1.body": "Five centuries ago, the imaginary world collided with ours. The Confluence gave birth to the Tumult: currents that alter everything in their path, reshaping landscapes and creatures and leaving the world impossible to cross. The survivors wandered from Oasis to Oasis before gathering at Asgartha, a stabilised peninsula that became the last home of humanity.", + "world.intro.2.title": "The reconquest", + "world.intro.2.body": "Asgartha launched the Rediscovery Endeavor: pass beyond the walls, secure the lands one by one, and trace the Tumult back to its source to calm it. The Exalts are its vanguard, and every game of Altered is one of those expeditions. Six factions are leading that reconquest, each with its own idea of what humanity should become.", + "world.intro.3.title": "The Exalts", + "world.intro.3.body": "Alteration lets you give shape to your imagination by capturing Mana — the same energy that feeds the Tumult. Those who master it set out in pairs: an Alterer, who manifests their memories and ideas, and their Alter Ego, a Chimaera born of the Tumult able to capture far more of it. Bound together, they form an Exalt. That pair is what you play.", + "world.intro.art.alt": "Key art of the set “{set}”", + "play.lead": "Two ways to play, for free.", + "play.external": "opens in a new tab", + "play.t1.title": "Online, right now", + "play.t1.step1": "Create a free Board Game Arena account.", + "play.t1.step2": "Pick a faction and its ready-made starter deck.", + "play.t1.step3": "Play the tutorial, then your first real game against a human.", + "play.t1.note": "Board Game Arena applies the rules for you, so you can learn by playing.", + "play.t1.cta": "Open Board Game Arena", + "play.t2.title": "Around a table", + "play.t2.step1": "Print proxies at home, or order printed cards from **Forgefire**, the officially approved service.", + "play.t2.step2": "Find a game night near you, or a shop already running tables.", + "play.t2.step3": "No one nearby? Start your own table. We give you the kit and the tournament tool.", + "play.t2.note": "Printing cards for your own use is fine. Forgefire guarantees quality and quantity.", + "play.t2.cta": "Find events near me", + "decks.lead": "Starter decks are solid, and they teach you the game while you enjoy yourself. But there comes a point when you want to push your own idea further than they allow. That is where deckbuilding starts: a free account, every format, and a deck that looks like nobody else’s. Build it here and it is simply there in your next Board Game Arena game. No export, no file, no copying a list across.", + "decks.feat.1.label": "Deckbuilder", + "decks.feat.1.text": "Every card from every set, legality checked live.", + "decks.feat.2.label": "Collection", + "decks.feat.2.text": "What you own, on paper and online, in one place.", + "decks.feat.3.label": "Straight onto BGA", + "decks.feat.3.text": "Already waiting for you the next time you play.", + "decks.fig.label": "The size of Altered Re:Union", + "decks.fig.cards.value": "2,800", + "decks.fig.cards.label": "cards", + "decks.fig.sets.value": "7", + "decks.fig.sets.label": "sets", + "decks.fig.formats.value": "6", + "decks.fig.formats.label": "formats", + "decks.fig.decks.value": "100+", + "decks.fig.decks.label": "community decks to start from", + "decks.fmt.label": "Formats", + "decks.fmt.what": "What it is", + "decks.fmt.who": "Who it is for", + "decks.fmt.browse": "Browse decks in this format", + "decks.fmt.row.size": "Deck size", + "decks.fmt.row.copies": "Copies per card", + "decks.fmt.row.unique": "Unique cards", + "decks.fmt.row.pool": "Card pool", + "decks.fmt.row.bga": "Played on BGA", + "decks.fmt.table": "How this format compares", + "decks.fmt.closing": "Tournaments always say which format they use.", + "decks.fmt.all": "All formats in detail", + "decks.fmt.standard.name": "Standard", + "decks.fmt.standard.what": "The reference format: a deck of at least 39 cards led by one Hero, built from any card of any set, Unique cards included.", + "decks.fmt.standard.who": "Everyone. It is the natural place to start, and the one to learn first.", + "decks.fmt.standard.size": "39 cards minimum", + "decks.fmt.standard.copies": "Up to 3", + "decks.fmt.standard.unique": "Allowed", + "decks.fmt.standard.pool": "Every set", + "decks.fmt.standard.bga": "Yes", + "decks.fmt.nuc.name": "Standard, no uniques", + "decks.fmt.nuc.what": "The Standard rules with one difference: no Unique cards. Every card in your deck is a regular card that anyone can own.", + "decks.fmt.nuc.who": "Players who want a level field, or who have not collected Uniques.", + "decks.fmt.nuc.size": "39 cards minimum", + "decks.fmt.nuc.copies": "Up to 3", + "decks.fmt.nuc.unique": "None", + "decks.fmt.nuc.pool": "Every set", + "decks.fmt.nuc.bga": "Yes", + "decks.fmt.singleton.name": "Singleton", + "decks.fmt.singleton.what": "A bigger deck of at least 59 cards, with a single copy of each card. Unique cards are allowed.", + "decks.fmt.singleton.who": "Deckbuilders who prefer variety to repetition: it plays very differently from Standard.", + "decks.fmt.singleton.size": "59 cards minimum", + "decks.fmt.singleton.copies": "1", + "decks.fmt.singleton.unique": "Allowed", + "decks.fmt.singleton.pool": "Every set", + "decks.fmt.singleton.bga": "No", + "decks.fmt.singleton_nuc.name": "Singleton, no uniques", + "decks.fmt.singleton_nuc.what": "Singleton without Unique cards: a deck of at least 59 cards, one copy of each, all regular cards.", + "decks.fmt.singleton_nuc.who": "Singleton players who want every card in the deck to be one anyone can own.", + "decks.fmt.singleton_nuc.size": "59 cards minimum", + "decks.fmt.singleton_nuc.copies": "1", + "decks.fmt.singleton_nuc.unique": "None", + "decks.fmt.singleton_nuc.pool": "Every set", + "decks.fmt.singleton_nuc.bga": "Yes", + "decks.fmt.frontier.name": "Frontier", + "decks.fmt.frontier.what": "The official Board Game Arena format. The deck-building rules are those of Standard, but Unique cards are only legal if they belong to a pool that is regularly changed, so the format keeps moving with the game.", + "decks.fmt.frontier.who": "Competitive players, and anyone who likes digging through the Uniques to find the golden nuggets.", + "decks.fmt.frontier.size": "39 cards minimum", + "decks.fmt.frontier.copies": "Up to 3", + "decks.fmt.frontier.unique": "Allowed, from a pool that is regularly changed", + "decks.fmt.frontier.pool": "Every set", + "decks.fmt.frontier.bga": "Yes, the official format", + "decks.fmt.draft.name": "Draft & Sealed", + "decks.fmt.draft.what": "You open a pool of cards and build a deck from what you get. Nobody's collection matters, so a first-timer and a veteran start level.", + "decks.fmt.draft.who": "Players who like freshness and creativity: every pool is different, so every deck is a new puzzle. It needs no collection at all.", + "decks.fmt.draft.size": "Built from your pool", + "decks.fmt.draft.copies": "As many as you open", + "decks.fmt.draft.unique": "Only if you open one", + "decks.fmt.draft.pool": "The pool you open", + "decks.fmt.draft.bga": "Yes", + "project.lead.1": "Equinox created Altered and carried it until March 2026, when the funding for its sixth set fell short. Before closing its services, the publisher released almost all of the cards, artwork and lore, and arranged the handover with Board Game Arena and the community.", + "project.lead.2": "Since 20 May, the game has been in the hands of Altered Re:Union, a French non-profit association. Elected governance, published accounts, a team of volunteers around the world: development, rules, illustration, translation and tournaments are all handled by people who play the game.", + "project.shipped.title": "What has shipped", + "project.shipped.1": "Roots of Corruption brought to Board Game Arena", + "project.shipped.2": "Frontier, a format designed by the community, playable now", + "project.shipped.3": "Refreshed starter decks", + "project.shipped.4": "Alternate arts playable on Board Game Arena again", + "project.shipped.5": "Ongoing balance updates, errata and suspensions", + "project.shipped.6": "A competitive season, with qualifiers and a 2027 World Cup", + "project.updated": "Last updated {date}.", + "project.wip.title": "What is being worked on", + "project.wip.1": "Neverending Journey on Board Game Arena", + "project.wip.2": "Card trading", + "project.wip.3": "Rules and cards in more languages", + "project.wip.4": "A public lore database", + "project.horizon": "Further out: new sets, designed by the community. We will not put a name or a date on one until there is something real to show you.", + "project.team": "Meet the team", + "project.discord": "Join us on Discord", + "project.donate": "Make a donation", + "project.external": "opens in a new tab", + "toc.faq": "FAQ", + "toc.bga": "Play on BGA", + "toc.external": "opens in a new tab", + "decks.flow.label": "How it works", + "decks.flow.once": "Once, to set up", + "decks.flow.every": "Then, every time", + "decks.flow.1.title": "Create your account", + "decks.flow.1.text": "Free, here on altered.re.", + "decks.flow.1.cta": "Create a free account", + "decks.flow.1.signin": "Already have one?", + "decks.flow.1.signin_link": "Sign in", + "decks.flow.2.title": "Link it to Board Game Arena", + "decks.flow.2.text": "From inside BGA, once and for all.", + "decks.flow.3.title": "Build your deck", + "decks.flow.3.text": "Every card, every format, legality checked as you build.", + "decks.flow.3.link": "Open the deckbuilder", + "decks.flow.4.title": "Play it on BGA", + "decks.flow.4.text": "It is already waiting for you. No export, no list to copy across." +} diff --git a/pages/_discover/lang/fr.json b/pages/_discover/lang/fr.json new file mode 100644 index 00000000..5b9df17d --- /dev/null +++ b/pages/_discover/lang/fr.json @@ -0,0 +1,210 @@ +{ + "meta.title": "Découvrir Altered — jeu de cartes à collectionner gratuit", + "meta.description": "Un jeu de cartes, gratuit en ligne, maintenu par une communauté.", + "header.title": "Découvrir Altered", + "toc.label": "Sommaire", + "toc.toggle": "Afficher le sommaire", + "toc.rules": "Règles rapides", + "nav.gameplay": "Déroulement d'une partie", + "nav.world": "Le monde et ses illustrations", + "nav.play": "Comment jouer", + "nav.decks": "Construire son deck", + "nav.project": "Qui s'occupe d'Altered", + "gameplay.lead": "Chaque joueur mène une expédition Héros et une expédition Compagnon, qui partent aux deux extrémités d'un chemin formé de cartes Aventure. Vous jouez des personnages dans une expédition, et à la fin du Jour l'expédition la plus forte avance. Réunissez votre Héros et votre Compagnon en premier et vous gagnez.", + "gameplay.diff.1.title": "Des décisions en continu, aucun temps mort", + "gameplay.diff.1.body": "Vous jouez une seule carte, puis c'est au tour de votre adversaire. Prendre l'initiative ou temporiser pour réagir, le contrecarrer ou installer votre propre plan : les décisions s'enchaînent et les parties restent courtes (20–40 minutes).", + "gameplay.diff.2.title": "Rien n'est jouer avant la fin", + "gameplay.diff.2.body": "Les personnages quittent le jeu la Nuit au lieu de s'accumuler : personne ne creuse un écart impossible à rattraper. Vous pouvez être en retard dans la course et l'emporter au dernier Jour.", + "gameplay.diff.3.title": "Le moment compte autant que la carte", + "gameplay.diff.3.body": "Les cartes rejoignent votre Réserve la Nuit et peuvent en revenir, parfois à un coût différent et avec des effets différents. Garder une carte, ou la jouer tôt pour préparer son retour, change ce que vous pourrez faire les Jours suivants.", + "gameplay.diff.4.title": "Chaque carte est aussi du mana", + "gameplay.diff.4.body": "N'importe quelle carte en main peut être placée comme Orbe de mana. Chaque pioche est un vrai choix, et aucune main n'est jamais morte.", + "gameplay.diff.5.title": "Les cartes Uniques", + "gameplay.diff.5.body": "À côté des cartes standard, Altered génère des versions uniques avec leurs propres capacités. Deux collections ne se ressemblent jamais.", + "gameplay.diff.6.title": "Facile à apprendre, difficile à maîtriser", + "gameplay.diff.6.body": "Vous comprendrez Altered en une partie. Vous mettrez bien plus longtemps à savoir quelle carte placer en mana, anticiper ce qui rejoindra la Réserve avant qu’elle ne déborde, et quand laisser filer un Jour.", + "gameplay.day.label": "Les phases d'un Jour", + "gameplay.day.dawn.name": "Matin", + "gameplay.day.dawn.text": "Piochez deux cartes, enterrez-en une", + "gameplay.day.noon.name": "Midi", + "gameplay.day.noon.text": "Les effets de Midi se déclenchent", + "gameplay.day.afternoon.name": "Après-midi", + "gameplay.day.afternoon.text": "Une carte chacun, jusqu'au passe", + "gameplay.day.dusk.name": "Crépuscule", + "gameplay.day.dusk.text": "Comparez les terrains. Le plus fort avance", + "gameplay.day.night.name": "Nuit", + "gameplay.day.night.text": "Le plateau se vide. Nouveau Jour", + "gameplay.visual.alt": "Le plateau d'Altered : deux expéditions partent aux deux extrémités du chemin et avancent l'une vers l'autre.", + "gameplay.cue.label": "Ce qui se passe pendant ce Jour", + "gameplay.cue.1": "Le joueur du haut joue Pêcheur Reka depuis sa main dans l'expédition Héros.", + "gameplay.cue.2": "Le joueur du bas ne peut pas contester l'expédition Héros : il joue Élémentaire de Kélon depuis sa Réserve dans l'expédition Compagnon.", + "gameplay.cue.3": "Les deux joueurs passent.", + "gameplay.cue.4": "Chaque joueur avance dans une expédition, et n'a pas pu bloquer son adversaire dans l'autre.", + "gameplay.cue.5": "La Nuit : les expéditions sont vidées. Élémentaire de Kélon a été jouée depuis la Réserve, elle est donc défaussée.", + "gameplay.cue.6": "Pêcheur Reka a été jouée depuis la main, elle va donc en Réserve pour les prochains Jours.", + "gameplay.visual.caption": "Votre Héros et votre Compagnon partent de part et d’autre du Tumulte. Réunissez-les avant votre adversaire et la partie est gagnée.", + "gameplay.rules.link": "Lire les règles rapides", + "world.lead": "Un cataclysme appelé le Tumulte a bouleversé le monde et dispersé celles et ceux qui l'habitaient. On a cru le monde perdu ; il n’était que transformé. Six factions sont parties le réapprendre, et aucune n’y cherche la même chose.", + "world.chips.label": "Factions", + "world.label.lore": "Qui ils sont", + "world.label.play": "Leur style de jeu", + "world.link.cards": "Voir toutes les cartes {faction}", + "world.credit": "Illustration de {artist}", + "world.human": "Chaque illustration d'Altered est réalisée par un artiste humain. Nous comptons bien que cela continue.", + "world.links.browse": "Parcourir toutes les cartes", + "world.links.wiki": "En savoir plus sur le monde", + "world.faction.ax.lore": "Nulle autre Faction n'est aussi friande d'innovation que les Axiom. Pour ces machinistes et ces ingénieurs, ces chercheurs et ces inventeurs, le monde regorge de merveilles et d'opportunités à exploiter et à apprivoiser. Ses membres sont hautement pragmatiques : ils croient fermement qu'à travers le progrès — qu'il soit technique, économique ou social — et les prouesses de la science, l'humanité ne cessera d'améliorer ses conditions de vie si rudement mises à l'épreuve par le Tumulte.", + "world.faction.ax.play": "Les Axiom construisent. Vous posez des permanents tour après tour, vous recyclez ce qui passe par votre Réserve, et ce qui ressemblait à une mise en place laborieuse devient une machine qui ne s’arrête plus. C’est la faction qui gagne tard, à condition de survivre aux premiers Jours : coupez-lui ses permanents ou videz sa Réserve, et tout l’édifice s’écroule. À choisir si vous aimez poser des pièces et les voir s’emboîter.", + "world.faction.ax.name": "Axiom", + "world.faction.br.lore": "La philosophie des Bravos consiste à se confronter aux obstacles qui émaillent leur route, à aller au-devant des challenges qui se présentent à eux ou qu'ils s'imposent dans le but de les surmonter. Ils appellent le danger, trépignent d'impatience de se mesurer à lui et de triompher. Être Bravos, c'est avant tout briser le statu quo, devenir meilleur, repousser sans cesse les limites : les frontières et les barrières qui existent autour d'eux, mais aussi leurs propres limitations.", + "world.faction.br.play": "Les Bravos foncent. Deux voies pour cela : des personnages peu chers aux grosses statistiques, qui poussent les deux expéditions en même temps, ou des Orbes de mana supplémentaires pour sortir des colosses bien avant l’heure. Dans les deux cas vous dictez le rythme et l’adversaire subit. Le revers est connu : vos grosses cartes sont des cibles idéales, et si la partie s’éternise, vous manquez de ressources.", + "world.faction.br.name": "Bravos", + "world.faction.ly.lore": "Lorsqu'ils contemplent le monde, les Lyra comprennent à quel point il n'est qu'expression. Chaque être vivant, chaque chose exhale rien qu'en existant une cohorte d'idées et d'émotions, telles des fragrances, telles des explosions sensorielles qui se doivent d'être ressenties pour être comprises et appréciées à leur juste valeur... Les Altérateurs Lyra se réjouissent d'arpenter un tel cosme, aussi vibrant qu'intense ; de se laisser transpercer par sa beauté, aussi émouvante qu'impitoyable.", + "world.faction.ly.play": "Les Lyra jouent avec les règles. Jets de dés, personnages spécialisés dans un type de région, cartes qui tordent le déroulement du Jour : il y a toujours un coup que l’adversaire n’attendait pas, et rarement celui qu’il redoutait. En échange, vous acceptez une part de hasard — le dé comme les cartes Aventure face cachée peuvent vous trahir — et vous défendez mal. La faction de ceux qui préfèrent surprendre plutôt que contrôler.", + "world.faction.ly.name": "Lyra", + "world.faction.mu.lore": "Les Muna aspirent à une totale adéquation avec leur environnement : une unité d'esprit, d'âme et de corps qui dépasse le prisme de la pensée ou de la raison. C'est ainsi qu'ils pourront embrasser le flux du monde, ne faire qu'un avec ses courants... Pour les membres de la Faction, l'existence puise ses racines dans l'instant, et la réalité est une vague qu'il faut savoir chevaucher. Pour les Muna, le Tumulte est le reflet de la rupture d'harmonie des êtres ; un symptôme, et non une cause.", + "world.faction.mu.play": "Les Muna font pousser. Vous invoquez des Plantes, vous les ancrez pour qu’elles restent les Jours suivants, puis vous les faites grandir : au bout de quelques Jours, l’adversaire se heurte à un mur de statistiques qu’il ne peut plus franchir. Le plan est simple et lisible, ce qui en fait une excellente première faction. Sa limite est là aussi : vous gênez peu l’adversaire, et une perturbation bien placée fissure tout l’édifice.", + "world.faction.mu.name": "Muna", + "world.faction.or.lore": "L'Ordis s'érige en champion de l'ordre : pour ses membres, chaque chose doit avoir la place et le rôle qui lui sont adaptés ; chaque être doit constituer un maillon essentiel d'un ensemble homogène et performant, tout en s'épanouissant dans cette fonction. De la même façon, pour que le monde retrouve sa stabilité et son unité d'antan, il doit de nouveau être régi par des lois physiques immuables, sans craindre l'inconstance permanente du Tumulte.", + "world.faction.or.play": "Les Ordis alignent. Là où d’autres cherchent la carte décisive, vous accumulez : des recrues produites en série, des personnages dont les effets passifs grippent discrètement le plan adverse, et une position qui se solidifie Jour après Jour. Vite en boostant vos recrues, ou lentement en empilant. Un jeu précis, presque chirurgical, où un seul point de statistique décide d’un Jour, à condition d’accepter d’y mettre le prix.", + "world.faction.or.name": "Ordis", + "world.faction.yz.lore": "Pour les mystiques Yzmir, l'esprit est le fondement de toute chose : il est le siège des idées, et c'est par lui qu'il convient de les dompter. Comme une lame qu'on aiguise, il doit être entretenu pour rester incisif et pénétrant. C'est pourquoi les Initiés Yzmir pratiquent la méditation, développent la philosophie et la spiritualité. C'est par leur biais que leur intellect et leur intuition sauront voir au-delà de ce que les sens leur révèlent, que leur conscience saura percer les voiles troubles de la réalité.", + "world.faction.yz.play": "Les Yzmir répondent. Les meilleurs sorts de retrait et de pioche du jeu, des personnages qui préparent le terrain, et des synergies où l’on sacrifie les siens pour obtenir une magie interdite. Vous laissez l’adversaire s’engager le premier, puis vous démontez son Jour pièce par pièce. Le prix à payer : un plan réactif ne vaut que si vous avez la bonne réponse au bon moment. L’anticipation n’est pas optionnelle.", + "world.faction.yz.name": "Yzmir", + "world.intro.name": "Le monde", + "world.intro.1.title": "Le monde", + "world.intro.1.body": "Il y a cinq siècles, le monde imaginaire est entré en collision avec le nôtre. De cette Confluence est né le Tumulte : des courants qui altèrent tout sur leur passage, déforment les paysages et les créatures, et rendent le monde impraticable. Les survivants ont erré d’Oasis en Oasis avant de se rassembler à Asgartha, une péninsule stabilisée, devenue le dernier foyer de l’humanité.", + "world.intro.2.title": "La reconquête", + "world.intro.2.body": "Asgartha a lancé la Grande Redécouverte : franchir les murs, sécuriser les terres une à une, et remonter jusqu’à la source du Tumulte pour l’apaiser. Les Exaltés en sont l’avant-garde, et chaque partie d’Altered est une de ces expéditions. Six factions mènent cette reconquête, chacune avec sa propre idée de ce que l’humanité devrait devenir.", + "world.intro.3.title": "Les Exaltés", + "world.intro.3.body": "L’Altération permet de donner corps à son imagination, en captant le Mana — l’énergie qui nourrit le Tumulte. Ceux qui la maîtrisent partent par deux : un Altérant, qui manifeste ses souvenirs et ses idées, et son Alter Ego, une chimère née du Tumulte capable d’en capter bien davantage. Liés l’un à l’autre, ils forment un Exalté. C’est cette paire que vous jouez.", + "world.intro.art.alt": "Illustration du set « {set} »", + "play.lead": "Deux façons de jouer, gratuitement.", + "play.external": "s'ouvre dans un nouvel onglet", + "play.t1.title": "En ligne, tout de suite", + "play.t1.step1": "Créez un compte gratuit sur Board Game Arena.", + "play.t1.step2": "Choisissez une faction et son deck de départ.", + "play.t1.step3": "Faites le tutoriel, puis votre première vraie partie contre un humain.", + "play.t1.note": "Board Game Arena applique les règles à votre place : vous apprenez en jouant.", + "play.t1.cta": "Ouvrir Board Game Arena", + "play.t2.title": "Autour d'une table", + "play.t2.step1": "Imprimez des proxies chez vous, ou commandez des cartes imprimées chez **Forgefire**, le service officiellement approuvé.", + "play.t2.step2": "Trouvez une soirée jeux près de chez vous, ou une boutique où l'on joue déjà.", + "play.t2.step3": "Personne autour de vous ? Lancez votre propre table. Nous fournissons le kit et l'outil de tournoi.", + "play.t2.note": "Imprimer des cartes pour votre usage personnel ne pose pas de problème. Forgefire garantit qualité et quantité.", + "play.t2.cta": "Trouver des événements près de chez moi", + "decks.lead": "Les decks de départ sont solides et vous apprennent le jeu en vous amusant. Mais il arrive un moment où vous voulez pousser votre idée plus loin que ce qu’ils permettent. C’est là que commence la construction : un compte gratuit, tous les formats, et un deck qui ne ressemble qu’à vous. Construisez-le ici et il est tout simplement là dans votre prochaine partie sur Board Game Arena. Pas d’export, pas de fichier, pas de liste à recopier.", + "decks.feat.1.label": "Constructeur de decks", + "decks.feat.1.text": "Toutes les cartes de toutes les extensions, légalité vérifiée en direct.", + "decks.feat.2.label": "Collection", + "decks.feat.2.text": "Ce que vous possédez, papier et numérique, au même endroit.", + "decks.feat.3.label": "Directement sur BGA", + "decks.feat.3.text": "Déjà prêt pour votre prochaine partie.", + "decks.fig.label": "La taille d'Altered Re:Union", + "decks.fig.cards.value": "2 800", + "decks.fig.cards.label": "cartes", + "decks.fig.sets.value": "7", + "decks.fig.sets.label": "extensions", + "decks.fig.formats.value": "6", + "decks.fig.formats.label": "formats", + "decks.fig.decks.value": "100+", + "decks.fig.decks.label": "decks de la communauté pour commencer", + "decks.fmt.label": "Formats", + "decks.fmt.what": "Ce que c'est", + "decks.fmt.who": "Pour qui", + "decks.fmt.browse": "Voir les decks dans ce format", + "decks.fmt.row.size": "Taille du deck", + "decks.fmt.row.copies": "Exemplaires par carte", + "decks.fmt.row.unique": "Cartes uniques", + "decks.fmt.row.pool": "Pool de cartes", + "decks.fmt.row.bga": "Jouable sur BGA", + "decks.fmt.table": "Comparaison de ce format", + "decks.fmt.closing": "Les tournois indiquent toujours le format utilisé.", + "decks.fmt.all": "Tous les formats en détail", + "decks.fmt.standard.name": "Standard", + "decks.fmt.standard.what": "Le format de référence : un deck d'au moins 39 cartes mené par un Héros, construit avec n'importe quelle carte de n'importe quelle extension, Uniques comprises.", + "decks.fmt.standard.who": "Tout le monde. C'est le point de départ naturel, et le format à apprendre en premier.", + "decks.fmt.standard.size": "39 cartes minimum", + "decks.fmt.standard.copies": "Jusqu'à 3", + "decks.fmt.standard.unique": "Autorisées", + "decks.fmt.standard.pool": "Toutes les extensions", + "decks.fmt.standard.bga": "Oui", + "decks.fmt.nuc.name": "Standard, sans uniques", + "decks.fmt.nuc.what": "Les règles du Standard avec une seule différence : aucune carte Unique. Chaque carte de votre deck est une carte ordinaire que chacun peut posséder.", + "decks.fmt.nuc.who": "Les joueurs qui veulent partir à égalité, ou qui n'ont pas collectionné d'Uniques.", + "decks.fmt.nuc.size": "39 cartes minimum", + "decks.fmt.nuc.copies": "Jusqu'à 3", + "decks.fmt.nuc.unique": "Aucune", + "decks.fmt.nuc.pool": "Toutes les extensions", + "decks.fmt.nuc.bga": "Oui", + "decks.fmt.singleton.name": "Singleton", + "decks.fmt.singleton.what": "Un deck plus grand, d'au moins 59 cartes, avec un seul exemplaire de chaque carte. Les cartes Uniques sont autorisées.", + "decks.fmt.singleton.who": "Les constructeurs qui préfèrent la variété à la répétition : on y joue très différemment du Standard.", + "decks.fmt.singleton.size": "59 cartes minimum", + "decks.fmt.singleton.copies": "1", + "decks.fmt.singleton.unique": "Autorisées", + "decks.fmt.singleton.pool": "Toutes les extensions", + "decks.fmt.singleton.bga": "Non", + "decks.fmt.singleton_nuc.name": "Singleton, sans uniques", + "decks.fmt.singleton_nuc.what": "Le Singleton sans cartes Uniques : un deck d'au moins 59 cartes, un exemplaire de chaque, que des cartes ordinaires.", + "decks.fmt.singleton_nuc.who": "Les joueurs de Singleton qui veulent que chaque carte du deck soit à la portée de tous.", + "decks.fmt.singleton_nuc.size": "59 cartes minimum", + "decks.fmt.singleton_nuc.copies": "1", + "decks.fmt.singleton_nuc.unique": "Aucune", + "decks.fmt.singleton_nuc.pool": "Toutes les extensions", + "decks.fmt.singleton_nuc.bga": "Oui", + "decks.fmt.frontier.name": "Frontier", + "decks.fmt.frontier.what": "Le format officiel de Board Game Arena. Les règles de construction sont celles du Standard, mais les cartes Uniques ne sont légales que si elles font partie d'un pool régulièrement renouvelé : le format évolue avec le jeu.", + "decks.fmt.frontier.who": "Les joueurs compétitifs, et ceux qui aiment fouiller parmi les Uniques pour y dénicher des pépites.", + "decks.fmt.frontier.size": "39 cartes minimum", + "decks.fmt.frontier.copies": "Jusqu'à 3", + "decks.fmt.frontier.unique": "Autorisées, dans un pool régulièrement renouvelé", + "decks.fmt.frontier.pool": "Toutes les extensions", + "decks.fmt.frontier.bga": "Oui, le format officiel", + "decks.fmt.draft.name": "Draft & Scellé", + "decks.fmt.draft.what": "Vous ouvrez un pool de cartes et construisez un deck avec ce que vous obtenez. La collection ne compte pas : un débutant et un vétéran partent à égalité.", + "decks.fmt.draft.who": "Les joueurs qui aiment la fraîcheur et la créativité : chaque pool est différent, donc chaque deck est une nouvelle énigme. Aucune collection n'est nécessaire.", + "decks.fmt.draft.size": "Construit depuis votre pool", + "decks.fmt.draft.copies": "Autant que vous en ouvrez", + "decks.fmt.draft.unique": "Seulement si vous en ouvrez", + "decks.fmt.draft.pool": "Le pool que vous ouvrez", + "decks.fmt.draft.bga": "Oui", + "project.lead.1": "Equinox a créé Altered et l’a porté jusqu’en mars 2026, où le financement de la sixième extension n’a pas suivi. Avant de fermer ses services, l’éditeur a publié la quasi-totalité des cartes, des illustrations et du lore, et organisé avec Board Game Arena et la communauté le passage de relais.", + "project.lead.2": "Depuis mai 2026, le jeu est entre les mains d’Altered Re:Union, association française à but non lucratif. Gouvernance élue, comptes publiés, équipe de bénévoles à travers le monde : le développement, les règles, les illustrations, les traductions et les tournois sont assurés par des gens qui y jouent.", + "project.shipped.title": "Ce qui est livré", + "project.shipped.1": "Roots of Corruption porté sur Board Game Arena", + "project.shipped.2": "Frontier, un format conçu par la communauté, jouable dès maintenant", + "project.shipped.3": "Decks de départ mis à jour", + "project.shipped.4": "Illustrations alternatives de nouveau jouables sur Board Game Arena", + "project.shipped.5": "Mises à jour d'équilibrage, errata et suspensions en continu", + "project.shipped.6": "Une saison compétitive, avec qualifications et une Coupe du monde 2027", + "project.updated": "Dernière mise à jour : {date}.", + "project.wip.title": "Ce qui est en cours", + "project.wip.1": "Neverending Journey sur Board Game Arena", + "project.wip.2": "Échange de cartes", + "project.wip.3": "Règles et cartes dans davantage de langues", + "project.wip.4": "Une base de données de lore publique", + "project.horizon": "Plus loin : de nouvelles extensions, conçues par la communauté. Nous n'annoncerons ni nom ni date tant qu'il n'y aura pas quelque chose de concret à montrer.", + "project.team": "L'équipe", + "project.discord": "Rejoindre le Discord", + "project.donate": "Faire un don", + "project.external": "s'ouvre dans un nouvel onglet", + "toc.faq": "FAQ", + "toc.bga": "Jouer sur BGA", + "toc.external": "s'ouvre dans un nouvel onglet", + "decks.flow.label": "Comment ça marche", + "decks.flow.once": "Une seule fois, pour commencer", + "decks.flow.every": "Ensuite, à chaque fois", + "decks.flow.1.title": "Créez votre compte", + "decks.flow.1.text": "Gratuit, ici sur altered.re.", + "decks.flow.1.cta": "Créer un compte gratuit", + "decks.flow.1.signin": "Vous en avez déjà un ?", + "decks.flow.1.signin_link": "Connectez-vous", + "decks.flow.2.title": "Liez-le à Board Game Arena", + "decks.flow.2.text": "Depuis BGA, une fois pour toutes.", + "decks.flow.3.title": "Construisez votre deck", + "decks.flow.3.text": "Toutes les cartes, tous les formats, légalité vérifiée pendant que vous construisez.", + "decks.flow.3.link": "Ouvrir le constructeur de decks", + "decks.flow.4.title": "Jouez-le sur BGA", + "decks.flow.4.text": "Il vous attend déjà. Pas d'export, pas de liste à recopier." +} diff --git a/pages/_discover/sections/decks.php b/pages/_discover/sections/decks.php new file mode 100644 index 00000000..71903ffe --- /dev/null +++ b/pages/_discover/sections/decks.php @@ -0,0 +1,118 @@ + 'standard', 'deck' => 'standard'], + ['slug' => 'nuc', 'deck' => 'nuc'], + ['slug' => 'singleton', 'deck' => 'singleton'], + ['slug' => 'singleton_nuc', 'deck' => 'singleton_nuc'], + ['slug' => 'frontier', 'deck' => 'frontier'], + ['slug' => 'draft', 'deck' => ''], // no deck list of its own +]; +$_rows = ['size', 'copies', 'unique', 'pool', 'bga']; +$_figs = ['cards', 'sets', 'formats', 'decks']; +?> +
+
+
+

+ +
+ +
+
+
+
+ +
+ +
    + +
  • + + +
  • + +
+ +
+
+

+
    +
  1. +

    +

    + +

    +
  2. +
  3. +

    +

    +
  4. +
+
+
+

+
    +
  1. +

    +

    + +
  2. +
  3. +

    +

    +
  4. +
+
+
+
+
+ +
+
+ $f): ?> + + +
+ + $f): ?> +
> +
+

+

+

+

+

+ +
+ + + + + + + + + + +
+
+ + +

+ + +

+
+
diff --git a/pages/_discover/sections/gameplay.php b/pages/_discover/sections/gameplay.php new file mode 100644 index 00000000..8820e1b1 --- /dev/null +++ b/pages/_discover/sections/gameplay.php @@ -0,0 +1,60 @@ + 'dawn', 'cues' => []], + ['key' => 'noon', 'cues' => []], + ['key' => 'afternoon', 'cues' => [1, 2, 3]], + ['key' => 'dusk', 'cues' => [4]], + ['key' => 'night', 'cues' => [5, 6]], +]; +$_rulesAssets = BASE_URL . '/assets/img/discover/'; +?> +

+ +
+ +
+

+

+
+ +
+ +
+
    + +
  1. + + + + + + + +
  2. + +
+ +
+
+ +
    + +
  1. + +
+
+
+
+
+ +

+ +

diff --git a/pages/_discover/sections/play.php b/pages/_discover/sections/play.php new file mode 100644 index 00000000..591add35 --- /dev/null +++ b/pages/_discover/sections/play.php @@ -0,0 +1,31 @@ + 't1', 'href' => 'https://boardgamearena.com/gamepanel?game=altered', 'external' => true, 'primary' => true], + ['id' => 't2', 'href' => BASE_URL . '/pages/events', 'external' => false, 'primary' => false], +]; +?> +

+ +
+ + + +
diff --git a/pages/_discover/sections/project.php b/pages/_discover/sections/project.php new file mode 100644 index 00000000..8c642cf9 --- /dev/null +++ b/pages/_discover/sections/project.php @@ -0,0 +1,41 @@ + +

+

+ +
+
+

+
    + +
  • + +
+

+ ' . h(formatDate($_shippedUpdated)) . '', pageText($tx, 'project.updated')) ?> +

+
+ +
+

+
    + +
  • + +
+

+
+
+ + diff --git a/pages/_discover/sections/world.php b/pages/_discover/sections/world.php new file mode 100644 index 00000000..0dd38460 --- /dev/null +++ b/pages/_discover/sections/world.php @@ -0,0 +1,99 @@ + 'ax', 'slug' => 'axiom', 'marker' => 'AX'], + ['code' => 'br', 'slug' => 'bravos', 'marker' => 'BR'], + ['code' => 'ly', 'slug' => 'lyra', 'marker' => 'LY'], + ['code' => 'mu', 'slug' => 'muna', 'marker' => 'MU'], + ['code' => 'or', 'slug' => 'ordis', 'marker' => 'OR'], + ['code' => 'yz', 'slug' => 'yzmir', 'marker' => 'YZ'], +]; +$_cardsApi = defined('CARDS_API_URL') ? CARDS_API_URL : 'https://cards.alteredcore.org'; +$_cdn = defined('CDN_URL') ? CDN_URL : ''; +$_worldLang = getLang() === 'fr' ? 'fr' : 'en'; +$_setArt = []; // key art of six main sets (assets/img/discover/sets, all 729x409), one picked at random for the introduction tab +$_setFile = dirname(__DIR__, 3) . '/plugins/core-altered-cards/data/altered.json'; +$_setData = is_file($_setFile) ? json_decode(ltrim((string)file_get_contents($_setFile), ""), true) : []; +$_setFiles = ['beyond-the-gates' => 'CORE', 'trial-by-frost' => 'ALIZE', 'whispers-from-the-maze' => 'BISE', + 'skybound-odyssey' => 'CYCLONE', 'seeds-of-unity' => 'DUSTER', 'roots-of-corruption' => 'EOLE']; +foreach ($_setFiles as $_slug => $_k) { + if (!is_file(dirname(__DIR__, 3) . '/assets/img/discover/sets/' . $_slug . '.webp')) continue; + $_n = $_setData['sets'][$_k][$_worldLang] ?? $_setData['sets'][$_k]['en'] ?? $_k; + $_setArt[] = ['src' => BASE_URL . '/assets/img/discover/sets/' . $_slug . '.webp', 'name' => $_n]; +} +$_setPick = $_setArt ? $_setArt[array_rand($_setArt)] : null; +?> +
+ +

+ +
+ + $f): ?> + + +
+ +
+
+ +

+

+ +
+ +
+ <?= h(str_replace('{set}', $_setPick['name'], $tx['world.intro.art.alt'] ?? '{set}')) ?> +
+
+ +
+ + $f): ?> + + + + +

+ + +
diff --git a/pages/_router.php b/pages/_router.php index 16796cc1..27f55cf9 100644 --- a/pages/_router.php +++ b/pages/_router.php @@ -82,6 +82,7 @@ if ($_pluginPage['type'] === 'spa') { $_pluginPage['sub_path'] = $_subPath; $pageFullwidth = $_pluginPage['fullwidth']; + $GLOBALS['_ac_render_expect'] = spaFirstPaintId($_pluginPage); // Manifest `meta`: title and link preview of the client route ($slug, $subPath), set before the header // (variables of includes/header.php). if (!empty($_pluginPage['meta_file'])) { diff --git a/pages/account.php b/pages/account.php index d37e60f5..40e18945 100644 --- a/pages/account.php +++ b/pages/account.php @@ -170,7 +170,7 @@
-
+
@@ -250,7 +250,7 @@ class="btn btn-outline-secondary btn-sm">

-

' . h($accountEmail) . '') : $txt['newsletter_off'] ?>

+

' . ac_masked_email($accountEmail) . '') : $txt['newsletter_off'] ?>

diff --git a/pages/design-system.php b/pages/design-system.php index 73e5a38e..21ca7f14 100644 --- a/pages/design-system.php +++ b/pages/design-system.php @@ -315,6 +315,7 @@ Invalid Neutral Large + Dense NEW UNIQUE BANNED @@ -329,6 +330,27 @@ +
+

Keys

+
+
+
+ + + +
+
+ + + +
+
+ +
+
+
+
+

Cards, lists, empty state

@@ -443,7 +465,7 @@
- +
@@ -474,6 +496,26 @@
  • +
    + + +
    @@ -498,6 +540,19 @@
    Loading…
    +
    +
    +
    +
    + Loading… +
    + + + + +
    +
    +
    @@ -521,6 +576,17 @@
    +
    +

    Tooltip, masked value

    +
    +

    Signed in as : point at the address, or focus it (ac_masked_email() renders span.ac-masked.ac-tooltip-host).

    +
    + Start-aligned Aligned on the start edge + Below Under its host +
    +
    +
    +

    Icons — Lucide

    PHP ac_icon('name'), site JS acIcon('name'), Angular <ac-icon name="…">. All names: lucide.dev/icons.

    diff --git a/pages/discover.php b/pages/discover.php new file mode 100644 index 00000000..6193d62c --- /dev/null +++ b/pages/discover.php @@ -0,0 +1,87 @@ + 'gameplay', 'dark' => false], + ['id' => 'world', 'dark' => false], + ['id' => 'play', 'dark' => false], + ['id' => 'decks', 'dark' => false], + ['id' => 'project', 'dark' => false], +]; + +include dirname(__DIR__) . '/includes/header.php'; +$_discoverAsset = function (string $rel): string { + $f = dirname(__DIR__) . '/' . $rel; + return BASE_URL . '/' . $rel . (is_file($f) ? '?v=' . filemtime($f) : ''); +}; +?> + + +
    + +
    +

    +
    + +
    + + + +
    + +
    +

    + +

    +
    +
    + +
    + +
    + +
    + + + + diff --git a/plugins/README.html b/plugins/README.html index f53b0fbd..14e9db11 100644 --- a/plugins/README.html +++ b/plugins/README.html @@ -515,8 +515,9 @@

    SPA plugins (manifest v2)

    - + + diff --git a/plugins/core-altered-cards/api/collection-search.php b/plugins/core-altered-cards/api/collection-search.php index d67e11ed..20cff90a 100644 --- a/plugins/core-altered-cards/api/collection-search.php +++ b/plugins/core-altered-cards/api/collection-search.php @@ -3,6 +3,7 @@ // Fetches the user's collection from the collection API, applies filters, // handles server-side pagination, and returns a cards-API-compatible envelope. require_once dirname(__DIR__) . '/includes/functions.php'; +require_once dirname(__DIR__) . '/includes/collection-query.php'; header('Content-Type: application/json'); @@ -14,43 +15,8 @@ $userId = (int)($_SESSION['user_id'] ?? 0); -$scalar = [ - 'faction', 'rarity', 'cardType', 'variation', - 'isFoil', 'isBanned', 'isSuspended', - 'cardReference', 'name', 'subTypes', 'locale', -]; -$range = ['mainCost', 'recallCost', 'oceanPower', 'mountainPower', 'forestPower']; - -$parts = []; - -foreach ($scalar as $k) { - if (!isset($_GET[$k])) continue; - if (is_array($_GET[$k])) { - foreach ($_GET[$k] as $v) { - if ($v !== '') $parts[] = $k . '=' . rawurlencode($v); - } - } elseif ($_GET[$k] !== '') { - $parts[] = $k . '=' . rawurlencode($_GET[$k]); - } -} - -if (isset($_GET['cardSet'])) { - $sets = is_array($_GET['cardSet']) ? $_GET['cardSet'] : [$_GET['cardSet']]; - foreach ($sets as $v) { - if ($v !== '') $parts[] = 'cardSet=' . rawurlencode($v); - } -} - -foreach ($range as $k) { - if (!isset($_GET[$k]) || !is_array($_GET[$k])) continue; - foreach (['gte', 'lte', 'gt', 'lt'] as $op) { - if (isset($_GET[$k][$op]) && $_GET[$k][$op] !== '') { - $parts[] = $k . '[' . $op . ']=' . rawurlencode($_GET[$k][$op]); - } - } -} - -$path = '/api/collection' . ($parts ? '?' . implode('&', $parts) : ''); +$query = cacCollectionQuery($_GET); +$path = '/api/collection' . ($query !== '' ? '?' . $query : ''); $data = collApiRequest(COLLECTION_API_URL, 'GET', $path, $userId); if ($data === false) { @@ -59,7 +25,11 @@ exit; } -$items = is_array($data) ? $data : []; +$items = is_array($data) ? $data : []; +$types = cacCollectionLocalTypes($_GET); +if ($types !== null) { + $items = array_values(array_filter($items, fn($it) => is_array($it) && in_array($it['cardType'] ?? '', $types, true))); +} $total = count($items); $perPage = max(1, (int)($_GET['itemsPerPage'] ?? 30)); $page = max(1, (int)($_GET['page'] ?? 1)); diff --git a/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts b/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts index 8d52e316..f73ebd06 100644 --- a/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts +++ b/plugins/core-altered-cards/e2e/deckbuilder-share.spec.ts @@ -53,7 +53,7 @@ const saveRequest = (page: Page) => test.describe('Site deck builder · « Partager » and « Terminer »', () => { for (const theme of ['light', 'dark'] as const) { - test(`saves the deck first, shares it, then opens the deck page (${theme})`, async ({ page, compact }, testInfo) => { + test(`saves the deck first, shares it, then opens the deck page (${theme})`, { tag: theme === 'light' ? '@mobile' : [] }, async ({ page, compact }, testInfo) => { const name = `E2E legacy ${testInfo.project.name} ${theme} ${Date.now()}`; await login(page, 'bob', '/pages/decks?lang=fr'); const id = await createServerDeck(page, name); @@ -92,7 +92,7 @@ test.describe('Site deck builder · « Partager » and « Terminer »', () => { }); } - test('stops on a failed save and carries the action on with « Réessayer »', async ({ page, compact }, testInfo) => { + test('stops on a failed save and carries the action on with « Réessayer »', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E legacy error ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', '/pages/decks?lang=fr'); const id = await createServerDeck(page, name); @@ -113,7 +113,7 @@ test.describe('Site deck builder · « Partager » and « Terminer »', () => { await expect(error).toBeHidden(); }); - test('a guest signs in to share: the browser deck moves to the account, then the share window opens', async ({ page, compact }, testInfo) => { + test('a guest signs in to share: the browser deck moves to the account, then the share window opens', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E legacy guest ${testInfo.project.name} ${Date.now()}`; await setBeta(page, false); await page.addInitScript(([deckName, hero]) => { diff --git a/plugins/core-altered-cards/includes/collection-query.php b/plugins/core-altered-cards/includes/collection-query.php new file mode 100644 index 00000000..9f3ac0f3 --- /dev/null +++ b/plugins/core-altered-cards/includes/collection-query.php @@ -0,0 +1,46 @@ + 1 ? $types : null; +} + +/** The non-empty string values of a list filter. */ +function cacCollectionValues(array $get, string $key): array +{ + return array_values(array_filter((array)($get[$key] ?? []), fn($v) => is_string($v) && $v !== '')); +} diff --git a/plugins/core-altered-cards/includes/functions.php b/plugins/core-altered-cards/includes/functions.php index 250be444..d6213ec4 100644 --- a/plugins/core-altered-cards/includes/functions.php +++ b/plugins/core-altered-cards/includes/functions.php @@ -36,6 +36,29 @@ function deckApiToken(): ?string { return $token ?: null; } +/** + * Deletes a deck of the signed-in player from the decks API: the HTTP status (0 when unreachable). On a 2xx, what the + * site keeps about the deck goes too (altArtForgetDeck). + */ +function cacDeckApiDelete(string $deckId, string $token): int +{ + $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_CUSTOMREQUEST => 'DELETE', + CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], + CURLOPT_TIMEOUT => 15, + ]); + curl_exec($ch); + $code = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + if ($code >= 200 && $code < 300) { + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + altArtForgetDeck((int)($_SESSION['user_id'] ?? 0), $deckId); + } + return $code; +} + /** @return array{upvoteCount: int, hasUpvoted: bool}|null */ function cacDeckApiUpvote(string $deckId, string $token): ?array { @@ -314,36 +337,11 @@ function cacHeroPortraitRef(string $ref): string { * same failure mode as the rest of this plugin's ownership calls. */ function cacApplyAltArtPreferencesToCards(array $deckCards, int $userId): array { - if (!defined('OWNERSHIP_API_URL') || !OWNERSHIP_API_URL || $userId <= 0) return $deckCards; - - $checkItems = []; - foreach ($deckCards as $c) { - if (empty($c['cardReference'])) continue; - $checkItems[] = ['reference' => (string)$c['cardReference'], 'quantity' => (int)($c['quantity'] ?? 1)]; - } - if (!$checkItems) return $deckCards; - - $result = collApiRequest(rtrim(OWNERSHIP_API_URL, '/'), 'POST', '/api/alt-arts/apply-to-deck', $userId, $checkItems); - if (!is_array($result) || !isset($result['lines']) || !is_array($result['lines'])) return $deckCards; - - // Lines[i] is itself a list (a shortfall/preference spread can turn one input line - // into several) — flatten, then merge references that ended up repeated across - // different input lines (e.g. two printings of the same card both falling back to - // the same default art). - $merged = []; - foreach ($result['lines'] as $lines) { - foreach ((array)$lines as $line) { - if (!isset($line['reference'], $line['quantity'])) continue; - $ref = (string)$line['reference']; - $merged[$ref] = ($merged[$ref] ?? 0) + (int)$line['quantity']; - } - } - - $out = []; - foreach ($merged as $ref => $qty) { - $out[] = ['cardReference' => $ref, 'quantity' => $qty]; - } - return $out; + if ($userId <= 0) return $deckCards; + require_once dirname(__DIR__, 3) . '/includes/func.keycloak.php'; + require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; + $token = kc_get_access_token($userId); + return ($token ? altArtApplyToDeck($token, $deckCards) : null) ?: $deckCards; } /** diff --git a/plugins/core-altered-cards/pages/deck.php b/plugins/core-altered-cards/pages/deck.php index 05c96077..8b16fff3 100644 --- a/plugins/core-altered-cards/pages/deck.php +++ b/plugins/core-altered-cards/pages/deck.php @@ -183,16 +183,7 @@ if ($delId) { $token = deckApiToken(); if ($token) { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($delId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], - CURLOPT_TIMEOUT => 15, - ]); - curl_exec($ch); - $delCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $delCode = cacDeckApiDelete($delId, $token); if ($delCode >= 200 && $delCode < 300) { flash($txt['deleted_ok']); } else { @@ -218,14 +209,7 @@ } $action = $_POST['action'] ?? ''; if ($action === 'delete') { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deckId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token, 'Accept: application/json'], - CURLOPT_TIMEOUT => 15, - ]); - $r = curl_exec($ch); $c = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); + $c = cacDeckApiDelete((string)$deckId, $token); if ($c >= 200 && $c < 300) { echo json_encode(['ok' => true]); } else { diff --git a/plugins/core-altered-cards/pages/decks.php b/plugins/core-altered-cards/pages/decks.php index 2c9e9029..e1b308f5 100644 --- a/plugins/core-altered-cards/pages/decks.php +++ b/plugins/core-altered-cards/pages/decks.php @@ -275,16 +275,7 @@ if ($deleteId) { $token = deckApiToken(); if ($token) { - $ch = curl_init(DECKS_API_URL . '/api/decks/' . rawurlencode($deleteId)); - curl_setopt_array($ch, [ - CURLOPT_RETURNTRANSFER => true, - CURLOPT_CUSTOMREQUEST => 'DELETE', - CURLOPT_HTTPHEADER => ['Accept: application/json', 'Authorization: Bearer ' . $token], - CURLOPT_TIMEOUT => 10, - ]); - curl_exec($ch); - $deleteCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); - curl_close($ch); + $deleteCode = cacDeckApiDelete($deleteId, $token); if ($deleteCode >= 200 && $deleteCode < 300) { flash($txt['deleted_ok']); } else { diff --git a/plugins/hello-world/pages/hellopagelock.php b/plugins/hello-world/pages/hellopagelock.php index 87bd4377..eb268fdc 100644 --- a/plugins/hello-world/pages/hellopagelock.php +++ b/plugins/hello-world/pages/hellopagelock.php @@ -81,7 +81,7 @@ - + diff --git a/plugins/hidden-creatures/README.md b/plugins/hidden-creatures/README.md new file mode 100644 index 00000000..88ca7683 --- /dev/null +++ b/plugins/hidden-creatures/README.md @@ -0,0 +1,75 @@ +# Hidden Creatures + +Illustrations hidden on some pages of the site. Each one falls slowly through the page, drifting and turning; a click +makes it jump and spin, swaps its image and shows a toast « Trouvé : ! 2/7 », with the spot's contest code +when it has one (Gleam « Secret Code » action), and a « Copier » button. + +Turn the hunt on or off in Admin → Plugins, then give the « hidden-creatures » section to the admin groups that run it +(Admin → Groups). Pages without a spot load nothing. + +## Creatures and spots: in the admin, not in the code + +The repository is public: where the creatures hide, their images and the codes are entered in Admin → Hidden +creatures (tables `plugin_hc_creatures`, `plugin_hc_spots`), never committed. + +- **Creature**: an id, a name (FR, EN), two images picked in the media library, idle and clicked (square, transparent + background). Saved as 400 px WebP copies in `uploads/hidden-creatures/` when GD can write WebP; the picked image as + it is otherwise. No image: the component's cotton ball. +- **Spot**: an id, its creature, its pages, its trigger, its code. The counter's total is the number of spots. Finds + are recorded by spot id: keep it once players have found the spot. + +Pages are page slugs: `index` for the home page, the slug of `/pages/` otherwise (`news-detail`, `decks`…); `*` +for every page (a link of the menu: the two scripts, about 20 KB, then load everywhere). + +Triggers: + +- **load**: on the page from the start, somewhere in its height, falling through it in a loop. +- **scroll-end**: crosses the screen once when the visitor reaches the bottom of the page (the end of a news). +- **event:**: crosses the screen once when the page runs + `window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: '' }))`. Sent today: `test-hand` (Re:Builder, + « Main de départ » tab: `features/deck/test-hand/test-hand.ts`), `tournament-heroes` (tournament-reports, the heroes + chart's « Détail »: `assets/app.js`). For another action, send a new name from that page's code; the line is public, + keep its comment neutral. +- **link:**: after a click on a link whose address starts with ``. A link to a new tab: the creature crosses + the page the visitor left, right away. Same tab: it waits (`localStorage`) for the visitor's return to the site, back + button or any later page. + +## Finds and the contest + +- **Signed-in player**: the click is recorded server side (`papi/find.php`, table `plugin_hc_finds`). The count is the + account's, on any device. +- **Anonymous visitor**: the count is this browser's (`localStorage`, key `acHiddenCreatures`); the toast asks them to + sign in for the find to count. Finds made before signing in are not carried over. +- **A find only counts after the page was shown**: rendering a page marks its spots seen in the PHP session + (`assets/global_head.php`), and `papi/find.php` refuses a spot the session has not seen (409). It does not prove the + click itself (a technical player can call the API once on the page), nor, for a spot on every page, that a link was + clicked. Codes can be shared. +- **Checking a winner** (Admin → Hidden creatures): enter the email of the Gleam entry, the screen lists that account's + finds with their dates. The same screen lists the players who found everything, and how many found each spot. The + email is the site account's (`users.email`, kept when `STORE_KC_USER_DATA` is on); otherwise search by username. + +## Files + +| File | Role | +| --- | --- | +| `assets/global_head.php` | In every page's ``: marks this page's spots seen, gives them to the scripts below, or nothing. | +| `assets/hidden-creatures.js` | Releases the creatures of the page at their trigger, reports a click, shows the toast. | +| `assets/cotton-fall.js` | The `` web component (animation, Shadow DOM), with a few changes marked « AlteredCore » in its header. | +| `papi/find.php` | `POST /papi/hidden-creatures/find {spot}`: records a signed-in player's find, replies with the code and the finds. | +| `admin/hunt.php` | Admin: creatures, spots, finds per spot, players who found everything, one player's finds. | +| `inc/functions.php` | Shared helpers (`hc*`): creatures and spots from the database, images, finds. | +| `sql/install.sql` | Tables `creatures`, `spots`, `finds`, created empty at the first activation. | +| `e2e/` | Playwright spec and its test spots (`fixtures.php`, run by the stack's entrypoint when the plugin is active). | + +## Tests + +`e2e/hidden-creatures.spec.ts` runs against test spots of its own (`fixtures.php`: news and privacy pages, a link to +example.test, Re:Builder's `test-hand` event), on a stack started with the plugin active: +`AC_ACTIVATE_PLUGINS="core-altered-cards rebuilder ownership hidden-creatures"`. It skips itself otherwise; the CI's +stack leaves the plugin off, a creature falling over the other specs would catch their clicks. + +## Tuning + +Size, speeds, drift, hit area: attributes of `` set in `hidden-creatures.js` (`size`: 200 px, 30 % of the +width on a phone; `fall-seconds`, `drift-amplitude`, `hit-radius`…), or the constants at the top of `cotton-fall.js`. +With `prefers-reduced-motion`, a creature stands still (a once-only one leaves after 10 s); a click still counts. diff --git a/plugins/hidden-creatures/admin/hunt.php b/plugins/hidden-creatures/admin/hunt.php new file mode 100644 index 00000000..7bb76453 --- /dev/null +++ b/plugins/hidden-creatures/admin/hunt.php @@ -0,0 +1,398 @@ + $isNew ? 'new' : $id])); + } + if ($isNew && isset($existing['creatures'][$id])) { + flash("A creature « $id » exists already.", 'error'); + redirect($_hcBack(['creature' => 'new'])); + } + hcSaveCreature( + $id, + mb_substr(trim((string)($_POST['name_fr'] ?? '')), 0, 100), + mb_substr(trim((string)($_POST['name_en'] ?? '')), 0, 100), + hcPrepareImage((string)($_POST['image_normal'] ?? ''), $id, 'normal'), + hcPrepareImage((string)($_POST['image_click'] ?? ''), $id, 'click') + ); + flash("Creature « $id » saved."); + } elseif ($action === 'creature-delete') { + if (hcDeleteCreature($id)) flash("Creature « $id » deleted."); + else flash("« $id » still hides in a spot: delete or change the spot first.", 'error'); + } elseif ($action === 'spot') { + $type = (string)($_POST['rule_type'] ?? ''); + $value = trim((string)($_POST['rule_value'] ?? '')); + $rule = in_array($type, ['event', 'link'], true) ? $type . ':' . $value : $type; + $pages = hcSplitPages((string)($_POST['pages'] ?? '')); + $code = trim((string)($_POST['code'] ?? '')); + $errors = hcSpotErrors($id, (string)($_POST['creature'] ?? ''), $pages, $rule, $code); + if ($isNew && isset($existing['spots'][$id])) $errors[] = "A spot « $id » exists already."; + if ($errors) { + flash(implode(' ', $errors), 'error'); + redirect($_hcBack(['spot' => $isNew ? 'new' : $id])); + } + hcSaveSpot($id, (string)$_POST['creature'], $pages, $rule, $code); + flash("Spot « $id » saved."); + } elseif ($action === 'spot-delete') { + hcDeleteSpot($id); + flash("Spot « $id » deleted. Its finds stay recorded, uncounted, until a spot takes the same id again."); + } + redirect($_hcUrl); +} + +$config = hcConfig(); +$creatures = $config['creatures']; +$spots = $config['spots']; +$spotIds = array_keys($spots); +$users = q('{users}'); +$imageUrl = function (string $path): string { return $path === '' ? '' : BASE_URL . '/' . $path; }; + +// Form being edited: ?creature=|new, ?spot=|new. +$editCreatureId = (string)($_GET['creature'] ?? ''); +$editCreature = $creatures[$editCreatureId] ?? null; +$editSpotId = (string)($_GET['spot'] ?? ''); +$editSpot = $spots[$editSpotId] ?? null; +[$editRuleType, $editRuleValue] = array_pad(explode(':', $editSpot['when'] ?? 'load', 2), 2, ''); + +// Players per spot. +$perSpot = array_fill_keys($spotIds, 0); +foreach ($db->query(hcq("SELECT spot, COUNT(*) AS n FROM {finds} GROUP BY spot")) as $row) { + if (isset($perSpot[$row['spot']])) $perSpot[$row['spot']] = (int)$row['n']; +} + +// Players who found every spot, first to finish first. +$complete = []; +if ($spotIds) { + $in = implode(',', array_fill(0, count($spotIds), '?')); + $stmt = $db->prepare(hcq( + "SELECT user_id, MAX(found_at) AS done_at, COUNT(*) AS n FROM {finds}" + . " WHERE spot IN ($in) GROUP BY user_id HAVING n = ? ORDER BY done_at" + )); + $stmt->execute(array_merge($spotIds, [count($spotIds)])); + $completeIds = array_column($stmt->fetchAll(), 'done_at', 'user_id'); + if ($completeIds) { + $uin = implode(',', array_fill(0, count($completeIds), '?')); + $stmt = $db->prepare("SELECT id, username, email FROM $users WHERE id IN ($uin)"); + $stmt->execute(array_keys($completeIds)); + $byId = array_column($stmt->fetchAll(), null, 'id'); + foreach ($completeIds as $uid => $doneAt) { + if (isset($byId[$uid])) $complete[] = $byId[$uid] + ['done_at' => $doneAt]; + } + } +} + +// One player's finds, by email or username. +$search = trim((string)($_GET['player'] ?? '')); +$matches = []; +if ($search !== '') { + $stmt = $db->prepare("SELECT id, username, email FROM $users WHERE email = :e OR username = :u LIMIT 10"); + $stmt->execute([':e' => $search, ':u' => $search]); + foreach ($stmt->fetchAll() as $u) $matches[] = $u + ['finds' => hcUserFinds((int)$u['id'])]; +} + +$ruleLabels = [ + 'load' => 'On the page from the start (falls through it in a loop)', + 'scroll-end' => 'At the bottom of the page (crosses the screen once)', + 'event' => 'On a page event (crosses the screen once)', + 'link' => 'After a click on a link (crosses the screen when the visitor is back)', +]; + +/** An image field with the media library picker (admin/includes/image-picker.php). */ +$picker = function (string $name, string $label, string $value) use ($imageUrl): void { + $id = 'hc_' . $name; + ?> +
    + +
    + + + +
    +
    + + + +
    +

    Hidden creatures

    +
    +

    + Creatures and spots are kept here, not in the code: the repository is public. A click on a creature counts the find + (recorded for signed-in players) and shows the spot's code, the Gleam « Secret Code » of that spot. +

    + + +

    Creatures

    +
    + +

    No creature yet.

    + +
    KeyDescription
    pages[].typeoptionalphp (default) or spa. An SPA page has no file.
    pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root).
    pages[].entryspaBuild manifest written by the plugin's build (relative to the plugin): {"version":1, "base":"browser/", "js":["main-HASH.js"], "css":["embed.css?v=…"], "documentCss":["document.css?v=…"]}. js: ES modules, in order. css: loaded inside the shadow root. documentCss: loaded in <head> (@font-face is ignored in a shadow root). Optional preload: {"*":["chunk-HASH.js"], "{slug}":[…], "lang:en":[…]}, the modules a page needs before it can draw (those its entry modules import, directly or not), requested together with the entry modules and the css files (<link rel="modulepreload">, added by an inline script right after the placeholder: it runs once the stylesheets above it have loaded, so the skeleton's first paint keeps their bandwidth): * on every page, {slug} on the page served at that slug, lang:… in that interface language. Without it, the browser finds the chunks one import at a time, one round trip per level.
    pages[].mountoptionalshadow (default): the plugin renders in a Shadow DOM, so Bootstrap and css/style.css do not reach it and its styles do not reach the site. light: rendered in the page.
    pages[].placeholderoptionalSPA: PHP file rendered by the server inside the mount point, with $slug (the requested page) and $subPath in scope: what the page shows while its scripts load, usually a skeleton of the screen built with ac-skeleton (design system, feedback). The runtime keeps it in view until the plugin draws its first screen, so a slow connection never shows an empty page. It goes once the mount's container takes some height: the first screen needs content in the page flow (its own skeleton while its data loads), not only a fixed or absolute overlay. Without it, a generic skeleton (rebuilder: a skeleton of the decks list, deck page or editor, desktop and phone).
    pages[].metaoptionalSPA: PHP file included before the site header, with $subPath (the client route under /pages/{slug}/) in scope. It may set $pageTitle, $pageDescription and $pageImage, so a client route has its own title and link preview (rebuilder: the deck's name on decks/{id}).
    pages[].fullwidthoptionalFull-width layout. Default true for SPA pages; false keeps the page in the site's content width (rebuilder).
    menuoptionalSuggested navigation entries, inserted on activation when no item has the same URL. Admins edit or hide them afterwards (Admin → Navigation). An entry targets page (URL /pages/{page}), plus path for a client route of an SPA page (/pages/{page}/{path}), or an explicit url. An entry with children is a dropdown (no URL of its own); its missing children are added under it. An entry with parent_url goes inside the existing top-level menu that has this URL (e.g. /pages/decks), last unless sort_order is set; at the top level when the site has no such menu.
    + + + + + + + + + + + $c): + $uses = count(array_filter($spots, function (array $s) use ($cid): bool { return $s['creature'] === $cid; })); ?> + + + + + + + + + +
    ImagesIdNameSpotsActions
    + + + <?= h($variant) ?> + + No image: cotton ball + + + + Edit + + + + + + + + +
    + +
    + +
    +

    + + + +
    +
    + + > +

    Lowercase letters, digits, dashes. Not shown to players.

    +
    +
    + + +
    +
    + + +
    +
    +
    +
    +

    + Square images with a transparent background. Saved as px WebP copies in + uploads// when the server can. +

    +
    + + Cancel +
    +
    + + +

    Spots

    +
    + +

    No spot yet.

    + + + + + + + + + + + + + + + + $s): ?> + + + + + + + + + + + + +
    IdCreaturePagesTriggerCodePlayersChangedActions
    (missing)' ?> + Edit +
    + + + + +
    +
    + +
    + + +
    +

    + + + +
    +
    + + > +

    Finds are recorded by spot id: keep it once players have found the spot.

    +
    +
    + + +
    +
    + + +

    Empty: no code, the hunt alone.

    +
    +
    + + +

    + Page slugs separated by spaces: index for the home page, the end of /pages/<slug> + otherwise. *: every page (a link of the menu). +

    +
    +
    + + +
    +
    + + +

    + Event: the name a page sends (test-hand: Re:Builder's « Main de départ » tab; + tournament-heroes: the heroes « Détail » of a tournament). Link: the start of its address. +

    +
    +
    +
    + + Cancel +
    +
    + + + +

    Check a player

    +
    + + + + +
    + +

    No account with this email or username.

    + + +

    + + + · / +

    +
    + + + + + + + + + +
    +
    + + +

    Found them all ()

    +
    + +

    Nobody yet.

    + + + + + + + + + + + + + + + + + + +
    PlayerEmailCompleted
    + +
    diff --git a/plugins/hidden-creatures/assets/cotton-fall.js b/plugins/hidden-creatures/assets/cotton-fall.js new file mode 100644 index 00000000..cf8991bf --- /dev/null +++ b/plugins/hidden-creatures/assets/cotton-fall.js @@ -0,0 +1,505 @@ +/* + * cotton-fall 🌾 — effet décoratif "drop-in". + * Des boules de coton (PNG) tombent lentement le long de la PAGE (pas de l'écran), + * avec dérive latérale + rotation erratiques. Au clic : swap PNG + poussée vers le + * haut + spin inversé amorti, puis retour à l'état standard. + * + * Intégration : + * + * + * + * Web Component autonome + Shadow DOM → zéro conflit de styles avec le site hôte. + * + * Copie AlteredCore (plugins/hidden-creatures), changements marqués « AlteredCore » : + * - attribut `once` : chaque boule traverse une fois puis disparaît (pas de retour en + * haut) ; la dernière partie, l'élément émet `cotton-fall-end` et se retire ; + * - attribut `spawn="viewport"` : les boules partent du haut de l'écran visible + * (au lieu d'être étalées sur toute la page) ; + * - l'overlay est replié avant chaque mesure, sinon une page qui raccourcit garde + * sa hauteur (l'overlay comptait dans scrollHeight). + */ +(() => { + 'use strict'; + + // ──────────────────────────────────────────────────────────────────────── + // 5. Bloc de constantes unique — TOUT se règle ici. + // (Chaque valeur est aussi surchargeable par attribut / objet JS, cf. §2.) + // ──────────────────────────────────────────────────────────────────────── + const SPEED = { + // --- Chute (vertical) --- + FALL_SECONDS_PER_SCREEN : 30, // 1 écran parcouru en 15 s → "lente" + + // --- Dérive latérale (horizontal) --- + DRIFT_AMPLITUDE : 0.50, // amplitude max ~12% de la largeur d'écran + DRIFT_WANDER : 0.6, // "nervosité" de la dérive (marche aléatoire amortie) + DRIFT_RESTORE : 0.1, // force de rappel vers l'intérieur (anti-sortie latérale) + + // --- Rotation standard --- + ROT_SPEED_MIN : 10, // °/s (lente) + ROT_SPEED_MAX : 24, // °/s + ROT_WOBBLE : 4, // variation aléatoire lente de la vitesse angulaire + + // --- Clic : poussée verticale --- + CLICK_PUSH_SPEED : 0.4, // impulsion vers le haut, en écrans/s + CLICK_GRAVITY : 0.4, // écrans/s² qui ramène à la vitesse de chute normale + + // --- Clic : spin --- + CLICK_SPIN_SPEED : 540, // °/s au moment du clic (~2 tours/s ≈ "rapide") + CLICK_SPIN_DAMPING: 2.5, // vitesse de retour à la rotation normale (amortissement) + }; + + const TIMING = { + CLICK_IMG_DURATION: 800, // ms d'affichage du PNG "clic" + ONCE_REDUCED_MS : 10000, // AlteredCore : durée d'affichage d'une boule `once` en reduced-motion + }; + + const LAYOUT = { + DENSITY_SCREENS_PER_BALL: 2, // ~1 boule / n hauteurs d'écran + MIN_COUNT : 1, // au moins n boule, même sur page courte + SIZE : 200, // px (taille d'affichage de référence) + SIZE_VARIATION : 0.2, // ±20% (effet de profondeur) + HIT_RADIUS : 80, // % : rayon du disque cliquable (clip circulaire → + // les coins transparents du PNG ne captent plus le clic). + // 50 = disque inscrit ; baisser (~46) pour coller à la matière. + }; + + const ASSETS = { + imgNormal : null, // URL du PNG boule de coton (placeholder si null) + imgClick : null, // URL du PNG "clic" (placeholder si null) + }; + + const A11Y = { + respectReducedMotion : true, // prefers-reduced-motion → swap PNG sans poussée/spin + pauseWhenHidden : true, // pause quand l'onglet est inactif + }; + + const Z_INDEX = 2147483000; + + // Regroupe les défauts pour la résolution de config (défauts < objet JS < attributs). + const DEFAULTS = { SPEED, TIMING, LAYOUT, ASSETS, A11Y, Z_INDEX }; + + // ──────────────────────────────────────────────────────────────────────── + // Utilitaires + // ──────────────────────────────────────────────────────────────────────── + const clamp = (v, lo, hi) => (v < lo ? lo : v > hi ? hi : v); + const rand = (lo, hi) => lo + Math.random() * (hi - lo); + const sign = (v) => (v < 0 ? -1 : 1); + + // Placeholders intégrés (SVG data-URI) pour développer/tester sans les vrais PNG. + const placeholder = (accent) => { + // Petit amas de cercles blancs flous → "boule de coton". + const svg = ` + + + + + + + + + + + + + + + + + + + + ${accent ? '' : ''} + `; + return 'data:image/svg+xml;utf8,' + encodeURIComponent(svg.trim()); + }; + const PLACEHOLDER_NORMAL = placeholder(false); + const PLACEHOLDER_CLICK = placeholder(true); + + // ──────────────────────────────────────────────────────────────────────── + // Le composant + // ──────────────────────────────────────────────────────────────────────── + class CottonFall extends HTMLElement { + constructor() { + super(); + this._shadow = this.attachShadow({ mode: 'open' }); + this._balls = []; + this._raf = 0; + this._last = 0; + this._running = false; + this._paused = false; + this._reduced = false; + + // Liaisons stables pour add/removeEventListener. + this._onFrame = this._onFrame.bind(this); + this._onResize = this._onResize.bind(this); + this._onVisibility = this._onVisibility.bind(this); + this._onMotionPref = this._onMotionPref.bind(this); + this._resizeTimer = 0; + } + + // ── Cycle de vie ────────────────────────────────────────────────────── + connectedCallback() { + this._cfg = this._readConfig(); + + // Structure Shadow DOM : un overlay (:host reste "transparent" au flux). + this._shadow.innerHTML = ` + +
    + `; + this._overlay = this._shadow.querySelector('.overlay'); + this._overlay.style.zIndex = String(this._cfg.zIndex); + // Rayon du disque cliquable (hérité par les .ball via variable CSS). + this._overlay.style.setProperty('--hit-radius', this._cfg.hitRadius + '%'); + + // Reduced motion. + this._mql = window.matchMedia('(prefers-reduced-motion: reduce)'); + this._reduced = this._cfg.respectReducedMotion && this._mql.matches; + if (this._mql.addEventListener) this._mql.addEventListener('change', this._onMotionPref); + else if (this._mql.addListener) this._mql.addListener(this._onMotionPref); // vieux Safari + + this._measure(); + this._syncCount(true); // 1re population : étalée sur la page (désynchronisée) + // AlteredCore : en reduced-motion, une boule `once` ne bouge pas → elle part après un délai. + if (this._cfg.once && this._reduced) this._endTimer = setTimeout(() => this._end(), TIMING.ONCE_REDUCED_MS); + + // Observers / listeners. + this._ro = new ResizeObserver(() => this._onResize()); + this._ro.observe(document.body); + window.addEventListener('resize', this._onResize, { passive: true }); + if (this._cfg.pauseWhenHidden) { + document.addEventListener('visibilitychange', this._onVisibility); + } + + this._start(); + } + + disconnectedCallback() { + this._stop(); + if (this._ro) this._ro.disconnect(); + window.removeEventListener('resize', this._onResize); + document.removeEventListener('visibilitychange', this._onVisibility); + if (this._mql) { + if (this._mql.removeEventListener) this._mql.removeEventListener('change', this._onMotionPref); + else if (this._mql.removeListener) this._mql.removeListener(this._onMotionPref); + } + clearTimeout(this._resizeTimer); + clearTimeout(this._endTimer); + this._balls.forEach(b => clearTimeout(b.clickTimer)); + this._balls = []; + if (this._overlay) this._overlay.innerHTML = ''; + } + + // ── Config : défauts < objet JS global < attributs (les attributs priment) ── + _readConfig() { + const g = (typeof window !== 'undefined' && window.COTTON_FALL) || {}; + const attr = (name) => (this.hasAttribute(name) ? this.getAttribute(name) : undefined); + // pick : attribut > objet JS > défaut ; `parse` convertit la chaîne d'attribut. + const pick = (attrName, jsVal, def, parse = (s) => s) => { + const a = attr(attrName); + if (a !== undefined && a !== '') return parse(a); + if (jsVal !== undefined && jsVal !== null) return jsVal; + return def; + }; + const num = (s) => Number(s); + + return { + // Assets + imgNormal : pick('img-normal', g.imgNormal, ASSETS.imgNormal) || PLACEHOLDER_NORMAL, + imgClick : pick('img-click', g.imgClick, ASSETS.imgClick) || PLACEHOLDER_CLICK, + // Layout / densité + size : pick('size', g.size, LAYOUT.SIZE, num), + sizeVariation: pick('size-variation', g.sizeVariation, LAYOUT.SIZE_VARIATION, num), + density : pick('density', g.density, LAYOUT.DENSITY_SCREENS_PER_BALL, num), + minCount : pick('min-count', g.minCount, LAYOUT.MIN_COUNT, num), + hitRadius : pick('hit-radius', g.hitRadius, LAYOUT.HIT_RADIUS, num), + // Vitesses (surchargeables) + fallSeconds : pick('fall-seconds', g.fallSeconds, SPEED.FALL_SECONDS_PER_SCREEN, num), + driftAmp : pick('drift-amplitude', g.driftAmplitude, SPEED.DRIFT_AMPLITUDE, num), + driftWander : pick('drift-wander', g.driftWander, SPEED.DRIFT_WANDER, num), + driftRestore : pick('drift-restore', g.driftRestore, SPEED.DRIFT_RESTORE, num), + rotMin : pick('rot-min', g.rotMin, SPEED.ROT_SPEED_MIN, num), + rotMax : pick('rot-max', g.rotMax, SPEED.ROT_SPEED_MAX, num), + // Divers + zIndex : pick('z-index', g.zIndex, Z_INDEX, num), + // AlteredCore + once : this.hasAttribute('once'), + spawn : pick('spawn', g.spawn, 'page'), + // A11Y + respectReducedMotion: g.respectReducedMotion !== undefined ? g.respectReducedMotion : A11Y.respectReducedMotion, + pauseWhenHidden : g.pauseWhenHidden !== undefined ? g.pauseWhenHidden : A11Y.pauseWhenHidden, + }; + } + + // ── Mesures (liées à la page, cf. §3) ───────────────────────────────── + _measure() { + const de = document.documentElement; + // AlteredCore : l'overlay replié ne compte plus dans scrollHeight (page qui raccourcit). + this._overlay.style.height = '0px'; + this._vw = window.innerWidth; // hauteur/largeur d'écran (unités relatives) + this._vh = window.innerHeight; + this._docW = de.clientWidth; // largeur du contenu (sans scrollbar) + this._docH = Math.max(de.scrollHeight, document.body.scrollHeight, this._vh); + // L'overlay épouse le document → défile avec la page. + this._overlay.style.width = this._docW + 'px'; + this._overlay.style.height = this._docH + 'px'; + } + + // ── Densité & nombre de boules (§6) ─────────────────────────────────── + _targetCount() { + const perBall = this._vh * this._cfg.density; + const n = Math.round(this._docH / perBall); + return Math.max(this._cfg.minCount, n); + } + + _syncCount(initial) { + if (this._cfg.once && !initial) return; // AlteredCore : une boule `once` ne revient pas + const target = this._targetCount(); + // Initial : étalées sur la hauteur de page. Ajouts ultérieurs : entrent par le haut. + while (this._balls.length < target) this._addBall(!initial); + while (this._balls.length > target) this._removeBall(); + } + + // Point d'ancrage horizontal tiré dans une bande INTÉRIEURE (jamais collé aux bords) : + // on garde une marge liée à l'amplitude de dérive → la boule a de la place des 2 côtés. + _pickXPref(size) { + const span = Math.max(0, this._docW - size); + const amp = this._cfg.driftAmp * this._vw; + const margin = Math.min(amp, span * 0.5); // au plus la moitié : bande toujours valide + if (span - 2 * margin <= 0) return span / 2; // page très étroite → centre + return rand(margin, span - margin); + } + + _makeBall(spawnTop) { + const size = this._cfg.size * (1 + rand(-1, 1) * this._cfg.sizeVariation); + const depth = size / this._cfg.size; // >1 : plus grosse ⇒ "plus proche" + const omegaBase = rand(this._cfg.rotMin, this._cfg.rotMax) * (Math.random() < 0.5 ? -1 : 1); + const xPref = this._pickXPref(size); + + const b = { + size, depth, omegaBase, + x: xPref, + xPref, + u: rand(-0.4, 0.4), // offset de dérive normalisé [-1,1] + uv: 0, // vitesse de dérive normalisée + y: this._spawnY(size, spawnTop), + vy: this._fallSpeed() * depth, + angle: rand(0, 360), + omega: omegaBase, + wobbleFreq: rand(0.2, 0.6), // rad/s (variation lente) + wobblePhase: rand(0, Math.PI * 2), + el: null, + clickTimer: 0, + showingClick: false, + }; + + const el = document.createElement('img'); + el.className = 'ball'; + el.src = this._cfg.imgNormal; + el.alt = ''; + el.setAttribute('aria-hidden', 'true'); + el.draggable = false; + el.style.width = size + 'px'; + el.style.height = size + 'px'; + el.addEventListener('click', (e) => { e.preventDefault(); this._onBallClick(b); }); + b.el = el; + this._render(b); + return b; + } + + // AlteredCore : `spawn="viewport"` → juste au-dessus de l'écran visible (dedans en + // reduced-motion, la boule ne bougeant pas). + _spawnY(size, spawnTop) { + if (this._cfg.spawn === 'viewport') { + return this._reduced ? window.scrollY + rand(0, Math.max(0, this._vh - size)) : window.scrollY - size; + } + return spawnTop ? -size : rand(0, this._docH); + } + + _addBall(spawnTop) { + const b = this._makeBall(spawnTop); + this._overlay.appendChild(b.el); + this._balls.push(b); + } + + _removeBall() { + const b = this._balls.pop(); + if (!b) return; + clearTimeout(b.clickTimer); + if (b.el && b.el.parentNode) b.el.parentNode.removeChild(b.el); + } + + _fallSpeed() { + return this._vh / this._cfg.fallSeconds; // px/s + } + + // ── Clic (§8) ───────────────────────────────────────────────────────── + _onBallClick(b) { + // 8.1 Swap PNG (temporisé) — conservé même en reduced-motion. + b.el.src = this._cfg.imgClick; + b.showingClick = true; + clearTimeout(b.clickTimer); + b.clickTimer = setTimeout(() => { + b.showingClick = false; + b.el.src = this._cfg.imgNormal; + }, this._cfg.timingClickImg || TIMING.CLICK_IMG_DURATION); + + if (this._reduced) return; // en reduced-motion : pas de poussée ni de spin. + + // 8.2 Poussée verticale (vers le haut, en écrans/s). + b.vy = -SPEED.CLICK_PUSH_SPEED * this._vh; + // 8.3 Spin au clic : rapide, sens opposé à la rotation courante. + b.omega = SPEED.CLICK_SPIN_SPEED * -sign(b.omega || b.omegaBase); + } + + // ── Boucle d'animation (§7) ─────────────────────────────────────────── + _start() { + if (this._running) return; + this._running = true; + this._last = 0; + this._raf = requestAnimationFrame(this._onFrame); + } + + _stop() { + this._running = false; + if (this._raf) cancelAnimationFrame(this._raf); + this._raf = 0; + } + + _onFrame(now) { + if (!this._running) return; + if (this._last === 0) this._last = now; + let dt = (now - this._last) / 1000; + this._last = now; + // Garde-fou : onglet revenu au premier plan, alt-tab… → pas de saut géant. + if (dt > 0.05) dt = 0.05; + + if (!this._reduced) this._step(dt); + + this._raf = requestAnimationFrame(this._onFrame); + } + + _step(dt) { + const fall = this._fallSpeed(); + const gravity = SPEED.CLICK_GRAVITY * this._vh; // px/s² + const amp = this._cfg.driftAmp * this._vw; // px (borne latérale) + const now = performance.now() / 1000; + + for (const b of this._balls) { + // 7.1 Vertical : gravité qui ramène vy vers la vitesse de chute (jamais au-delà). + const cruise = fall * b.depth; + b.vy = Math.min(b.vy + gravity * dt, cruise); + b.y += b.vy * dt; + + // AlteredCore : `once` → sortie en bas = fin de la boule. + if (b.y > this._docH && this._cfg.once) { + b.gone = true; + continue; + } + // Wrap : sorti en bas → respawn en haut, désynchronisé. + if (b.y > this._docH) { + b.y = -b.size; + b.vy = cruise; + b.xPref = this._pickXPref(b.size); + b.u = rand(-0.4, 0.4); + b.uv = 0; + } + + // 7.2 Latéral : marche aléatoire amortie (Ornstein-Uhlenbeck), bornée. + const noise = rand(-1, 1); + b.uv += (this._cfg.driftWander * noise - this._cfg.driftRestore * b.u) * dt; + b.uv *= (1 - Math.min(1, this._cfg.driftRestore * dt)); // amortissement + b.u += b.uv * dt; + if (b.u > 1) { b.u = 1; b.uv = Math.min(0, b.uv); } // rappel doux, pas de choc + if (b.u < -1) { b.u = -1; b.uv = Math.max(0, b.uv); } + // Amplitude effective = place réellement dispo de CE côté (rappel implicite près + // des bords) → x reste dans [0, docW-size] sans mur ni collage sur le bord. + const span = Math.max(0, this._docW - b.size); + b.xPref = clamp(b.xPref, 0, span); // sûreté si la page a rétréci + const room = Math.max(0, Math.min(amp, b.xPref, span - b.xPref)); + b.x = b.xPref + b.u * room; + + // 7.3 Rotation : omega amorti vers l'omega normal (avec wobble lent). + // Au repos → converge vers omegaNormal ; après clic → revient en douceur. + const wobble = Math.sin(now * b.wobbleFreq + b.wobblePhase) * SPEED.ROT_WOBBLE; + const omegaNormal = b.omegaBase + wobble * sign(b.omegaBase); + b.omega += (omegaNormal - b.omega) * SPEED.CLICK_SPIN_DAMPING * dt; + b.angle += b.omega * dt; + + this._render(b); + } + + if (this._cfg.once && this._balls.some(b => b.gone)) { + this._balls = this._balls.filter((b) => { + if (!b.gone) return true; + clearTimeout(b.clickTimer); + b.el.remove(); + return false; + }); + if (!this._balls.length) this._end(); + } + } + + // AlteredCore : plus aucune boule `once` → l'élément prévient et se retire. + _end() { + this.dispatchEvent(new CustomEvent('cotton-fall-end')); + this.remove(); + } + + _render(b) { + // transform seul → accélération GPU, pas de reflow (§10). + b.el.style.transform = + `translate3d(${b.x.toFixed(2)}px, ${b.y.toFixed(2)}px, 0) rotate(${b.angle.toFixed(2)}deg)`; + } + + // ── Handlers ────────────────────────────────────────────────────────── + _onResize() { + clearTimeout(this._resizeTimer); + this._resizeTimer = setTimeout(() => { + this._measure(); + this._syncCount(); + }, 120); // léger debounce + } + + _onVisibility() { + if (document.hidden) { + this._stop(); + } else if (this._cfg.pauseWhenHidden) { + this._start(); + } + } + + _onMotionPref(e) { + this._reduced = this._cfg.respectReducedMotion && e.matches; + } + } + + // Expose les défauts (introspection / réglage global éventuel). + CottonFall.DEFAULTS = DEFAULTS; + + if (!customElements.get('cotton-fall')) { + customElements.define('cotton-fall', CottonFall); + } +})(); diff --git a/plugins/hidden-creatures/assets/global_head.php b/plugins/hidden-creatures/assets/global_head.php new file mode 100644 index 00000000..59185961 --- /dev/null +++ b/plugins/hidden-creatures/assets/global_head.php @@ -0,0 +1,60 @@ + $spot) { + $creature = $config['creatures'][$spot['creature']] ?? null; + if ($creature === null || !array_intersect([$page, '*'], $spot['pages'])) continue; + hcMarkSeen((string)$id); + $spots[] = [ + 'id' => (string)$id, + 'when' => $spot['when'], + 'name' => $creature['name_' . $lang] ?: $creature['name_fr'] ?: $creature['name_en'], + 'normal' => $image($creature['normal']), + 'click' => $image($creature['click']), + ]; + } + if (!$spots) return; + + $text = [ + 'fr' => [ + 'found' => 'Trouvé : {name} ! {count}/{total}', + 'again' => '{name}, déjà trouvé ({count}/{total})', + 'code' => '· Code : {code}', + 'login' => 'Connecte-toi pour qu’il compte.', + 'copy' => 'Copier', + ], + 'en' => [ + 'found' => 'Found: {name}! {count}/{total}', + 'again' => '{name}, already found ({count}/{total})', + 'code' => '· Code: {code}', + 'login' => 'Sign in for it to count.', + 'copy' => 'Copy', + ], + ][$lang]; + $data = [ + 'spots' => $spots, + 'all' => hcSpotIds(), + 'api' => BASE_URL . '/papi/hidden-creatures/find', + 'csrf' => csrfToken(), + 'signedIn' => kcIsLoggedIn(), + 'text' => $text, + ]; + $json = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG | JSON_HEX_AMP; + $mtime = function (string $f): int { return (int)@filemtime(__DIR__ . '/' . $f); }; + ?> + + + + elements. A click reports the find (papi/find.php: recorded for a signed-in user, the spot's + * contest code) and shows the count: the account's finds when signed in, this browser's otherwise. + */ +(function () { + 'use strict'; + + var config = window.acHiddenCreatures; + if (!config || !config.spots || !config.spots.length) return; + + var STORAGE_KEY = 'acHiddenCreatures'; + + function foundIds() { + try { + var ids = JSON.parse(localStorage.getItem(STORAGE_KEY) || '[]'); + return Array.isArray(ids) ? ids : []; + } catch (e) { + return []; + } + } + + /** Records finds in this browser; true when `id` is new. */ + function markFound(id, others) { + var ids = foundIds(); + var isNew = ids.indexOf(id) === -1; + [id].concat(others || []).forEach(function (one) { if (ids.indexOf(one) === -1) ids.push(one); }); + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); } catch (e) { /* private mode: not kept */ } + return isNew; + } + + function format(template, values) { + return template.replace(/\{(\w+)\}/g, function (all, key) { return key in values ? values[key] : all; }); + } + + /** POST papi/find.php: its reply, or null (network, not seen…). */ + function report(spot) { + if (!window.fetch) return Promise.resolve(null); + return fetch(config.api, { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': config.csrf }, + body: JSON.stringify({ spot: spot.id }), + }).then(function (res) { return res.ok ? res.json() : null; }).catch(function () { return null; }); + } + + function announce(spot, reply) { + var text = config.text; + var account = reply && Array.isArray(reply.found); + // Signed in: the account's finds, kept in this browser too. Otherwise: this browser's. + var isNew = account ? reply.new : markFound(spot.id); + if (account) markFound(spot.id, reply.found); + var ids = account ? reply.found : foundIds(); + var count = ids.filter(function (id) { return config.all.indexOf(id) !== -1; }).length; + var message = format(isNew ? text.found : text.again, { name: spot.name, count: count, total: config.all.length }); + var code = reply && reply.code; + if (code) message += ' ' + format(text.code, { code: code }); + if (code && !config.signedIn) message += ' ' + text.login; + window.acToast(message, code ? { + duration: 12000, + actionLabel: text.copy, + onAction: function () { if (navigator.clipboard) navigator.clipboard.writeText(code).catch(function () {}); }, + } : { duration: 4000 }); + } + + /** A spot's creature: falling through the page in a loop, or (`once`) across the screen a single time. */ + function release(spot, once) { + var el = document.createElement('cotton-fall'); + // No image (none picked in the admin yet): the component's cotton ball. + if (spot.normal) el.setAttribute('img-normal', spot.normal); + if (spot.click) el.setAttribute('img-click', spot.click); + // One creature: a ball per 1000 screens, at least one. 200 px, less on a phone (30 % of the width). + el.setAttribute('density', '1000'); + el.setAttribute('size', String(Math.round(Math.min(200, window.innerWidth * 0.3)))); + el.setAttribute('min-count', '1'); + if (once) { + el.setAttribute('once', ''); + el.setAttribute('spawn', 'viewport'); + } + var announced = false; + // A click on the creature, retargeted from the component's shadow DOM. + el.addEventListener('click', function () { + if (announced || !window.acToast) return; + announced = true; + report(spot).then(function (reply) { announce(spot, reply); }); + }); + document.body.appendChild(el); + } + + config.spots.forEach(function (spot) { + if (spot.when === 'load') { + release(spot, false); + } else if (spot.when === 'scroll-end') { + var onScroll = function () { + var de = document.documentElement; + if (window.scrollY + window.innerHeight < de.scrollHeight - 100) return; + window.removeEventListener('scroll', onScroll); + release(spot, true); + }; + window.addEventListener('scroll', onScroll, { passive: true }); + } else if (spot.when.indexOf('event:') === 0) { + var name = spot.when.slice(6); + var onSpot = function (e) { + if (e.detail !== name) return; + window.removeEventListener('ac-hidden-spot', onSpot); + release(spot, true); + }; + window.addEventListener('ac-hidden-spot', onSpot); + } else if (spot.when.indexOf('link:') === 0) { + followLink(spot, spot.when.slice(5)); + } + }); + + /** + * `link:`: a click on a link to leaves the site, so the creature waits (localStorage) for the visitor's + * return: the next page, or this one back from the back-forward cache. A link to a new tab: here, right away. + */ + function followLink(spot, url) { + var key = STORAGE_KEY + ':waiting:' + spot.id; + var waiting = function () { try { return !!localStorage.getItem(key); } catch (e) { return false; } }; + var setWaiting = function (on) { + try { if (on) localStorage.setItem(key, '1'); else localStorage.removeItem(key); } catch (e) { /* not kept */ } + }; + var released = false; + var releaseOnce = function () { + if (released) return; + released = true; + setWaiting(false); + release(spot, true); + }; + if (waiting()) releaseOnce(); + window.addEventListener('pageshow', function (e) { if (e.persisted && waiting()) releaseOnce(); }); + document.addEventListener('click', function (e) { + var link = e.target.closest && e.target.closest('a[href]'); + if (!link || link.href.indexOf(url) !== 0) return; + if (link.target === '_blank') releaseOnce(); + else if (!released) setWaiting(true); + }, true); + } +})(); diff --git a/plugins/hidden-creatures/e2e/fixtures.php b/plugins/hidden-creatures/e2e/fixtures.php new file mode 100644 index 00000000..ab22fcac --- /dev/null +++ b/plugins/hidden-creatures/e2e/fixtures.php @@ -0,0 +1,21 @@ + { + const all = await page.evaluate(() => (window as unknown as { acHiddenCreatures?: Config }).acHiddenCreatures?.all ?? []); + test.skip(!all.includes('e2e-load'), 'hidden-creatures or its e2e fixtures are not on this stack'); +} + +/** The creature of the page: an in 's shadow DOM. Clicked by event, it never stops moving. */ +const creature = (page: Page) => page.locator('cotton-fall img.ball'); + +type Reply = { code: string | null; new?: boolean; found?: string[] }; + +async function clickCreature(page: Page): Promise { + const reply = page.waitForResponse((res) => res.url().endsWith(FIND)); + await creature(page).first().dispatchEvent('click'); + const res = await reply; + expect(res.status()).toBe(200); + return (await res.json()) as Reply; +} + +test('a visitor finds a creature, counted in their browser, with the spot\'s code', async ({ page }, testInfo) => { + await page.goto(LOAD_PAGE); + await skipWithoutFixtures(page); + await expect(creature(page)).toHaveCount(1); + const normal = await creature(page).getAttribute('src'); + + const reply = await clickCreature(page); + expect(reply).toEqual({ code: 'E2E-CODE' }); + const toast = page.locator('.ac-toast'); + await expect(toast).toContainText(new RegExp(`^Trouvé : ${NAME} ! 1/\\d+ · Code : E2E-CODE Connecte-toi`)); + await expect(toast.getByRole('button', { name: 'Copier' })).toBeVisible(); + // The clicked image (here the cotton ball's own). + await expect(creature(page)).not.toHaveAttribute('src', normal!); + await evidence(page, testInfo, 'hidden-creatures-found'); + + // Again on the next visit: already found, same count. + await page.reload(); + await clickCreature(page); + await expect(toast).toContainText(new RegExp(`^${NAME}, déjà trouvé \\(1/\\d+\\)`)); +}); + +test('a spot only counts once its page was shown', async ({ page }) => { + await page.goto(LOAD_PAGE); + await skipWithoutFixtures(page); + const status = await page.evaluate(async (url) => { + const { csrf } = (window as unknown as { acHiddenCreatures: Config }).acHiddenCreatures; + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf }, + body: JSON.stringify({ spot: 'e2e-test-hand' }), + }); + return res.status; + }, FIND); + expect(status).toBe(409); +}); + +test('an event spot: the creature crosses the screen when the page sends the event', async ({ page }) => { + await page.goto(EVENT_PAGE); + await skipWithoutFixtures(page); + await expect(creature(page)).toHaveCount(0); + await page.evaluate(() => window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'e2e' }))); + await expect(creature(page)).toHaveCount(1); +}); + +test('a link spot: the creature is there when the visitor comes back from the site', async ({ page, context }) => { + await context.route('https://example.test/**', (route) => route.fulfill({ contentType: 'text/html', body: '

    Elsewhere

    ' })); + await page.goto(EVENT_PAGE); + await skipWithoutFixtures(page); + const addLink = (target: string) => page.evaluate((t) => { + const a = Object.assign(document.createElement('a'), { href: 'https://example.test/', textContent: 'Elsewhere', target: t }); + a.id = 'e2e-link'; + document.body.append(a); + }, target); + + // Same tab: on the site again, back from the other one. + await addLink(''); + await page.locator('#e2e-link').dispatchEvent('click'); + await page.waitForURL('https://example.test/'); + await page.goBack(); + await expect(creature(page)).toHaveCount(1); + // Released once: not on the next page. + await page.goto(EVENT_PAGE); + await expect(creature(page)).toHaveCount(0); + + // A new tab: right away on the page the visitor left. + await addLink('_blank'); + const popup = page.waitForEvent('popup'); + await page.locator('#e2e-link').dispatchEvent('click'); + await (await popup).close(); + await expect(creature(page)).toHaveCount(1); +}); + +test('a signed-in player finds the Test hand creature, then another: recorded on the account', async ({ page, compact }) => { + await setBeta(page, true); + await login(page, 'alice', LOAD_PAGE); + await skipWithoutFixtures(page); + const id = await page.goto('/pages/decks?lang=fr').then(() => createServerDeck(page, `E2E creatures ${Date.now()}`)); + + // Not there before the tab opens. + await page.goto(`/pages/deck?id=${id}`); + await expect(page.locator('app-deck-page')).toBeVisible(); + await expect(creature(page)).toHaveCount(0); + if (compact) await page.getByRole('navigation', { name: 'Consultation du deck' }).getByRole('link', { name: 'Main' }).click(); + else await page.getByRole('tab', { name: 'Main de départ' }).click(); + await expect(creature(page)).toHaveCount(1); + const fromTestHand = await clickCreature(page); + expect(fromTestHand.found).toContain('e2e-test-hand'); + + await page.goto(LOAD_PAGE); + const fromNews = await clickCreature(page); + expect(fromNews.found).toEqual(expect.arrayContaining(['e2e-load', 'e2e-test-hand'])); + const total = await page.evaluate(() => (window as unknown as { acHiddenCreatures: Config }).acHiddenCreatures.all.length); + // « Trouvé : … ! 2/4 · Code » the first time, « …, déjà trouvé (2/4) · Code » on a later run (alice keeps her finds). + await expect(page.locator('.ac-toast__message')).toHaveText(new RegExp(`${fromNews.found!.length}/${total}\\)? · Code : E2E-CODE$`)); +}); diff --git a/plugins/hidden-creatures/inc/functions.php b/plugins/hidden-creatures/inc/functions.php new file mode 100644 index 00000000..9216364b --- /dev/null +++ b/plugins/hidden-creatures/inc/functions.php @@ -0,0 +1,187 @@ + name_fr, name_en, normal, click, updated_at) and their spots (id => creature, pages, when, + * code, updated_at), read once per request. Empty when the tables do not exist (plugin files without its install). + */ +function hcConfig(bool $reload = false): array { + static $config = null; + if ($config !== null && !$reload) return $config; + $config = ['creatures' => [], 'spots' => []]; + try { + $db = getDB(); + foreach ($db->query(hcq("SELECT * FROM {creatures} ORDER BY id")) as $c) { + $config['creatures'][$c['id']] = [ + 'name_fr' => $c['name_fr'], + 'name_en' => $c['name_en'], + 'normal' => $c['image_normal'], + 'click' => $c['image_click'], + 'updated_at' => $c['updated_at'], + ]; + } + foreach ($db->query(hcq("SELECT * FROM {spots} ORDER BY id")) as $s) { + $config['spots'][$s['id']] = [ + 'creature' => $s['creature_id'], + 'pages' => hcSplitPages($s['pages']), + 'when' => $s['rule'], + 'code' => $s['code'], + 'updated_at' => $s['updated_at'], + ]; + } + } catch (PDOException $e) { + // Tables not installed: no spots. + } + return $config; +} + +function hcSplitPages(string $pages): array { + return array_values(array_filter(preg_split('/[\s,]+/', trim($pages)) ?: [], 'strlen')); +} + +/** Spot ids: the counter's total. */ +function hcSpotIds(): array { + return array_map('strval', array_keys(hcConfig()['spots'])); +} + +/** The session saw this spot's page (global_head.php): a find can only count after that. */ +function hcMarkSeen(string $spot): void { + $_SESSION['hc_seen'][$spot] = time(); +} + +function hcSeen(string $spot): bool { + return !empty($_SESSION['hc_seen'][$spot]); +} + +/** spot => code, for the spots that have one. */ +function hcSpotCodes(): array { + return array_filter(array_map(function (array $s): string { return $s['code']; }, hcConfig()['spots']), 'strlen'); +} + +// ─── Admin: creatures and spots ───────────────────────────────────────────── + +function hcValidId(string $id): bool { + return (bool)preg_match('/^[a-z0-9][a-z0-9-]{0,63}$/', $id); +} + +/** Errors of a spot form, [] when valid. `rule` is the stored form: load, scroll-end, event:, link:. */ +function hcSpotErrors(string $id, string $creature, array $pages, string $rule, string $code): array { + $errors = []; + if (!hcValidId($id)) $errors[] = 'Spot id: lowercase letters, digits and dashes.'; + if (!isset(hcConfig()['creatures'][$creature])) $errors[] = 'Unknown creature.'; + if (!$pages) $errors[] = 'At least one page.'; + foreach ($pages as $page) { + if ($page !== '*' && !preg_match('/^[a-z0-9_-]+$/', $page)) $errors[] = "Page « $page »: a slug (index, news-detail…) or *."; + } + [$type, $value] = array_pad(explode(':', $rule, 2), 2, ''); + if (!in_array($type, HC_RULES, true)) $errors[] = 'Unknown trigger.'; + if ($type === 'event' && !preg_match('/^[a-z0-9-]+$/', $value)) $errors[] = 'Event name: lowercase letters, digits and dashes.'; + if ($type === 'link' && !preg_match('#^https?://\S+$#', $value)) $errors[] = 'Link: an address starting with http:// or https://.'; + if (mb_strlen($code) > 100) $errors[] = 'Code: 100 characters at most.'; + return $errors; +} + +function hcSaveSpot(string $id, string $creature, array $pages, string $rule, string $code): void { + getDB()->prepare(hcq( + "INSERT INTO {spots} (id, creature_id, pages, rule, code) VALUES (:id, :c, :p, :r, :code)" + . " ON DUPLICATE KEY UPDATE creature_id = VALUES(creature_id), pages = VALUES(pages), rule = VALUES(rule), code = VALUES(code)" + ))->execute([':id' => $id, ':c' => $creature, ':p' => implode(' ', $pages), ':r' => $rule, ':code' => $code]); + hcConfig(true); +} + +function hcDeleteSpot(string $id): void { + getDB()->prepare(hcq("DELETE FROM {spots} WHERE id = :id"))->execute([':id' => $id]); + hcConfig(true); +} + +function hcSaveCreature(string $id, string $nameFr, string $nameEn, string $normal, string $click): void { + getDB()->prepare(hcq( + "INSERT INTO {creatures} (id, name_fr, name_en, image_normal, image_click) VALUES (:id, :fr, :en, :n, :c)" + . " ON DUPLICATE KEY UPDATE name_fr = VALUES(name_fr), name_en = VALUES(name_en)," + . " image_normal = VALUES(image_normal), image_click = VALUES(image_click)" + ))->execute([':id' => $id, ':fr' => $nameFr, ':en' => $nameEn, ':n' => $normal, ':c' => $click]); + hcConfig(true); +} + +/** Deletes a creature no spot uses; false otherwise. */ +function hcDeleteCreature(string $id): bool { + foreach (hcConfig()['spots'] as $spot) { + if ($spot['creature'] === $id) return false; + } + getDB()->prepare(hcq("DELETE FROM {creatures} WHERE id = :id"))->execute([':id' => $id]); + hcConfig(true); + return true; +} + +/** + * An image picked in the media library (uploads/…), made a square WebP of HC_IMAGE_SIZE px in uploads/hidden-creatures/ + * when GD can: the stored path. An image made here already, or one GD cannot read or write, is kept as it is; a path + * outside uploads/ gives ''. + */ +function hcPrepareImage(string $path, string $creature, string $variant): string { + $path = ltrim(trim($path), '/'); + if ($path === '') return ''; + $root = dirname(__DIR__, 3); + $uploads = realpath($root . '/uploads'); + $source = realpath($root . '/' . $path); + if (!$uploads || !$source || strpos($source, $uploads . DIRECTORY_SEPARATOR) !== 0 || !is_file($source)) return ''; + if (preg_match('#^uploads/' . HC_IMAGE_FOLDER . '/[a-z0-9-]+-(normal|click)-[0-9a-f]{8}\.webp$#', $path)) return $path; + if (!function_exists('imagewebp') || !function_exists('imagecreatefromstring')) return $path; + + $image = @imagecreatefromstring((string)file_get_contents($source)); + if (!$image) return $path; + $w = imagesx($image); + $h = imagesy($image); + $scale = min(1, HC_IMAGE_SIZE / max($w, $h)); + $dw = max(1, (int)round($w * $scale)); + $dh = max(1, (int)round($h * $scale)); + $out = imagecreatetruecolor(HC_IMAGE_SIZE, HC_IMAGE_SIZE); + imagealphablending($out, false); + imagesavealpha($out, true); + imagefill($out, 0, 0, imagecolorallocatealpha($out, 0, 0, 0, 127)); + // Centred in the square, transparent around a non-square image. + imagecopyresampled($out, $image, (int)((HC_IMAGE_SIZE - $dw) / 2), (int)((HC_IMAGE_SIZE - $dh) / 2), 0, 0, $dw, $dh, $w, $h); + + $dir = $root . '/uploads/' . HC_IMAGE_FOLDER; + if (!is_dir($dir) && !@mkdir($dir, 0755, true)) return $path; + $name = $creature . '-' . $variant . '-' . substr(md5($path . filemtime($source)), 0, 8) . '.webp'; + $ok = imagewebp($out, $dir . '/' . $name, 85); + imagedestroy($image); + imagedestroy($out); + return $ok ? 'uploads/' . HC_IMAGE_FOLDER . '/' . $name : $path; +} + +// ─── Finds ────────────────────────────────────────────────────────────────── + +/** A user's finds, spot => found_at, for the spots that still exist. */ +function hcUserFinds(int $userId): array { + $stmt = getDB()->prepare(hcq("SELECT spot, found_at FROM {finds} WHERE user_id = :u ORDER BY found_at")); + $stmt->execute([':u' => $userId]); + $finds = []; + foreach ($stmt as $row) { + if (in_array($row['spot'], hcSpotIds(), true)) $finds[$row['spot']] = $row['found_at']; + } + return $finds; +} + +/** Records a find; true the first time. */ +function hcRecordFind(int $userId, string $spot): bool { + $stmt = getDB()->prepare(hcq("INSERT IGNORE INTO {finds} (user_id, spot) VALUES (:u, :spot)")); + $stmt->execute([':u' => $userId, ':spot' => $spot]); + return $stmt->rowCount() > 0; +} diff --git a/plugins/hidden-creatures/papi/find.php b/plugins/hidden-creatures/papi/find.php new file mode 100644 index 00000000..f8d4df10 --- /dev/null +++ b/plugins/hidden-creatures/papi/find.php @@ -0,0 +1,28 @@ + 'unknown_spot']); + return; +} +if (!hcSeen($_hcSpot)) { + http_response_code(409); + echo json_encode(['error' => 'not_seen']); + return; +} + +$_hcReply = ['code' => (hcSpotCodes()[$_hcSpot] ?? '') ?: null]; +$_hcUserId = kcIsLoggedIn() ? (int)($_SESSION['user_id'] ?? 0) : 0; +if ($_hcUserId > 0) { + $_hcReply['new'] = hcRecordFind($_hcUserId, $_hcSpot); + $_hcReply['found'] = array_keys(hcUserFinds($_hcUserId)); +} +echo json_encode($_hcReply, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); diff --git a/plugins/hidden-creatures/plugin.json b/plugins/hidden-creatures/plugin.json new file mode 100644 index 00000000..f48463d0 --- /dev/null +++ b/plugins/hidden-creatures/plugin.json @@ -0,0 +1,35 @@ +{ + "$schema": "../plugin.schema.json", + "id": "hidden-creatures", + "name": "Hidden Creatures", + "version": "1.0.0", + "description": "Illustrations hidden on some pages of the site, falling through the page; a click counts the find (recorded for signed-in players) and shows the spot's contest code. Creatures, spots, codes and finds in the admin.", + "author": "AlteredCore", + "icon": "sparkles", + "table_prefix": "hc", + + "api": [ + { "endpoint": "find", "file": "papi/find.php", "methods": ["POST"] } + ], + + "admin": [ + { + "section": "hidden-creatures", + "file": "admin/hunt.php", + "label_en": "Hidden creatures", + "label_fr": "Créatures cachées", + "icon": "sparkles" + } + ], + + "assets": { + "global_php": ["assets/global_head.php"] + }, + + "sql": "sql/install.sql", + "tables": ["creatures", "spots", "finds"], + + "tests": { + "e2e": "e2e" + } +} diff --git a/plugins/hidden-creatures/sql/install.sql b/plugins/hidden-creatures/sql/install.sql new file mode 100644 index 00000000..9171eaa4 --- /dev/null +++ b/plugins/hidden-creatures/sql/install.sql @@ -0,0 +1,26 @@ +CREATE TABLE IF NOT EXISTS {creatures} ( + `id` VARCHAR(64) NOT NULL PRIMARY KEY, + `name_fr` VARCHAR(100) NOT NULL DEFAULT '', + `name_en` VARCHAR(100) NOT NULL DEFAULT '', + `image_normal` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'uploads/… ; empty: the component''s cotton ball', + `image_click` VARCHAR(255) NOT NULL DEFAULT '', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS {spots} ( + `id` VARCHAR(64) NOT NULL PRIMARY KEY, + `creature_id` VARCHAR(64) NOT NULL, + `pages` VARCHAR(500) NOT NULL DEFAULT '' COMMENT 'page slugs separated by spaces, * for all', + `rule` VARCHAR(255) NOT NULL DEFAULT 'load' COMMENT 'load | scroll-end | event: | link:', + `code` VARCHAR(100) NOT NULL DEFAULT '' COMMENT 'contest code shown on a find', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + KEY `creature_id` (`creature_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + +CREATE TABLE IF NOT EXISTS {finds} ( + `user_id` INT NOT NULL, + `spot` VARCHAR(64) NOT NULL, + `found_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `spot`), + KEY `spot` (`spot`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/plugins/ownership/api/alt-art-leave-global.php b/plugins/ownership/api/alt-art-leave-global.php new file mode 100644 index 00000000..f659800d --- /dev/null +++ b/plugins/ownership/api/alt-art-leave-global.php @@ -0,0 +1,64 @@ +", …]} +// POST /papi/ownership/alt-art-leave-global {"deck": ""} → {"changed": bool} +// « Global » → « par deck » (pages/alt-arts.php): the page lists the player's decks, writes the defaults in each one +// (what Board Game Arena showed of it, see includes/func.alt-arts.php) and drops its brush choices, then sets the mode +// (api/alt-art-preference-mode.php) even when some decks could not be written: best effort. +// A deck is only written while the player is in « Global » (409 otherwise). Manifest: "auth": "user". +require_once __DIR__ . '/../includes/functions.php'; +require_once dirname(__DIR__, 3) . '/includes/func.alt-arts.php'; +require_once dirname(__DIR__, 3) . '/includes/func.keycloak.php'; + +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: private, no-store'); + +if (!defined('OWNERSHIP_API_URL') || !OWNERSHIP_API_URL) { + http_response_code(503); + echo json_encode(['error' => 'ownership']); + return; +} + +$userId = (int)($_SESSION['user_id'] ?? 0); +$token = kc_get_access_token($userId); +if (!$token) { + http_response_code(401); + echo json_encode(['error' => 'token']); + return; +} +// The page sends four decks at a time: the session's lock would run them one after the other. +session_write_close(); + +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { + $ids = altArtUserDeckIds($token); + if ($ids === null) { + http_response_code(502); + echo json_encode(['error' => 'decks']); + return; + } + echo json_encode(['decks' => $ids]); + return; +} + +$deckId = pluginApiBody()['deck'] ?? ''; +if (!is_string($deckId) || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $deckId)) { + http_response_code(400); + echo json_encode(['error' => 'deck']); + return; +} + +// Read from the service, not the session's cache: outside « Global », apply-to-deck would only swap the prints not owned. +[, $mode] = altArtHttp('GET', rtrim(OWNERSHIP_API_URL, '/') . '/api/alt-arts/preference-mode', $token); +if (($mode['mode'] ?? null) !== 'Global') { + http_response_code(409); + echo json_encode(['error' => 'mode']); + return; +} + +$changed = altArtWriteDefaultsInDeck($token, $deckId); +if ($changed === null) { + http_response_code(502); + echo json_encode(['error' => 'write']); + return; +} +altArtForgetDeck($userId, $deckId); +echo json_encode(['changed' => $changed]); diff --git a/plugins/ownership/api/alt-art-preference-mode.php b/plugins/ownership/api/alt-art-preference-mode.php index 39725e6d..c13b085e 100644 --- a/plugins/ownership/api/alt-art-preference-mode.php +++ b/plugins/ownership/api/alt-art-preference-mode.php @@ -1,8 +1,9 @@ 'Unauthorized']); - exit; -} - $userId = (int)($_SESSION['user_id'] ?? 0); -$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; -if ($method === 'GET') { +if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'GET') { echo json_encode(['mode' => ownGetAltArtPreferenceMode($userId)]); exit; } -if ($method !== 'PUT') { - http_response_code(405); - echo json_encode(['error' => 'Method not allowed']); - exit; -} - -$body = json_decode(file_get_contents('php://input'), true); -if (!is_array($body)) $body = []; - -if (!csrfValid($body['csrf_token'] ?? null)) { - http_response_code(403); - echo json_encode(['error' => 'Invalid CSRF token']); - exit; -} - -$mode = $body['mode'] ?? null; +$mode = pluginApiBody()['mode'] ?? null; if (!in_array($mode, ['PerDeck', 'Global'], true)) { http_response_code(400); echo json_encode(['error' => 'mode must be "PerDeck" or "Global"']); exit; } -[$status, $raw] = ownApiRequestRaw('PUT', '/api/alt-arts/preference-mode', $userId, ['mode' => $mode]); - +[$status] = ownApiRequestRaw('PUT', '/api/alt-arts/preference-mode', $userId, ['mode' => $mode]); if ($status !== 204) { http_response_code($status ?: 502); echo json_encode(['error' => 'Ownership API error']); diff --git a/plugins/ownership/api/alt-art-search.php b/plugins/ownership/api/alt-art-search.php index d5b077b7..d1aa6a01 100644 --- a/plugins/ownership/api/alt-art-search.php +++ b/plugins/ownership/api/alt-art-search.php @@ -39,17 +39,6 @@ if (is_string($ty) && $ty !== '') $types[] = $ty; } -// PerDeck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead (see AltArtPreferenceMode). Enforced here, -// not just by which filter buttons pages/alt-arts.php renders, so a caller can't bypass -// it by requesting a non-token type directly. -if (!ownIsAltArtGlobalMode($userId)) { - $types = array_values(array_filter($types, fn($ty) => strpos($ty, 'TOKEN') === 0)); - if (!$types) { - $types = ['TOKEN', 'TOKEN_LANDMARK_PERMANENT', 'TOKEN_MANA']; - } -} - foreach ($types as $ty) { $parts[] = 'type[]=' . rawurlencode($ty); } diff --git a/plugins/ownership/assets/style.css b/plugins/ownership/assets/style.css index 5837bc56..dcfa23a0 100644 --- a/plugins/ownership/assets/style.css +++ b/plugins/ownership/assets/style.css @@ -94,11 +94,16 @@ } .own-opener-card altered-card, -.own-tilt-card altered-card { +.own-tilt-card altered-card, +.own-opener-card > img { display: block; width: 100%; } +.own-opener-card > img { + border-radius: var(--ac-radius-dialog); +} + .own-card-pending { transform: scale(0.8); } @@ -331,95 +336,288 @@ --ac-icon-size: 48px; } -/* Alt Arts BGA — one row per multi-art family, tiles instead of a paginated grid. - .filter-row/.filter-toggle/.card-altered come from core-altered-cards/assets/style.css, - linked explicitly by pages/alt-arts.php since this plugin doesn't load it by default. */ -/* 4 tiles per row on desktop, 2 on mobile — a family's strip is its own little grid, - wrapping to as many rows as it needs; the
    after it (see js/alt-arts.js) is what - visually separates one family from the next. */ -.own-aa-strip { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: var(--ac-space-2-5); - margin-bottom: var(--ac-space-1-5); +/* Default alt arts (pages/alt-arts.php): one row per multi-art family, its name and what decks take on the + left (its cards fanned out), its illustrations under their keys on the right. */ +.own-aa-list { + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + overflow: hidden; } -/* Wide screens: tiles keep the size they had in the 1200px layout (4 across 1176px of - content) and the strip fits as many as the row allows — auto-fill rather than auto-fit - so every family's tiles stay the same size, even a 2-art family. */ -@media (min-width: 1500px) { - .own-aa-strip { - grid-template-columns: repeat(auto-fill, minmax(calc(1176px / 4 - var(--ac-space-2-5)), 1fr)); +.own-aa-list:empty { + display: none; +} +.own-aa-family { + --chevron: 44px; + display: flex; + flex-wrap: wrap; + align-items: stretch; + gap: var(--ac-space-4) 0; + padding: var(--ac-space-5) var(--ac-space-6); + border-bottom: 1px solid var(--ac-color-divider); +} +.own-aa-family:last-child { + border-bottom: 0; +} +.own-aa-family__head { + flex: 1 1 100%; + display: flex; + flex-direction: column; + gap: var(--ac-space-1); +} +/* As Re:Builder's brush: the choices on a band ending in a chevron, which fits into the notch of what decks take + (centred on its right); on a phone, one under the other, the chevron pointing down. */ +.own-aa-family > .own-aa-row, +.own-aa-family__info { + position: relative; + isolation: isolate; + box-sizing: border-box; +} +.own-aa-family > .own-aa-row::before, +.own-aa-family__info::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; +} +.own-aa-family > .own-aa-row { + padding: var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)) var(--ac-space-4) var(--ac-space-5); +} +.own-aa-family > .own-aa-row::before { + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-primary-tint); + clip-path: polygon(0 0, calc(100% - var(--chevron)) 0, 100% 50%, calc(100% - var(--chevron)) 100%, 0 100%); +} +.own-aa-family__info { + flex: 0 1 330px; + margin-left: calc(var(--ac-space-4) - var(--chevron)); + padding: var(--ac-space-4) var(--ac-space-5) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)); + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--ac-space-1); +} +.own-aa-family__info::before { + border-radius: 0 var(--ac-radius-card) var(--ac-radius-card) 0; + background: var(--ac-color-bg-app); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--chevron) 50%); +} +.own-aa-family__name { + margin: 0; + font: var(--ac-font-heading); + color: var(--ac-color-text); +} +.own-aa-family__meta { + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.own-aa-family__uses { + font: var(--ac-font-caption); + color: var(--ac-color-text-2); +} +/* The family's cards, 1st on the left, fanned out as in Re:Builder's brush; a token's or a hero's single card large. + Each card opens large on a click. */ +.own-aa-fan { + --card: 100px; + --step: 64px; + position: relative; + height: calc(var(--card) * 7 / 5 + var(--ac-space-5)); + margin-left: var(--ac-space-2); +} +.own-aa-fan--single { + --card: 164px; +} +.own-aa-fan__card { + position: absolute; + top: var(--ac-space-2); + left: calc(var(--i) * var(--step)); + width: var(--card); + padding: 0; + border: 0; + border-radius: var(--ac-radius-sm); + background: none; + cursor: zoom-in; + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)); + transform-origin: 50% 120%; + transition: transform var(--ac-duration-fast); +} +.own-aa-fan__card img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: inherit; + box-shadow: var(--ac-shadow-card); +} +.own-aa-fan__card:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + z-index: 1; +} +@media (hover: hover) { + .own-aa-fan__card:hover { + z-index: 1; + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)) translateY(calc(-1 * var(--ac-space-1))); } } +.own-aa-family > .own-aa-row { + flex: 999 1 420px; + min-width: 0; +} @media (max-width: 767.98px) { - .own-aa-strip { - grid-template-columns: repeat(2, 1fr); - gap: var(--ac-space-1-5); + .own-aa-family { + padding: var(--ac-space-4); + } + .own-aa-family { + --chevron: 28px; + } + .own-aa-family > .own-aa-row { + flex-basis: 100%; + padding: var(--ac-space-4) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-4)); + } + .own-aa-family > .own-aa-row::before { + border-radius: var(--ac-radius-card) var(--ac-radius-card) 0 0; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--chevron)), 50% 100%, 0 calc(100% - var(--chevron))); + } + .own-aa-family__info { + flex-basis: 100%; + margin: calc(var(--ac-space-3) - var(--chevron) - var(--ac-space-4)) 0 0; + padding: calc(var(--chevron) + var(--ac-space-4)) var(--ac-space-5) var(--ac-space-5); + align-items: center; + text-align: center; + } + /* Centred under the chevron: as wide as its cards. */ + .own-aa-fan { + width: calc(var(--card) + (var(--n, 1) - 1) * var(--step)); + margin-left: 0; + } + .own-aa-family__info::before { + border-radius: 0 0 var(--ac-radius-card) var(--ac-radius-card); + clip-path: polygon(0 0, 50% var(--chevron), 100% 0, 100% 100%, 0 100%); } } -.own-aa-tile { + +/* One family's illustrations, each under its keys, on one line that scrolls sideways (js/alt-art-widget.js; also in + the card modals). An arrow on each side while there is more that way. */ +.own-aa-scroller { position: relative; - min-width: 0; - cursor: pointer; } -/* Direct child only — .own-aa-tile also nests the marker s one level deeper - inside .own-aa-markers, and a descendant selector here would out-specify - .own-aa-marker's own width and blow the markers up to the tile's full size. */ -.own-aa-tile > img { +.own-aa-strip { + display: flex; + gap: var(--ac-space-5); + overflow-x: auto; + overscroll-behavior-x: contain; + scroll-snap-type: x mandatory; + scroll-padding-inline: var(--ac-space-1); + padding: var(--ac-space-1); + margin: calc(-1 * var(--ac-space-1)); + scrollbar-width: thin; +} +.own-aa-strip > .own-aa-print { + flex: none; + scroll-snap-align: start; +} +/* The illustration cut at an edge fades out while there is more that way (any opaque colour: the mask uses alpha). */ +.own-aa-scroller { + --fade-start: 0px; + --fade-end: 0px; +} +.own-aa-scroller--more-start { + --fade-start: min(96px, 10%); +} +.own-aa-scroller--more-end { + --fade-end: min(96px, 10%); +} +.own-aa-scroller--more-start .own-aa-strip, +.own-aa-scroller--more-end .own-aa-strip { + -webkit-mask-image: linear-gradient(to right, transparent, var(--ac-color-text) var(--fade-start), var(--ac-color-text) calc(100% - var(--fade-end)), transparent); + mask-image: linear-gradient(to right, transparent, var(--ac-color-text) var(--fade-start), var(--ac-color-text) calc(100% - var(--fade-end)), transparent); +} +.own-aa-scroller__arrow { + position: absolute; + top: 50%; + z-index: 2; + transform: translateY(-50%); + border-radius: var(--ac-radius-pill); + box-shadow: var(--ac-shadow-e2); +} +.own-aa-scroller__arrow[hidden] { + display: none; +} +.own-aa-scroller__arrow--prev { + left: calc(-1 * var(--ac-space-3)); +} +.own-aa-scroller__arrow--next { + right: calc(-1 * var(--ac-space-3)); +} +.own-aa-print { + width: 200px; + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + text-align: center; +} +.own-aa-keys { + align-self: stretch; + margin-bottom: var(--ac-space-1-5); +} +.own-aa-print__art { display: block; width: 100%; - border-radius: var(--ac-radius-md); - aspect-ratio: 63.5/88; + margin-bottom: var(--ac-space-1-5); + aspect-ratio: 63.5 / 88; object-fit: cover; - transition: transform var(--ac-duration-fast); + border-radius: var(--ac-radius-md); background: var(--ac-color-track); + transition: transform var(--ac-duration-fast); } -.own-aa-tile:hover > img { - transform: scale(1.03); +.own-aa-print__zoom { + display: block; + width: 100%; + padding: 0; + border: 0; + border-radius: var(--ac-radius-md); + background: none; + cursor: zoom-in; +} +.own-aa-print__zoom:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; } -.own-aa-tile--unowned { - cursor: not-allowed; +@media (hover: hover) { + .own-aa-print__zoom:hover .own-aa-print__art { + transform: translateY(calc(-1 * var(--ac-space-1))); + } } -.own-aa-tile--unowned > img { +.own-aa-print--unowned .own-aa-print__art { opacity: .35; filter: grayscale(1); } -.own-aa-tile--unowned:hover > img { - transform: none; -} -.own-aa-markers { - position: absolute; - top: var(--ac-space-1); - right: var(--ac-space-1); - z-index: 2; -} -/* Positioned by --slot (the owned copy's slotIndex, set in JS) rather than stacked in flex/DOM - order — that height stays the same copy's height on every tile, so a lone marker on a tile - shows at a glance which copy (1st/2nd/3rd, i.e. which deck slot — see distributeAcrossSlots - in deckbuilder.php) it is, instead of always looking like "the first one" at the top. */ -.own-aa-marker { - position: absolute; - top: calc(var(--slot, 0) * 22px); - right: 0; - width: 20px; - height: auto; - cursor: pointer; - filter: drop-shadow(0 1px 2px var(--ac-color-overlay-label)); +.own-aa-print__label { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); } -.own-aa-marker--active { - outline: 2px solid var(--ac-color-primary); - border-radius: var(--ac-radius-xs); +.own-aa-print__note { + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); } .own-aa-row-error { - margin-top: var(--ac-space-1); + margin-top: var(--ac-space-2); + font: var(--ac-font-small); + color: var(--ac-color-danger); } -.own-aa-sep { - border: none; - border-top: 1px solid var(--ac-color-divider); - margin: var(--ac-space-3) 0; +/* Two illustrations in view on a phone (and a bit of the next one). */ +@media (max-width: 767.98px) { + .own-aa-strip { + gap: var(--ac-space-4); + } + .own-aa-strip > .own-aa-print { + width: calc((100% - var(--ac-space-4)) / 2.2); + } } -/* Per-deck vs. global alt-art mode toggle + its explanation panel (pages/alt-arts.php) */ +/* Window over the page (the legacy deckbuilder's token illustrations, assets/deckbuilder/token-arts.js) */ .own-aa-modal-overlay { position: fixed; inset: 0; @@ -437,9 +635,6 @@ overflow-y: auto; box-shadow: var(--ac-shadow-dialog); } -.own-aa-modal .ac-card__meta { - margin-bottom: var(--ac-space-3); -} /* Mode switch bar and search field (pages/alt-arts.php) */ .own-aa-modebar { diff --git a/plugins/ownership/e2e/alt-arts.spec.ts b/plugins/ownership/e2e/alt-arts.spec.ts new file mode 100644 index 00000000..fbce73c1 --- /dev/null +++ b/plugins/ownership/e2e/alt-arts.spec.ts @@ -0,0 +1,103 @@ +import { evidence, expect, login, test, type Page } from '../../../tests/e2e/fixtures'; + +const FEE = { base: 'ALT_CORE_B_AX_09_C', alt: 'ALT_MUSUBI_B_AX_09_C' }; +const SCARABOT = { base: 'ALT_CORE_B_AX_31_C', alt: 'ALT_CORE_A_AX_31_C' }; + +/** + * The page's search (`/papi/ownership/alt-art-search`) answers two families: Fée Clochette (the plain print and + * an alt art owned once, the three cards on the plain print) and the Scarabot token (one card). The choices saved + * (`/papi/ownership/alt-art-set-preference`) are kept here and answered without the service (204, or `answer`). + */ +async function routeAltArts(page: Page, answer: { status: number; json?: unknown } = { status: 204 }): Promise { + const family = (familyId: number, cardType: string, name: string, refs: { base: string; alt: string }, cards: number) => ({ + family: { familyId, faction: 'AX', rarity: 'C', cardType, name, reference: refs.base }, + options: { + familyId, + faction: 'AX', + rarity: 'C', + options: [ + { reference: refs.base, ownedQuantity: null }, + { reference: refs.alt, ownedQuantity: 1 }, + ], + slots: Array.from({ length: cards }, (_, i) => ({ slotIndex: i + 1, reference: refs.base, isExplicitChoice: false })), + }, + }); + const fee = family(1, 'CHARACTER', 'Fée Clochette', FEE, 3); + const scarabot = family(2, 'TOKEN', 'Scarabot', SCARABOT, 1); + await page.route('**/papi/ownership/alt-art-search**', (route) => + route.fulfill({ + json: { + families: [fee.family, scarabot.family], + options: { '1:AX:C': fee.options, '2:AX:C': scarabot.options }, + hasMore: false, + }, + }), + ); + const saved: string[][] = []; + await page.route('**/papi/ownership/alt-art-set-preference', async (route) => { + saved.push((route.request().postDataJSON() as { slotReferences: string[] }).slotReferences); + await route.fulfill(answer); + }); + return saved; +} + +test('chooses the default alt arts with the keys above each illustration', { tag: '@mobile' }, async ({ page }, testInfo) => { + const saved = await routeAltArts(page); + await login(page, 'alice', '/pages/ownership-alt-arts?lang=fr'); + const fee = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Fée Clochette' }) }); + const key = (n: number, card: string) => fee.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + // The three cards take the plain print. + for (const card of ['1ère carte', '2ème carte', '3ème carte']) { + await expect(key(1, card)).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, card)).toHaveAttribute('aria-pressed', 'false'); + } + // Per deck, the defaults go to cards added to a deck; in « Global » (the player's mode, kept by the service), to every deck. + const global = await page.evaluate(() => (window as unknown as { OWN_AA_CONFIG: { isGlobalMode: boolean } }).OWN_AA_CONFIG.isGlobalMode); + await expect(fee.getByText(global ? 'Tous vos decks utilisent' : 'Une carte ajoutée à un deck prend')).toBeVisible(); + // The 1st card takes the alt art: saved, and the fan shows it first. + await key(2, '1ère carte').click(); + await expect.poll(() => saved.at(-1)).toEqual([FEE.alt, FEE.base, FEE.base]); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.alt)); + await expect(fee.getByRole('button', { name: 'Agrandir 1ère carte : Illustration 2' })).toBeVisible(); + // A card of the fan, or an illustration, opens large as on the cards pages. + const zoom = page.locator('#card-modal'); + await fee.getByRole('button', { name: 'Agrandir 1ère carte : Illustration 2' }).click(); + await expect(zoom).toBeVisible(); + await expect(zoom.locator('img').first()).toHaveAttribute('src', new RegExp(FEE.alt)); + await expect(zoom.getByRole('link', { name: 'Accéder au détail' })).toBeVisible(); + await evidence(page, testInfo, '41-default-alt-arts-zoom'); + await page.keyboard.press('Escape'); + await expect(zoom).toBeHidden(); + await fee.getByRole('button', { name: 'Agrandir Illustration 1' }).click(); + await expect(zoom.locator('img').first()).toHaveAttribute('src', new RegExp(FEE.base)); + await page.keyboard.press('Escape'); + // Its only copy owned is on the 1st card: the other cards cannot take it. + await expect(key(2, '2ème carte')).toBeDisabled(); + await expect(key(2, '3ème carte')).toBeDisabled(); + // A token has one key an illustration. + const scarabot = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Scarabot' }) }); + await expect(scarabot.getByText('Tous vos decks utilisent')).toBeVisible(); + const token = scarabot.getByRole('group', { name: 'Cartes de Illustration 2' }).getByRole('button'); + await token.click(); + await expect.poll(() => saved.at(-1)).toEqual([SCARABOT.alt]); + await expect(token).toHaveAccessibleName('Choisie'); + await expect(token).toHaveAttribute('aria-pressed', 'true'); + await fee.scrollIntoViewIfNeeded(); + await evidence(page, testInfo, '40-default-alt-arts-keys'); +}); + +test('puts the keys back when the service refuses a choice', async ({ page }) => { + const saved = await routeAltArts(page, { status: 409, json: [{ reference: FEE.alt, requested: 2, owned: 1 }] }); + await login(page, 'alice', '/pages/ownership-alt-arts?lang=fr'); + const fee = page.getByRole('article').filter({ has: page.getByRole('heading', { name: 'Fée Clochette' }) }); + const key = (n: number, card: string) => fee.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + await key(2, '1ère carte').click(); + await expect.poll(() => saved.at(-1)).toEqual([FEE.alt, FEE.base, FEE.base]); + // Refused: the 1st card takes the plain print again, the fan with it, and the reason is shown. + await expect(fee.getByRole('alert')).toContainText(`${FEE.alt} (2/1)`); + await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); + await expect(fee.locator('.own-aa-fan img').first()).toHaveAttribute('src', new RegExp(FEE.base)); +}); diff --git a/plugins/ownership/includes/subnav.php b/plugins/ownership/includes/subnav.php index 0990b973..6fb86912 100644 --- a/plugins/ownership/includes/subnav.php +++ b/plugins/ownership/includes/subnav.php @@ -14,8 +14,8 @@ // physical-collection tab elsewhere on the site (core-altered-cards/pages/collection.php); // keeping "Digital Ownership"/"Propriété numérique" here avoids the two being confused. $ownSubnavTxt = [ - 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Alt Arts BGA', 'import' => 'Equinox Import'], - 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Alt Arts BGA', 'import' => 'Import Equinox'], + 'en' => ['collection' => 'Digital Ownership', 'boosters' => 'Boosters', 'history' => 'History', 'altArts' => 'Default alt arts', 'import' => 'Equinox Import'], + 'fr' => ['collection' => 'Propriété numérique', 'boosters' => 'Boosters', 'history' => 'Historique', 'altArts' => 'Arts alternatifs par défaut', 'import' => 'Import Equinox'], ][getUiLang()]; $ownBoosterCount = null; diff --git a/plugins/ownership/js/alt-art-widget.js b/plugins/ownership/js/alt-art-widget.js index b9d5477f..eeaa72d0 100644 --- a/plugins/ownership/js/alt-art-widget.js +++ b/plugins/ownership/js/alt-art-widget.js @@ -1,6 +1,6 @@ -// Reusable single-family alt-art marker widget: renders one interactive tile strip -// (one tile per illustration of a card family) with the player's copy markers, -// click-to-move, optimistic UI with server-authoritative rollback on a 409 shortfall. +// Reusable single-family alt-art widget: one illustration per print of a card family, each under its +// keys (the 1st, 2nd and 3rd card, design system `ac-keys`), optimistic UI with server-authoritative +// rollback on a 409 shortfall. // Extracted from pages/alt-arts.php's per-family rendering so the exact same logic can // be embedded in card-detail modals (see card-modal-enhance.js), scoped to one family // instead of a paginated multi-family search. @@ -41,76 +41,174 @@ return cfg.cdnUrl + '/cards/' + cfg.lang + '/' + (p[1] || '') + '/' + ref + '.webp'; }; - // Builds one `.own-aa-row` for a single family. cfg = {cdnUrl, lang, markerImg, - // setPreferenceUrl, csrfToken, txt:{saveError}}. Returns {el, destroy()}. + // Texts of the widget, in the page's language (the card modals that embed it pass only a save error). + const TXT = { + fr: { + cards: ['1ère carte', '2ème carte', '3ème carte'], suffixes: ['ère', 'ème', 'ème'], card: 'carte', + choose: 'Choisir', chosen: 'Choisie', illustration: 'Illustration %s', cardsOf: 'Cartes de %s', + unlimited: 'Illimitée', notOwned: 'Non possédée', ownedOne: '1 possédée', ownedMany: '%s possédées', colon: ' : ', zoom: 'Agrandir %s', previous: 'Illustrations précédentes', next: 'Illustrations suivantes', + }, + en: { + cards: ['1st card', '2nd card', '3rd card'], suffixes: ['st', 'nd', 'rd'], card: 'card', + choose: 'Choose', chosen: 'Chosen', illustration: 'Illustration %s', cardsOf: 'Cards of %s', + unlimited: 'Unlimited', notOwned: 'Not owned', ownedOne: '1 owned', ownedMany: '%s owned', colon: ': ', zoom: 'Enlarge %s', previous: 'Previous illustrations', next: 'Next illustrations', + }, + }; + const texts = () => TXT[(document.documentElement.lang || 'en').slice(0, 2)] || TXT.en; + + // Builds one `.own-aa-row` for a single family: each illustration under its keys, the 1st, 2nd and 3rd card + // (one key for a token). A key pressed is a card that takes the illustration; pressing another gives it that + // card. cfg = {cdnUrl, lang, setPreferenceUrl, csrfToken, txt:{saveError}, onChange(references, labels)?, onZoom(reference)?}. + // Returns {el, destroy()}. function createFamilyRow(family, optData, cfg) { const groupKey = family.familyId + ':' + family.faction + ':' + family.rarity; + const txt = texts(); - const state = { - slots: optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex) - .map((s) => ({ slotIndex: s.slotIndex, reference: s.reference })), - // Rotation starts at slot 1 so successive clicks fill slots 1, 2, 3 in order. - activeSlot: optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex)[0].slotIndex, - }; + // One slot a card (1 for a token), 1st first. + const slots = optData.slots.slice().sort((a, b) => a.slotIndex - b.slotIndex) + .map((s) => ({ slotIndex: s.slotIndex, reference: s.reference })); + const single = slots.length === 1; // Last slot references the server confirmed -- what a failed save rolls back to. - let savedRefs = state.slots.map((s) => s.reference); - - const renderTile = (opt) => { - const tile = document.createElement('div'); - tile.className = 'own-aa-tile'; - tile.dataset.ref = opt.reference; - tile.dataset.group = groupKey; - const unowned = opt.ownedQuantity !== null && opt.ownedQuantity === 0; - if (unowned) tile.classList.add('own-aa-tile--unowned'); - - const img = document.createElement('img'); - img.src = cdnUrl(cfg, opt.reference); - img.loading = 'lazy'; - img.alt = ''; - tile.appendChild(img); - - const markers = document.createElement('div'); - markers.className = 'own-aa-markers'; - tile.appendChild(markers); - return tile; - }; + let savedRefs = slots.map((s) => s.reference); const row = document.createElement('div'); row.className = 'own-aa-row'; row.dataset.group = groupKey; + // The illustrations on one line that scrolls sideways, an arrow on each side while there is more that way. + const scroller = document.createElement('div'); + scroller.className = 'own-aa-scroller'; const strip = document.createElement('div'); strip.className = 'own-aa-strip'; - strip.dataset.group = groupKey; - optData.options.forEach((opt) => strip.appendChild(renderTile(opt))); - row.appendChild(strip); + const arrow = (dir, icon, label) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'ac-icon-button own-aa-scroller__arrow own-aa-scroller__arrow--' + dir; + btn.setAttribute('aria-label', label); + btn.innerHTML = window.acIcon ? window.acIcon(icon) : (dir === 'prev' ? '‹' : '›'); + btn.hidden = true; + btn.addEventListener('click', () => { + strip.scrollBy({ left: (dir === 'prev' ? -1 : 1) * strip.clientWidth * 0.8, behavior: 'smooth' }); + }); + return btn; + }; + const prev = arrow('prev', 'chevron-left', txt.previous); + const next = arrow('next', 'chevron-right', txt.next); + scroller.append(prev, strip, next); + row.appendChild(scroller); + const updateArrows = () => { + const max = strip.scrollWidth - strip.clientWidth; + prev.hidden = strip.scrollLeft <= 1; + next.hidden = strip.scrollLeft >= max - 1; + // The cut illustration fades out on the side where there is more. + scroller.classList.toggle('own-aa-scroller--more-start', !prev.hidden); + scroller.classList.toggle('own-aa-scroller--more-end', !next.hidden); + }; + strip.addEventListener('scroll', updateArrows, { passive: true }); + const resizeObserver = typeof ResizeObserver === 'function' ? new ResizeObserver(updateArrows) : null; + if (resizeObserver) resizeObserver.observe(strip); const err = document.createElement('div'); - err.className = 'own-aa-row-error text-danger small'; + err.className = 'own-aa-row-error'; + err.setAttribute('role', 'alert'); err.hidden = true; row.appendChild(err); const showError = (message) => { err.textContent = message; err.hidden = false; }; const clearError = () => { err.hidden = true; }; - // Moves each slot's marker into whichever tile currently holds its reference. - const applyMarkers = () => { - strip.querySelectorAll('.own-aa-markers').forEach((el) => { el.innerHTML = ''; }); - state.slots.forEach((slot) => { - const tile = strip.querySelector('.own-aa-tile[data-ref="' + CSS.escape(slot.reference) + '"]'); - if (!tile) return; - const marker = document.createElement('img'); - marker.className = 'own-aa-marker' + (slot.slotIndex === state.activeSlot ? ' own-aa-marker--active' : ''); - marker.src = cfg.markerImg; - marker.dataset.slot = String(slot.slotIndex); - marker.dataset.group = groupKey; - marker.style.setProperty('--slot', slot.slotIndex); - marker.alt = ''; - tile.querySelector('.own-aa-markers').appendChild(marker); + // A card can take this illustration unless the copies owned of it are all on other cards. + const canTake = (opt, index) => opt.ownedQuantity === null + || slots.filter((s, i) => i !== index && s.reference === opt.reference).length < opt.ownedQuantity; + + const noteOf = (opt) => opt.ownedQuantity === null ? txt.unlimited + : opt.ownedQuantity === 0 ? txt.notOwned + : opt.ownedQuantity === 1 ? txt.ownedOne : txt.ownedMany.replace('%s', opt.ownedQuantity); + + const keyButton = (opt, index) => { + const key = document.createElement('button'); + key.type = 'button'; + key.className = 'ac-key'; + key.dataset.ref = opt.reference; + key.dataset.slot = String(index); + if (single) { + // Named by its text (« Choisir » / « Choisie ») within the group « Cartes de Illustration n ». + key.innerHTML = ''; + } else { + key.setAttribute('aria-label', txt.cards[index] || (index + 1) + txt.suffixes[2] + ' ' + txt.card); + key.innerHTML = '' + (index + 1) + '' + (txt.suffixes[index] || txt.suffixes[2]) + + '' + txt.card + ''; + } + return key; + }; + + optData.options.forEach((opt, i) => { + const label = txt.illustration.replace('%s', i + 1); + const print = document.createElement('div'); + print.className = 'own-aa-print'; + if (opt.ownedQuantity === 0) print.classList.add('own-aa-print--unowned'); + + const keys = document.createElement('div'); + keys.className = 'ac-keys own-aa-keys'; + keys.style.setProperty('--ac-keys-count', String(slots.length)); + keys.setAttribute('role', 'group'); + keys.setAttribute('aria-label', txt.cardsOf.replace('%s', label)); + slots.forEach((s, index) => keys.appendChild(keyButton(opt, index))); + print.appendChild(keys); + + const img = document.createElement('img'); + img.className = 'own-aa-print__art'; + img.src = cdnUrl(cfg, opt.reference); + img.loading = 'lazy'; + img.alt = ''; + // With a zoom from the host page, the illustration opens large. + if (cfg.onZoom) { + const zoom = document.createElement('button'); + zoom.type = 'button'; + zoom.className = 'own-aa-print__zoom'; + zoom.setAttribute('aria-label', txt.zoom.replace('%s', label)); + zoom.addEventListener('click', () => cfg.onZoom(opt.reference)); + zoom.appendChild(img); + print.appendChild(zoom); + } else { + print.appendChild(img); + } + + const name = document.createElement('span'); + name.className = 'own-aa-print__label'; + name.textContent = label; + print.appendChild(name); + const note = document.createElement('span'); + note.className = 'own-aa-print__note'; + note.textContent = noteOf(opt); + print.appendChild(note); + + strip.appendChild(print); + }); + + const options = new Map(optData.options.map((o) => [o.reference, o])); + const illustrationOf = (ref) => txt.illustration.replace('%s', optData.options.findIndex((o) => o.reference === ref) + 1); + + // What each card takes, in words (« 1ère carte : Illustration 2 »), and how to name its zoom. + const describe = () => slots.map((s, i) => { + const text = single ? illustrationOf(s.reference) : (txt.cards[i] || '') + txt.colon + illustrationOf(s.reference); + return { text, zoom: txt.zoom.replace('%s', text) }; + }); + + // Each key pressed when its card takes the key's illustration, disabled when it cannot take it. + const render = () => { + strip.querySelectorAll('.ac-key').forEach((key) => { + const index = parseInt(key.dataset.slot, 10); + const opt = options.get(key.dataset.ref); + const on = slots[index].reference === opt.reference; + key.setAttribute('aria-pressed', on ? 'true' : 'false'); + key.disabled = !on && !canTake(opt, index); + if (single) key.querySelector('.ac-key__label').textContent = on ? txt.chosen : txt.choose; }); + if (cfg.onChange) cfg.onChange(slots.map((s) => s.reference), describe()); }; - applyMarkers(); + render(); const saveGroup = async (slotReferences) => { const res = await fetch(cfg.setPreferenceUrl, { @@ -138,11 +236,9 @@ throw new Error(saveErrorMsg); }; - // One save in flight at a time, always sending the latest marker state: firing a - // request per click let two quick clicks race server-side, so the stored slots - // could end up on the older click while the markers showed the newer one (e.g. - // both top markers on an alt art, yet a 2-copy deck got one alt + one base). - // Clicks made while a save is in flight are coalesced into one follow-up save. + // One save in flight at a time, always sending the latest key state: firing a request per click let two + // quick clicks race server-side, so the stored slots could end up on the older click while the keys showed + // the newer one. Clicks made while a save is in flight are coalesced into one follow-up save. let saving = false; let savePending = false; const requestSave = async () => { @@ -151,7 +247,7 @@ let lastError = null; do { savePending = false; - const refs = state.slots.map((s) => s.reference); + const refs = slots.map((s) => s.reference); try { await saveGroup(refs); savedRefs = refs; @@ -163,8 +259,8 @@ saving = false; if (lastError) { - state.slots.forEach((s, i) => { s.reference = savedRefs[i]; }); - applyMarkers(); + slots.forEach((s, i) => { s.reference = savedRefs[i]; }); + render(); showError(lastError.message); return; } @@ -176,55 +272,26 @@ })); }; - // After a successful move, the active slot advances to the next one in sequence - // (wrapping around) so consecutive clicks on different cards spread across all the - // player's markers instead of always redirecting the same one. Explicitly clicking - // a marker still overrides this and pins that slot as the next to move. - const advanceActiveSlot = () => { - const idx = state.slots.findIndex((s) => s.slotIndex === state.activeSlot); - const next = state.slots[(idx + 1) % state.slots.length]; - state.activeSlot = next.slotIndex; - }; - - const moveActiveMarker = (newRef) => { - // The active slot is only a preferred starting point, not a hard requirement: if - // it's already on the clicked tile (e.g. a 2/1 split where the active marker sits - // on the minority tile the player just clicked), moving it would be a no-op even - // though another marker elsewhere clearly isn't on this tile yet. So walk the - // rotation from the active slot and grab the first marker that isn't already here. - const startIdx = state.slots.findIndex((s) => s.slotIndex === state.activeSlot); - let slot = null; - for (let i = 0; i < state.slots.length; i++) { - const candidate = state.slots[(startIdx + i) % state.slots.length]; - if (candidate.reference !== newRef) { slot = candidate; break; } - } - if (!slot) return; // every marker is already on this tile — nothing to move - - slot.reference = newRef; - state.activeSlot = slot.slotIndex; - advanceActiveSlot(); - applyMarkers(); + // A key: its card takes its illustration. + const onClick = (e) => { + const key = e.target.closest('.ac-key'); + if (!key) return; + const slot = slots[parseInt(key.dataset.slot, 10)]; + if (!slot || slot.reference === key.dataset.ref) return; + slot.reference = key.dataset.ref; + render(); clearError(); requestSave(); }; - - const onClick = (e) => { - const marker = e.target.closest('.own-aa-marker'); - if (marker) { - state.activeSlot = parseInt(marker.dataset.slot, 10); - applyMarkers(); - return; - } - const tile = e.target.closest('.own-aa-tile'); - if (!tile || tile.classList.contains('own-aa-tile--unowned')) return; - moveActiveMarker(tile.dataset.ref); - }; row.addEventListener('click', onClick); - const destroy = () => { row.removeEventListener('click', onClick); }; + const destroy = () => { + row.removeEventListener('click', onClick); + if (resizeObserver) resizeObserver.disconnect(); + }; return { el: row, destroy }; } - window.OWN_ALT_ART_WIDGET = { hasRealChoice, fetchSingleFamily, createFamilyRow }; + window.OWN_ALT_ART_WIDGET = { hasRealChoice, fetchSingleFamily, createFamilyRow, cdnUrl }; })(); diff --git a/plugins/ownership/js/alt-arts.js b/plugins/ownership/js/alt-arts.js index f9cefb4e..7e30a0a0 100644 --- a/plugins/ownership/js/alt-arts.js +++ b/plugins/ownership/js/alt-arts.js @@ -14,7 +14,6 @@ const widgetCfg = { cdnUrl: cfg.cdnUrl, lang: cfg.lang, - markerImg: cfg.markerImg, setPreferenceUrl: cfg.setPreferenceUrl, csrfToken: cfg.csrfToken, txt: { saveError: t('saveError', 'Could not save your choice.') }, @@ -165,20 +164,76 @@ loadMoreBtn?.addEventListener('click', renderMore); // ---- Rendering ---- + const labelOf = (map, code) => (cfg.labels && cfg.labels[map] && cfg.labels[map][code]) || code; + + // One family: its name, its illustrations under their keys, then what decks take (its cards fanned out, the 1st on + // the left). const renderFamilyRow = (family) => { const key = groupKeyOf(family.familyId, family.faction, family.rarity); const optData = optionsByKey[key]; - const wrap = document.createElement('div'); - if (!optData || !optData.options || !optData.options.length) return wrap; // shouldn't happen - - const widgetRow = window.OWN_ALT_ART_WIDGET.createFamilyRow(family, optData, widgetCfg); - wrap.appendChild(widgetRow.el); - - const sep = document.createElement('hr'); - sep.className = 'own-aa-sep'; - wrap.appendChild(sep); - return wrap; + const row = document.createElement('article'); + row.className = 'own-aa-family'; + if (!optData || !optData.options || !optData.options.length) return row; // shouldn't happen + + const info = document.createElement('div'); + info.className = 'own-aa-family__info'; + const name = document.createElement('h2'); + name.className = 'own-aa-family__name'; + name.textContent = family.name || family.reference || ''; + const meta = document.createElement('span'); + meta.className = 'own-aa-family__meta'; + meta.textContent = [labelOf('type', family.cardType), labelOf('faction', family.faction), labelOf('rarity', family.rarity)] + .filter(Boolean).join(' · '); + const uses = document.createElement('span'); + uses.className = 'own-aa-family__uses'; + uses.textContent = optData.slots.length === 1 || cfg.isGlobalMode + ? t('usesAll', 'All your decks use') + : t('usesNew', 'A card added to a deck takes'); + const fan = document.createElement('div'); + fan.className = 'own-aa-fan'; + if (optData.slots.length === 1) fan.classList.add('own-aa-fan--single'); + // The card's name on top; its illustrations and their keys on the left, what decks take on the right (as in + // Re:Builder's brush: the choices, then the result). + const head = document.createElement('div'); + head.className = 'own-aa-family__head'; + head.append(name, meta); + info.append(uses, fan); + row.appendChild(head); + + // A card large, as on the cards pages (pages/_card-zoom-modal.php of core-altered-cards). + const zoom = (ref) => { + if (window.acOpenCardZoom) window.acOpenCardZoom(ref, false, cfg.lang, window.OWN_ALT_ART_WIDGET.cdnUrl(cfg, ref)); + }; + + // One button a card, made once; a change only swaps their images and names. + const drawFan = (references, labels) => { + fan.style.setProperty('--n', references.length); + references.forEach((ref, i) => { + let card = fan.children[i]; + if (!card) { + card = document.createElement('button'); + card.type = 'button'; + card.className = 'own-aa-fan__card'; + card.style.setProperty('--i', i); + const img = document.createElement('img'); + img.alt = ''; + img.loading = 'lazy'; + card.appendChild(img); + card.addEventListener('click', () => zoom(card.dataset.ref)); + fan.appendChild(card); + } + card.dataset.ref = ref; + card.setAttribute('aria-label', labels[i].zoom); + const img = card.firstChild; + const src = window.OWN_ALT_ART_WIDGET.cdnUrl(cfg, ref); + if (img.getAttribute('src') !== src) img.src = src; + }); + }; + + const widgetRow = window.OWN_ALT_ART_WIDGET.createFamilyRow(family, optData, { ...widgetCfg, onChange: drawFan, onZoom: zoom }); + row.append(widgetRow.el, info); + return row; }; runSearch(); diff --git a/plugins/ownership/js/boosters.js b/plugins/ownership/js/boosters.js index 5c6c6939..830e0db8 100644 --- a/plugins/ownership/js/boosters.js +++ b/plugins/ownership/js/boosters.js @@ -32,6 +32,15 @@ return rendererLoading; }; + // Same rarity test as core-altered-cards/assets/card-search.js: the 6th segment of the + // reference starts with "U" for a Unique (ALT_CORE_B_AX_106_U_530). + const isUniqueRef = (ref) => ((ref || '').split('_')[5] || '').charAt(0) === 'U'; + + const cardImageUrl = (ref) => { + const lang = locale(); + return (window.OWN_CDN_URL || '') + '/cards/' + lang + '/' + (ref.split('_')[1] || '') + '/' + ref + '.webp'; + }; + const loadingEl = document.getElementById('own-boosters-loading'); const emptyEl = document.getElementById('own-boosters-empty'); const errorEl = document.getElementById('own-boosters-error'); @@ -204,10 +213,15 @@ } const card = opened[0]; - if (card) await ensureCardRenderer(); - cardEl.innerHTML = card - ? '' - : ''; + // Only Uniques go through (it rebuilds them from the uniques API); + // every other card is a plain CDN image, like card-search.js does. + const unique = card && isUniqueRef(card.cardReference); + if (unique) await ensureCardRenderer(); + cardEl.innerHTML = !card + ? '' + : unique + ? '' + : ''; const cardArtEl = cardEl.firstElementChild; if (card) await window.OWN_CARD_TILT.waitForReady(cardEl); openerLoadingEl.hidden = true; diff --git a/plugins/ownership/js/card-tilt.js b/plugins/ownership/js/card-tilt.js index a32d1596..b04692c5 100644 --- a/plugins/ownership/js/card-tilt.js +++ b/plugins/ownership/js/card-tilt.js @@ -79,11 +79,20 @@ window.OWN_CARD_TILT = (() => { }; // Resolves once hostEl contains a (the web component's - // internal render target) or timeoutMs elapses, whichever comes first — so callers - // can wait for a just-mounted to actually have something to tilt - // before calling attach() on it. + // internal render target) or a loaded (plain cards), or timeoutMs elapses, + // whichever comes first — so callers can wait for a just-mounted card to actually + // have something to tilt before calling attach() on it. const waitForReady = (hostEl, timeoutMs = 4000) => new Promise((resolve) => { const settle = () => requestAnimationFrame(() => requestAnimationFrame(resolve)); + const img = hostEl.querySelector(':scope > img'); + if (img) { + if (img.complete) { settle(); return; } + const timer = setTimeout(resolve, timeoutMs); + const done = () => { clearTimeout(timer); settle(); }; + img.addEventListener('load', done, { once: true }); + img.addEventListener('error', done, { once: true }); + return; + } if (hostEl.querySelector('canvas')) { settle(); return; } const observer = new MutationObserver(() => { if (!hostEl.querySelector('canvas')) return; diff --git a/plugins/ownership/pages/alt-arts.php b/plugins/ownership/pages/alt-arts.php index ea4032f5..43596f62 100644 --- a/plugins/ownership/pages/alt-arts.php +++ b/plugins/ownership/pages/alt-arts.php @@ -1,13 +1,15 @@ 'Network error.', 'saveError' => 'Could not save your choice.', 'loadMore' => 'Load more', - 'markerHint' => 'Click a marker, then click a card to move it there.', - 'modePerDeck' => 'Set per deck', - 'modeGlobal' => 'Set globally', - 'modeInfoBtn' => 'What\'s the difference?', - 'modeInfoTitle' => 'Per deck vs. global illustrations', - 'modePerDeckTitle' => 'Per deck (default)', - 'modePerDeckDesc' => "Clicking a card no longer shows the illustration preference. Only tokens can be changed on this page. In the deckbuilder, you choose each card's and the hero's illustration individually while building the deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => 'Your illustration preferences are set here or by clicking a card, and apply automatically everywhere: opening a deck to edit it, duplicating it, importing it, retrieving it from BGA, and whenever you change a card\'s quantity. The deckbuilder no longer lets you change illustrations card by card, and there is no "apply preferences" button — it always applies.', - 'modeClose' => 'Close', - 'modeSaving' => 'Saving…', + 'keysHint' => 'Click a box above an illustration to give it that card.', + 'intro' => 'Choose the illustration of the 1st, 2nd and 3rd card of each card: your default alt arts. A token has a single illustration, the same in all your decks.', + 'usesNew' => 'A card added to a deck takes', + 'usesAll' => 'All your decks use', + 'results' => 'Cards', + 'detail_label' => 'View detail', + 'modeLabel' => 'How your decks use them', + 'modePerDeck' => 'Per deck', + 'modeGlobal' => 'Global', + 'modePerDeckDesc' => 'A card added to a deck in the deck builder takes your default alt arts. Each deck then keeps its own illustrations, which you can change card by card for that deck only. Board Game Arena shows each deck\'s illustrations.', + 'modeGlobalDesc' => 'Your default alt arts apply to all your decks, on this site and on Board Game Arena, whenever a deck is opened or changed. Illustrations cannot be changed deck by deck.', + 'toGlobalTitle' => 'Switch to global mode?', + 'toGlobalText' => 'All your decks will follow your default alt arts. The illustrations chosen deck by deck will be replaced.', + 'toGlobalConfirm' => 'Switch to global', + 'toPerDeckTitle' => 'Switch to per-deck mode?', + 'toPerDeckText' => 'Your default alt arts will be written in all your decks. Each deck then keeps its illustrations, which you can change in the deck builder.', + 'toPerDeckConfirm' => 'Switch to per deck', + 'cancel' => 'Cancel', + 'progress' => 'Decks updated: %1$s / %2$s', 'modeSaveError' => 'Could not save your setting.', - 'perDeckNotice' => 'Per-deck mode is on: only token illustrations are managed here. Regular cards\' illustrations are chosen individually in each deck, in the deckbuilder.', ], 'fr' => [ 'unavailable' => 'Le service de propriété numérique n\'est pas configuré sur ce site.', @@ -57,19 +66,26 @@ 'networkError' => 'Erreur réseau.', 'saveError' => 'Impossible d\'enregistrer votre choix.', 'loadMore' => 'Charger plus', - 'markerHint' => 'Cliquez un marqueur, puis une carte pour l\'y déplacer.', - 'modePerDeck' => 'Définir par deck', - 'modeGlobal' => 'Définir au global', - 'modeInfoBtn' => 'Quelle est la différence ?', - 'modeInfoTitle' => 'Illustrations par deck ou au global', - 'modePerDeckTitle' => 'Par deck (par défaut)', - 'modePerDeckDesc' => "Le changement des préférences d'une carte n'est pas visible lorsque l'on clique sur une carte. Seuls les jetons sont modifiables sur cette page. Dans le deckbuilder, vous choisissez l'illustration de chaque carte et du héros individuellement pendant la construction du deck.", - 'modeGlobalTitle' => 'Global', - 'modeGlobalDesc' => "Vos préférences d'illustration sont modifiables ici ou en cliquant sur une carte, et s'appliquent automatiquement partout : à l'ouverture d'un deck pour le modifier, à sa duplication, à son import, à sa récupération depuis BGA, et à chaque changement du nombre d'exemplaires d'une carte. Le deckbuilder ne permet plus de changer les illustrations carte par carte, et il n'y a plus de bouton \"appliquer mes préférences\" — c'est toujours appliqué.", - 'modeClose' => 'Fermer', - 'modeSaving' => 'Enregistrement…', + 'keysHint' => 'Cliquez sur une case au-dessus d\'une illustration pour la lui attribuer.', + 'intro' => 'Choisissez l\'illustration de la 1ère, 2ème et 3ème carte de chaque carte : vos arts alternatifs par défaut. Un jeton a une seule illustration, la même dans tous vos decks.', + 'usesNew' => 'Une carte ajoutée à un deck prend', + 'usesAll' => 'Tous vos decks utilisent', + 'results' => 'Cartes', + 'detail_label' => 'Accéder au détail', + 'modeLabel' => 'Utilisation dans vos decks', + 'modePerDeck' => 'Par deck', + 'modeGlobal' => 'Global', + 'modePerDeckDesc' => 'Une carte ajoutée à un deck dans le deck builder prend vos arts par défaut. Chaque deck garde ensuite ses propres illustrations, que vous pouvez changer carte par carte pour ce deck seulement. Board Game Arena montre les illustrations de chaque deck.', + 'modeGlobalDesc' => 'Vos arts par défaut s\'appliquent à tous vos decks, sur ce site et sur Board Game Arena, à chaque ouverture et à chaque modification d\'un deck. Les illustrations ne se changent pas deck par deck.', + 'toGlobalTitle' => 'Passer en mode global ?', + 'toGlobalText' => 'Tous vos decks vont suivre vos arts par défaut. Les illustrations choisies deck par deck seront remplacées.', + 'toGlobalConfirm' => 'Passer en mode global', + 'toPerDeckTitle' => 'Passer en mode par deck ?', + 'toPerDeckText' => 'Vos arts par défaut vont être écrits dans tous vos decks. Chaque deck garde ensuite ses illustrations, que vous pouvez changer dans le deck builder.', + 'toPerDeckConfirm' => 'Passer en mode par deck', + 'cancel' => 'Annuler', + 'progress' => 'Decks mis à jour : %1$s / %2$s', 'modeSaveError' => 'Impossible d\'enregistrer votre choix.', - 'perDeckNotice' => 'Le mode par deck est actif : seuls les jetons sont gérés ici. Les illustrations des cartes normales se choisissent individuellement dans chaque deck, dans le deckbuilder.', ], ][getUiLang()]; @@ -78,8 +94,7 @@ $ownActiveTab = 'alt-arts'; $userId = (int)($_SESSION['user_id'] ?? 0); -$altArtMode = ($ownEnabled && $ownLoggedIn) ? ownGetAltArtPreferenceMode($userId) : 'PerDeck'; -$isGlobalMode = $altArtMode === 'Global'; +$isGlobalMode = $ownEnabled && $ownLoggedIn && ownGetAltArtPreferenceMode($userId) === 'Global'; $cacDir = dirname(__DIR__, 2) . '/core-altered-cards'; $cacAvailable = is_file($cacDir . '/includes/functions.php'); @@ -100,13 +115,6 @@ // the type filter. $raritiesData = array_filter($raritiesData, fn($r) => ($r['gem'] ?? '') !== 'U'); -// Per-deck mode: this page only manages token illustrations — regular card alt-arts are -// chosen per deck in the deckbuilder instead. Enforced again server-side in -// api/alt-art-search.php; this is just what the type-filter row offers. -if (!$isGlobalMode) { - $typesData = array_filter($typesData, fn($code) => strpos($code, 'TOKEN') === 0, ARRAY_FILTER_USE_KEY); -} - $uiLang = getUiLang(); ?>
    @@ -130,39 +138,41 @@ .filter-toggle below come from it. --> -
    -
    - - -
    - -
    +

    - -
    - - - + +
    +

    +
    +
    +

    + + +
    + +
    +
    -

    +

    -
    +
    diff --git a/plugins/ownership/pages/boosters.php b/plugins/ownership/pages/boosters.php index 8fe3c4c5..ddf8badf 100644 --- a/plugins/ownership/pages/boosters.php +++ b/plugins/ownership/pages/boosters.php @@ -78,7 +78,8 @@
    - + diff --git a/plugins/ownership/pages/ownership.php b/plugins/ownership/pages/ownership.php index 112ccb77..2468e7f9 100644 --- a/plugins/ownership/pages/ownership.php +++ b/plugins/ownership/pages/ownership.php @@ -12,8 +12,8 @@ 'boosters_remaining' => '%d remaining', 'history' => 'History', 'history_d' => 'View every transaction that changed your digital ownership.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choose which illustration is used for each copy you play on Board Game Arena.', + 'altArts' => 'Default alt arts', + 'altArts_d' => 'Choose the illustration each copy of a card takes when you add it to a deck.', 'import' => 'Equinox Import', 'import_d' => 'Request and/or import your digital ownership from the Equinox site (a secure site).', 'login_prompt' => 'Log in to manage your digital ownership.', @@ -30,8 +30,8 @@ 'boosters_remaining' => '%d restant(s)', 'history' => 'Historique', 'history_d' => 'Consultez toutes les transactions qui ont modifié vos propriétés.', - 'altArts' => 'Alt Arts BGA', - 'altArts_d' => 'Choisissez l\'illustration utilisée pour chaque exemplaire joué sur Board Game Arena.', + 'altArts' => 'Arts alternatifs par défaut', + 'altArts_d' => 'Choisissez l\'illustration que prend chaque exemplaire d\'une carte quand vous l\'ajoutez à un deck.', 'import' => 'Import Equinox', 'import_d' => 'Demandez et/ou importez vos propriétés numériques du site d\'Equinox (sur un site sécurisé).', 'login_prompt' => 'Connectez-vous pour gérer votre propriété numérique.', diff --git a/plugins/ownership/plugin.json b/plugins/ownership/plugin.json index 36b1b866..6bc9ecad 100644 --- a/plugins/ownership/plugin.json +++ b/plugins/ownership/plugin.json @@ -12,7 +12,7 @@ {"slug": "ownership-collection", "file": "pages/collection.php", "title_en": "Collection", "title_fr": "Collection"}, {"slug": "boosters", "file": "pages/boosters.php", "title_en": "Boosters", "title_fr": "Boosters"}, {"slug": "ownership-history", "file": "pages/history.php", "title_en": "History", "title_fr": "Historique"}, - {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Alt Arts BGA", "title_fr": "Alt Arts BGA"} + {"slug": "ownership-alt-arts", "file": "pages/alt-arts.php", "title_en": "Default alt arts", "title_fr": "Arts alternatifs par défaut"} ], "api": [ @@ -22,7 +22,8 @@ {"endpoint": "history-detail", "file": "api/history-detail.php"}, {"endpoint": "alt-art-search", "file": "api/alt-art-search.php"}, {"endpoint": "alt-art-set-preference", "file": "api/alt-art-set-preference.php"}, - {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php"} + {"endpoint": "alt-art-preference-mode", "file": "api/alt-art-preference-mode.php", "methods": ["GET", "PUT"], "auth": "user"}, + {"endpoint": "alt-art-leave-global", "file": "api/alt-art-leave-global.php", "methods": ["GET", "POST"], "auth": "user"} ], "assets": { diff --git a/plugins/plugin.schema.json b/plugins/plugin.schema.json index 0e6e8a5a..1d109ab3 100644 --- a/plugins/plugin.schema.json +++ b/plugins/plugin.schema.json @@ -89,6 +89,10 @@ "type": "boolean", "description": "Full-width layout (default true for spa pages, false for php pages)." }, + "placeholder": { + "type": "string", + "description": "spa: PHP file, relative to the plugin, rendered by the server inside the mount point with $slug (the requested page) and $subPath (client route) in scope: what the page shows while its scripts load, usually a skeleton of the screen (ac-skeleton). The runtime keeps it until the plugin draws its first screen. Without it, a generic skeleton." + }, "meta": { "type": "string", "description": "spa: PHP file, relative to the plugin, included before the site header with $slug (the requested page) and $subPath (client route under /pages/{slug}/) in scope; it may set $pageTitle, $pageDescription and $pageImage (title and link previews of a client route)." diff --git a/plugins/rebuilder/.htaccess b/plugins/rebuilder/.htaccess new file mode 100644 index 00000000..97f7d15e --- /dev/null +++ b/plugins/rebuilder/.htaccess @@ -0,0 +1,7 @@ +# Build files with a content hash in their name (dist/browser: main-HASH.js, polyfills-HASH.js, chunk-HASH.js, +# media/NAME-HASH.woff2): a new build has new names, so browsers keep them without asking again. + + + Header set Cache-Control "public, max-age=31536000, immutable" + + diff --git a/plugins/rebuilder/README.md b/plugins/rebuilder/README.md index 160c05d5..a071e4bc 100644 --- a/plugins/rebuilder/README.md +++ b/plugins/rebuilder/README.md @@ -14,9 +14,9 @@ its single **Decks** entry. The pages it replaces keep answering their own calls |---|---| | `plugin.json` | Manifest v2: SPA page and its `beta_slugs`, API endpoints, build and e2e declarations | | `meta.php` | Manifest `meta`: the deck's name as page title and its link preview (title, hero · format, decklist image, faction colour: core-altered-cards `includes/deck-preview/preview.php`, shared with the site's deck page) on `deck?id=` and `deckbuilder?id=` | -| `papi/` | PHP endpoints of the plugin (`/papi/rebuilder/…`, `AlteredCore.page.apiUrl`): `community-builders` (the site's community deckbuilders) | +| `papi/` | PHP endpoints of the plugin (`/papi/rebuilder/…`, `AlteredCore.page.apiUrl`): `community-builders` (the site's community deckbuilders), `my-deck-ids` (ids of the user's decks, for the editor's ownership check, without downloading every deck) | | `app/` | Angular sources (see `app/AGENTS.md` for the code rules, `app/design/COMPONENTS.md` for the components) | -| `app/src/main.ts`, `app/src/app/embed/` | Start-up: reads `window.AlteredCore`, routes of the decks section, host session, shadow-root overlays and styles | +| `app/src/main.ts`, `app/src/app/embed/` | Start-up: reads `window.AlteredCore`, requests the page's deck while the modules download (`prefetch.ts`), routes of the decks section, host session, shadow-root overlays and styles | | `app/src/embed/` | Global styles: `embed.scss` (shadow root, after the design system), `document.scss` (``: printed-card fonts) | | `e2e/` | Playwright scenarios run by CI against the full stack | | `dist/` | Build output (`npm run build`), not committed | @@ -31,7 +31,9 @@ npm run build # → ../dist/browser + ../dist/embed-manifest.json npm run lint && npm test ``` -`build` is the only build (`ng build`, production configuration, then `scripts/embed-manifest.mjs`). +`build` is the only build (`ng build --stats-json`, production configuration, then `scripts/embed-manifest.mjs`, +which also lists in the manifest's `preload` the chunks each page imports before it can draw, read from esbuild's +metafile: the shell requests them right after the skeleton). The app is not served on its own: run it in the site's stack (`docker-compose.stack.yml`). ## Design system @@ -87,8 +89,8 @@ decks API, opened by id. The local stack's decks API does not have them: their p « Importer » › *Export altered.gg* imports the decks of an altered.gg personal-data export (the Equinox ZIP) into the account, with the behaviour of the site's `equinox-deck-import` plugin: the ZIP is read -in the browser (`decks.csv`, `DecompressionStream`); with the « Global » alt-art preference of the -ownership API (relay `services.ownership`), the user's alt arts replace the exported cards; each deck is +in the browser (`decks.csv`, `DecompressionStream`); with the ownership service (relay `services.ownership`), the +cards and the hero take the user's default alt arts; each deck is created private with `POST /api/decks` through the relay, one per second, and a deck already in the account (same name, same cards) is skipped. A failed deck stops the queue until « Réessayer » (3 attempts, then it is given up); the import can be paused or cancelled. @@ -103,6 +105,25 @@ English. `npm run lint` runs `npm run i18n:check`, which fails on a message with English translation, and on a translation left over. Vocabulary: the site's deck builder (`plugins/core-altered-cards/includes/deckbuilder/i18n.php`, `data/search_settings.json`). +## Load performance + +On a slow network the pages are bound by bytes and round trips. What keeps them fast: + +- The shell requests every module a page needs at once (manifest `preload`, see Build), once the skeleton can be + painted (its stylesheet loaded); nothing is discovered one import at a time. +- `main.ts` requests the page's deck while the modules download (`src/app/embed/prefetch.ts`). +- The editor learns whether the deck is the user's from `papi/my-deck-ids` (ids only), not from the account's full + deck list. +- JSON of the relay and of the plugin endpoints is gzipped (`includes/json-gzip.php`); the hashed build files are + cached for a year (`.htaccess`, with `mod_headers`). +- Card art loads half a screen ahead on 3G or with the data saver (`AcViewportLoader`), and the test hand's code + (with the CDK's drag and drop) only with its view (`@defer`). +- The runtime draws the first screen in the server skeleton's grid cell: no layout shift (CLS) when it replaces it. + +`tests/e2e/perf-ab.mjs` measures two versions of the site side by side on an emulated 3G / slow 4G phone (FCP, +LCP, app drawn, page ready, images on screen, TTI, CLS, bytes; cold, warm and just-deployed cache): see its header. +Run it before and after a change that touches the load path. + ## Known gaps - « Importer » › *Liste de cartes* creates a guest deck when signed out. diff --git a/plugins/rebuilder/app/AGENTS.md b/plugins/rebuilder/app/AGENTS.md index 4a5b2ce7..8a50cf76 100644 --- a/plugins/rebuilder/app/AGENTS.md +++ b/plugins/rebuilder/app/AGENTS.md @@ -104,7 +104,9 @@ composants s'adaptent à la taille d'écran et à la densité : pas d'écrans « 1. `npm run build` et `npm run lint` sans erreur, `npm test` vert ; `php tests/run.php` à la racine du repo (couleurs en dur, points de rupture). 2. E2E dans le site (`tests/e2e` + `plugins/rebuilder/e2e`) sur la stack `docker-compose.stack.yml` : voir - `../README.md`. + `../README.md`. Un changement de ce qui s'affiche pendant un chargement (squelette, données ou images qui arrivent + après le premier écran) : `npm run loading` et une timeline avant / après (`npm run loading:timeline`) dans + `tests/e2e`, voir `AGENTS.md` à la racine § Checks. 3. Capture Playwright de l'écran embarqué à 1440×900 et 390×844. 4. Recherche de valeurs en dur (`#[0-9a-f]{3,6}`, `px` de hauteur de contrôle) hors `design-system/tokens/tokens.css`. diff --git a/plugins/rebuilder/app/design/COMPONENTS.md b/plugins/rebuilder/app/design/COMPONENTS.md index 1dc177eb..b4670e23 100644 --- a/plugins/rebuilder/app/design/COMPONENTS.md +++ b/plugins/rebuilder/app/design/COMPONENTS.md @@ -61,7 +61,9 @@ Classes et états des boutons : `design-system/docs/components/button.md`. | `ac-radio-card` (dans un conteneur `role=radiogroup`) | `name`, `title`, `description`, `tag?: {label, tone}`, `checked`, `layout: 'row' \| 'stacked' \| 'compact'` (`compact` : radio 18 px, titre `--ac-field-font-size`/700, description sur une ligne avec points de suspension, tag centré à droite) | `choose` | choix du format (création : `compact`, réglages : `row`) | | `ac-switch`, CSS DS (`field.css`) | `label`, `checked = model(false)` | — | Légaux uniquement (Communauté, desktop) | | `ac-checkbox`, CSS DS (`field.css`, `ac-check`) | `label`, `checked = model(false)` | — | Légaux uniquement (feuille de filtres mobile, appliquée par « Appliquer ») | -| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `emptyPlaceholder`, `searchPlaceholder = 'Rechercher…'` | `searchChange` (texte saisi) | éditeur d'effet (déclencheur, condition, effet), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide | +| `ac-check-list` | `options: {id, text, glyph?, group?}[]`, `values = model()`, `label` (nom accessible de la liste), `searchPlaceholder = 'Rechercher…'` ; champ de recherche (`--ac-control-md`) puis une case `ac-check` par option, dans l'ordre de `options` : cocher ou décocher ne déplace aucune ligne ; une valeur cochée va à la fin de `values` (ordre de saisie) ; Entrée dans la recherche coche le premier résultat non coché et vide le champ ; la liste prend la hauteur restante et défile | — | éditeur d'effet (colonne de droite en fenêtre, étape `ac-ability-picker` en compact) | +| `ac-or-values` | `values = model()` (`{id, text, glyph?}`), `empty` (texte quand rien n'est choisi) ; une valeur par ligne, « ou » seul sur sa ligne entre deux valeurs, bouton « Retirer » (cible `--ac-hit-min`) sur chacune | — | éditeur d'effet (cartes des critères en fenêtre, critères en compact) | +| `ac-combobox` | `options: {id, text, glyph?, thumb?, group?}[]` (`thumb` : vignette dans la liste ; `group` : intertitre au-dessus des options consécutives d’un même groupe), `values = model()`, `placeholder`, `searchPlaceholder = 'Rechercher…'`, `keepOpen = false` | `searchChange` (texte saisi) | sous-types (filtres), calculateurs de la main de départ (vignettes, groupes par type). Le contrôle « Ajouter… » est un bouton (`role=combobox`) qui ouvre la liste dans un overlay CDK (`cdkConnectedOverlay` : largeur du bouton, sous le bouton ou au-dessus faute de place, fermeture au clic extérieur et sur Échap). La liste est un `cdkListbox` (flèches, Début / Fin, Entrée, saisie rapide, ARIA). Le champ de recherche n'est **pas focalisé** à l'ouverture (pas de clavier tactile tant qu'on ne le touche pas) ; une lettre sur le bouton démarre la recherche, Entrée dans le champ prend le premier résultat, une flèche passe dans la liste. Après un choix ou Échap, la liste se ferme et le focus revient au bouton. `glyph` : caractère `Altered Icons` avant le texte, dans la liste et sur la puce (main, réserve, partout). « ou » entre chaque valeur et avant le bouton « Ajouter… » dès qu'au moins une valeur est choisie ; rien si la liste est vide. `keepOpen` (calculateurs, sous-types) : une case à cocher devant chaque option, un clic coche ou décoche sans fermer la liste, les valeurs cochées restent dans la liste (gras, couleur primaire) ; Entrée dans la recherche coche le premier résultat non coché et vide le champ ; les puces passent **sous** le bouton (« ou » entre elles seulement), pour que cocher ne déplace ni le bouton ni la liste | | `ac-editable-title` | `value = model()`, `ariaLabel` | — | titre du deck (desktop) | Classes et états des champs et du contrôle segmenté : `design-system/docs/components/field.md` et `segmented.md`. @@ -73,7 +75,7 @@ Classes et états des champs et du contrôle segmenté : `design-system/docs/com | `ac-chip`, CSS DS (`chips.css` : ` + } + @if (editable()) { + + } +
    +
    + Mes decks + @if (deck.opening()) { +
    + + @if (!reduced()) { + + + } + } @else { +
    + @if (renaming()) { + + } @else { +

    {{ deck.name() }}

    + @if (editable() && !reduced()) { + + } + } +
    + + @if (reduced()) { + + {deck.total(), plural, =1 {1 carte} other {{{ deck.total() }} cartes}} + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } + + } @else { + {{ deck.hero()?.name }}{{ formatLabel() }} + + + @if (deck.isDraft() && !editable()) { + Brouillon + } + {{ deck.isPublic() ? labels.public : labels.private }} + + + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } + + } + } +
    + + + +@if (deck.opening()) { + + @if (reduced()) { + + + } + + +} @else { + +@if (reduced() && rarityLimits().length) { + + +} + +
    + @if (!reduced()) { + Puissance totale + } + + @if (!reduced() && rarityLimits().length) { + Raretés + + } +
    +@if (reduced()) { + +} +} + +
    + +
    + + + @if (legality().state === 'legal') { + + } @else if (legality().state === 'illegal') { + + } + diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss new file mode 100644 index 00000000..271a8fa8 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.scss @@ -0,0 +1,308 @@ +:host { + /* Width of a column of the full bar's actions (two columns of small buttons, deck page and editor « Aperçu »): the + same for the actions in skeleton and the buttons (« Copier en image ▾ », the widest, is 170 px), so that the name + column keeps its width, and its lines, when the owner is known. */ + --deck-bar-action-width: 170px; + container: deck-bar / inline-size; + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: var(--ac-space-5); + /* One height for every deck, from its skeleton (placeholder.php, then the deck opening) to the deck: the tallest + column, three rows of actions or the text (back link, name with its rename button, meta, badges, counts), whose rows + never wrap. */ + min-height: max( + calc(3 * var(--ac-control-sm) + 2 * var(--ac-space-1-5) + 2 * var(--ac-space-3) + 2px), + calc(1.3 * var(--ac-font-size-caption) + var(--ac-control-sm) + 1.4 * var(--ac-font-size-small) + var(--ac-badge-height) + + 1.35 * var(--ac-font-size-body-strong) + 4 * 2px + 2 * var(--ac-space-2) + 2px) + ); + padding-right: var(--ac-space-5); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + box-shadow: var(--ac-shadow-card); +} +.identity { + align-self: stretch; + flex: 1 1 auto; + min-width: 0; + display: flex; +} +.art { + position: relative; + flex: 0 0 150px; + overflow: hidden; + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-track); +} +.portrait { + position: absolute; + inset: 0; + padding: 0; + border: 0; + background: none; + cursor: pointer; +} +.zoom { + cursor: zoom-in; +} +.portrait:focus-visible, +.settings:focus-visible, +.legality:focus-visible, +.back:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: -2px; +} +.settings { + /* A surface-coloured chip with a border and a shadow: it stands out on a dark or a light hero art alike. */ + position: absolute; + right: var(--ac-space-2); + bottom: var(--ac-space-2); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 1px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + color: var(--ac-color-text); + box-shadow: var(--ac-shadow-e3); + cursor: pointer; +} +@media (hover: hover) { + .settings:hover { + background: var(--ac-color-bg-subtle); + color: var(--ac-color-primary); + } +} +.text { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + justify-content: center; + gap: 2px; + padding: var(--ac-space-2) var(--ac-space-4); + border-left: 4px solid; +} +.back { + align-self: flex-start; + display: inline-flex; + align-items: center; + gap: 2px; + font: var(--ac-font-caption); + color: var(--ac-color-link); + text-decoration: none; +} +@media (hover: hover) { + .back:hover { + color: var(--ac-color-link-hover); + } +} +.name-row { + display: flex; + align-items: center; + gap: var(--ac-space-1-5); + min-width: 0; + /* The rename button's height, with or without it. */ + min-height: var(--ac-control-sm); +} +/* One line, the whole name in the tooltip; a long one a size smaller. */ +.name { + margin: 0; + min-width: 0; + font: var(--ac-font-title); + color: var(--ac-color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.name.long { + font: var(--ac-font-heading); +} +.rename { + flex: 1 1 auto; + min-width: 0; + max-width: 40rem; + margin: 0; + padding: 0 var(--ac-space-1-5); + height: var(--ac-control-md); + font: var(--ac-font-title); + color: var(--ac-color-text); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-control); +} +.rename.long { + font: var(--ac-font-heading); +} +.rename:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 0; +} +.rename-button { + flex-shrink: 0; + color: var(--ac-color-text-muted); +} +.meta { + font: var(--ac-font-small); + color: var(--ac-color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.dot { + margin-inline: var(--ac-space-2); +} +/* One row of whole badges, and of whole counts: what does not fit goes to a second row, hidden. */ +.badges { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--ac-space-1-5); + height: var(--ac-badge-height); + overflow: hidden; +} +.meta strong { + color: var(--ac-color-text); +} +.counts { + display: flex; + flex-wrap: wrap; + gap: 0 var(--ac-space-3); + height: calc(1.35 * var(--ac-font-size-body-strong)); + overflow: hidden; + white-space: nowrap; + font: var(--ac-font-small); + color: var(--ac-color-text-2); +} +.counts strong { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.legality { + display: inline-flex; + padding: 0; + border: 0; + background: none; + cursor: pointer; + border-radius: var(--ac-radius-pill); +} +.sep { + align-self: stretch; + width: 1px; + flex-shrink: 0; + margin: var(--ac-space-3) 0; + background: var(--ac-color-divider); +} +.terrain { + flex-shrink: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-1-5); +} +.actions { + flex-shrink: 0; + margin-left: auto; + /* Space between the buttons and the bar's top and bottom borders. */ + padding: var(--ac-space-3) 0; +} +.actions:empty { + display: none; +} + +/* Reduced bar (editor search, starting hand): one row. */ +:host(.reduced) { + gap: var(--ac-space-4); + /* The text: back link, name, meta. */ + min-height: calc(1.3 * var(--ac-font-size-caption) + 1.3 * var(--ac-font-size-heading) + 1.4 * var(--ac-font-size-small) + 2 * 2px + + 2 * var(--ac-space-2) + 2px); + padding-right: var(--ac-space-4); +} +:host(.reduced) .actions { + padding: var(--ac-space-2) 0; +} +:host(.reduced) .art { + flex-basis: 96px; +} +:host(.reduced) .settings { + right: var(--ac-space-1-5); + bottom: var(--ac-space-1-5); + width: 28px; + height: 28px; +} +:host(.reduced) .text { + padding: var(--ac-space-2) var(--ac-space-3); +} +:host(.reduced) .name-row { + min-height: 0; +} +:host(.reduced) .name { + font: var(--ac-font-heading); +} +:host(.reduced) .name.long { + font: var(--ac-font-body-strong); +} + +/* Narrower bars (window width, deck panel open): the curve goes first, then the terrain totals and the rarities, so that + the name keeps at least 210 px (its column is 1017 px narrower than the bar with everything, 795 px without the curve). + Same widths in placeholder.css, as window widths. */ +@container deck-bar (max-width: 1204px) { + :host(:not(.reduced)) :is(ac-cost-curve, .curve-skeleton, .sep.curve) { + display: none; + } +} +@container deck-bar (max-width: 993px) { + :host(:not(.reduced)) :is(.terrain, .terrain-skeleton), + :host(:not(.reduced)) .sep { + display: none; + } +} +/* Reduced bar: the curve, then the terrain totals, then the rarities; the name keeps about 200 px (1228, 1050 and 839 px + narrower than the bar). */ +@container deck-bar (max-width: 1409px) { + :host(.reduced) :is(ac-cost-curve, .curve-skeleton, .sep.curve) { + display: none; + } +} +@container deck-bar (max-width: 1231px) { + :host(.reduced) :is(.terrain, .terrain-skeleton), + :host(.reduced) .sep:has(+ :is(.terrain, .terrain-skeleton)) { + display: none; + } +} +@container deck-bar (max-width: 1020px) { + :host(.reduced) :is(ac-rarity-limits, .limits-skeleton), + :host(.reduced) .sep { + display: none; + } +} + +/* While the deck opens: the text's rows, and blocks of the size of the curve, the rarity limits and the terrain totals, + so that nothing moves when the deck arrives. */ +.badges-skeleton { + display: flex; + gap: var(--ac-space-1-5); +} +.curve-skeleton { + flex: 0 0 145px; + height: 51px; +} +.limits-skeleton { + flex: 0 1 230px; + height: 30px; +} +.terrain-skeleton { + flex: 0 0 162px; + height: 30px; +} +:host(:not(.reduced)) .curve-skeleton { + flex-basis: 181px; + height: 125px; +} +:host(:not(.reduced)) .terrain-skeleton { + flex-basis: 216px; + height: 103px; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts new file mode 100644 index 00000000..44efc87b --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-bar/deck-bar.ts @@ -0,0 +1,93 @@ +import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, output, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { RouterLink } from '@angular/router'; +import { illegalLabel, type DeckLegality } from '../../../core/deck-legality'; +import { rarityLimits } from '../../../core/deck-rules'; +import { DeckStore } from '../../../core/deck-store'; +import { formatInfo } from '../../../core/formats'; +import { AcIconButton } from '../../../ui/buttons'; +import { AcBadge, AcTerrainTotals } from '../../../ui/chips'; +import { AcSkeleton } from '../../../ui/containers'; +import { AcIcon } from '../../../ui/icon'; +import { AcCardArt, AcCostCurve, AcRarityLimits, factionColor } from '../../../ui/metier'; +import { AcOverlayService } from '../../../ui/overlay'; +import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; + +/** From this length the title takes one type size less, so that two lines keep the bar's height. */ +const LONG_NAME = 40; + +/** + * The deck bar from 768 px, above the deck page and the editor: hero art, faction border, name (two lines at most), + * format and badges, counts per type, cost curve, terrain totals, then the page's actions (`[barActions]`). + * `reduced` (editor search and starting hand): one row, smaller art, a mini curve with the `highlight` bucket outlined. + */ +@Component({ + selector: 'app-deck-bar', + imports: [NgTemplateOutlet, RouterLink, AcCardArt, AcCostCurve, AcRarityLimits, AcTerrainTotals, AcBadge, AcIcon, AcIconButton, AcSkeleton], + host: { '[class.reduced]': 'reduced()', '[class.opening]': 'deck.opening()', '[attr.aria-busy]': 'deck.opening() || null' }, + templateUrl: './deck-bar.html', + styleUrl: './deck-bar.scss', +}) +export class DeckBar { + protected readonly deck = inject(DeckStore); + private readonly el = inject>(ElementRef).nativeElement; + private readonly injector = inject(Injector); + private readonly overlay = inject(AcOverlayService); + + readonly reduced = input(false); + readonly legality = input.required(); + /** Editor: the art opens « Choisir un héros », the settings button on it, the rename button next to the name, the number of problems. */ + readonly editable = input(false); + /** Bucket of the mini curve outlined (cost of the last card added). */ + readonly highlight = input(null); + readonly openSettings = output(); + readonly changeHero = output(); + readonly showLegality = output(); + + protected readonly renaming = signal(false); + protected readonly border = computed(() => factionColor(this.deck.hero()?.faction)); + protected readonly long = computed(() => this.deck.name().length > LONG_NAME); + /** Rares, Exalteds and Uniques against the format's caps. */ + protected readonly rarityLimits = computed(() => rarityLimits(this.deck.status(), this.deck.format())); + protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); + protected readonly problems = computed(() => this.legality().rules.length + this.legality().errors.length); + protected readonly illegalLabel = computed(() => illegalLabel(this.editable() ? this.problems() : 0)); + protected readonly labels = { + legalDetails: $localize`:@@deck.bar.legalDetails:Légal : voir les règles du format`, + illegalDetails: $localize`:@@deck.page.illegalDetails:Non légal : voir le détail`, + settings: $localize`:@@ui.deckSummary.settings:Réglages du deck : héros, format, visibilité`, + changeHero: $localize`:@@ui.deckSummary.changeHero:Changer de héros`, + rename: $localize`:@@ui.editableTitle.rename:Renommer le deck`, + name: $localize`:@@ui.editableTitle.label:Nom du deck`, + public: $localize`:@@deck.page.public:Public`, + private: $localize`:@@deck.page.private:Privé`, + }; + protected readonly zoomLabel = computed(() => $localize`:@@ui.cardTile.zoom:Agrandir ${this.deck.hero()?.name ?? ''}:name:`); + + /** Read-only: the hero's card large. */ + protected zoomHero(): void { + const hero = this.deck.hero(); + if (hero) openCardZoom(this.overlay, { card: { reference: hero.reference, name: hero.name, faction: { code: hero.faction, name: hero.faction }, cardType: { reference: 'HERO' } } }); + } + + protected startRename(): void { + this.renaming.set(true); + afterNextRender( + () => { + const field = this.el.querySelector('input.rename'); + field?.focus(); + field?.select(); + }, + { injector: this.injector }, + ); + } + + /** Enter or leaving the field keeps the name (an empty one is ignored), Échap gives up. */ + protected endRename(value: string | null): void { + if (!this.renaming()) return; + this.renaming.set(false); + const name = value?.trim(); + if (name && name !== this.deck.name()) this.deck.rename(name); + afterNextRender(() => this.el.querySelector('.rename-button')?.focus(), { injector: this.injector }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts new file mode 100644 index 00000000..5939e321 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.spec.ts @@ -0,0 +1,46 @@ +import type { Card, HydratedLine } from '../../../core/models'; +import { boardRows, deckBoard, fitColumns } from './board-layout'; + +const line = (reference: string, type: string, mainCost: number, quantity = 1): HydratedLine => ({ + card: { reference, name: reference, mainCost, cardType: { reference: type } } as unknown as Card, + quantity, +}); + +describe('boardRows', () => { + it('takes the smallest row count that fits the columns', () => { + // 17 cards on one row: 17 columns > 12; on two rows: 2 + 4 + 1 + 2 = 9. + expect(boardRows([4, 8, 2, 3], 12)).toBe(2); + // A singleton deck of 45 cards. + expect(boardRows([15, 18, 6, 6], 12)).toBe(5); + expect(boardRows([3, 2], 12)).toBe(1); + }); + + it('stops at the largest group when the groups alone exceed the columns', () => { + expect(boardRows([1, 1, 1], 2)).toBe(1); + }); +}); + +describe('deckBoard', () => { + it('puts the Uniques apart, sorts by cost and gives each group its columns', () => { + const groups = deckBoard([ + line('ALT_CORE_B_YZ_08_C', 'CHARACTER', 3, 3), + line('ALT_CORE_B_YZ_04_C', 'CHARACTER', 1, 2), + line('ALT_CORE_B_YZ_08_U_123', 'CHARACTER', 2), + line('ALT_CORE_B_YZ_20_C', 'SPELL', 4, 0), + ]); + expect(groups.map((g) => g.id)).toEqual(['characters', 'spells', 'uniques']); + expect(groups[0].lines.map((l) => l.card.reference)).toEqual(['ALT_CORE_B_YZ_04_C', 'ALT_CORE_B_YZ_08_C']); + expect(groups[0].count).toBe(5); + expect(groups[1].count).toBe(0); + expect(groups.map((g) => g.columns)).toEqual([2, 1, 1]); + }); +}); + +describe('fitColumns', () => { + it('keeps the cards at their minimum width', () => { + // 1 940 px, 4 groups: (1940 - 4 × 28) / 162 = 11.3. + expect(fitColumns(1940, 4, 150, 20, 12)).toBe(11); + expect(fitColumns(1376, 4, 150, 20, 12)).toBe(7); + expect(fitColumns(100, 4, 150, 20, 12)).toBe(1); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts new file mode 100644 index 00000000..3718f8c7 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/board-layout.ts @@ -0,0 +1,70 @@ +import { isUniqueReference } from '../../../core/card-art'; +import { typeOf } from '../../../core/deck-rules'; +import { GROUP_LABELS, compareLines, groupIdOf, type DeckGroupId } from '../../../core/deck-view'; +import type { HydratedLine } from '../../../core/models'; + +export type BoardGroupId = DeckGroupId | 'uniques'; + +/** A column group of the deck board: one card type. */ +export interface BoardGroup { + id: BoardGroupId; + label: string; + count: number; + lines: HydratedLine[]; +} + +/** A group laid out on the board, `columns` card columns wide. */ +export interface BoardColumnGroup extends BoardGroup { + columns: number; +} + +const ORDER: BoardGroupId[] = ['characters', 'spells', 'permanents', 'uniques', 'other']; +const UNIQUES = $localize`:@@deck.board.uniques:Uniques`; + +/** + * The deck board's groups: one per type (Uniques apart from the other Characters), cards in decklist order. Lines at 0 + * copies stay (a card just removed in the editor). + */ +export function boardGroups(lines: readonly HydratedLine[]): BoardGroup[] { + const buckets = new Map(); + for (const line of lines) { + if (typeOf(line.card) === 'HERO' || line.quantity < 0) continue; + const id: BoardGroupId = isUniqueReference(line.card.reference) ? 'uniques' : groupIdOf(line.card); + let bucket = buckets.get(id); + if (!bucket) buckets.set(id, (bucket = [])); + bucket.push(line); + } + return ORDER.flatMap((id) => { + const rows = buckets.get(id); + if (!rows) return []; + rows.sort(compareLines); + return [{ id, label: id === 'uniques' ? UNIQUES : GROUP_LABELS[id].label, count: rows.reduce((n, l) => n + l.quantity, 0), lines: rows }]; + }); +} + +/** Every group gets the same number of rows, the smallest that fits the board in `maxColumns` card columns. */ +export function layoutBoard(groups: readonly BoardGroup[], maxColumns: number): BoardColumnGroup[] { + const rows = boardRows(groups.map((g) => g.lines.length), maxColumns); + return groups.map((g) => ({ ...g, columns: Math.max(1, Math.ceil(g.lines.length / rows)) })); +} + +/** The deck board: its groups laid out in `maxColumns` card columns at most. */ +export function deckBoard(lines: readonly HydratedLine[], maxColumns = 12): BoardColumnGroup[] { + return layoutBoard(boardGroups(lines), maxColumns); +} + +/** The smallest row count with which groups of these sizes fit in `maxColumns` columns (each group at least one). */ +export function boardRows(sizes: readonly number[], maxColumns: number): number { + const most = Math.max(1, ...sizes); + let rows = 1; + while (rows < most && sizes.reduce((n, size) => n + Math.max(1, Math.ceil(size / rows)), 0) > maxColumns) rows++; + return rows; +} + +/** + * Card columns that fit `width` with cards of `minCard` px at least: each group has its padding on both sides less one + * gap (the board's CSS), each column a card and a gap. + */ +export function fitColumns(width: number, groups: number, minCard: number, padding: number, gap: number): number { + return Math.max(1, Math.floor((width - groups * (2 * padding - gap)) / (minCard + gap))); +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html new file mode 100644 index 00000000..d59b5d4a --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.html @@ -0,0 +1,42 @@ +@if (deck.opening()) { +
    + +
    + @for (c of [0, 1, 2, 3]; track c) { + + } +
    +
    +} @else { +@for (g of groups(); track g.id) { +
    +

    {{ g.label }} · {{ g.count }}

    +
    + @for (l of g.lines; track trackOf(l)) { + @let stack = stacks().get(l.card.reference); + + } +
    +
    +} @empty { +
    + Deck vide + @if (editable()) { +

    Ajoutez des cartes depuis la recherche.

    + } @else { +

    Ce deck ne contient encore aucune carte.

    + } +
    +} +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss new file mode 100644 index 00000000..4f9ee86e --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.scss @@ -0,0 +1,46 @@ +:host { + display: flex; + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + padding: var(--ac-space-5) 0; +} +.group { + /* Card columns of the same width in every group, 260px at most (a small deck does not get giant cards). */ + --card-max: 260px; + /* Grows with its columns from its padding less one gap: every card column gets the same width. */ + flex: var(--columns) 1 calc(2 * var(--ac-space-5) - var(--ac-space-3)); + min-width: 0; + max-width: calc(var(--columns) * var(--card-max) + (var(--columns) - 1) * var(--ac-space-3) + 2 * var(--ac-space-5)); + display: flex; + flex-direction: column; + gap: var(--ac-space-3); + padding: 0 var(--ac-space-5); +} +.group + .group { + border-left: 1px solid var(--ac-color-divider); +} +.title { + margin: 0; + font: var(--ac-font-overline); + font-size: var(--ac-font-size-small); + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text); +} +.cards { + display: grid; + grid-template-columns: repeat(var(--columns), minmax(0, 1fr)); + column-gap: var(--ac-space-3); + row-gap: var(--ac-space-4); + align-items: end; +} +.empty { + flex: 1; + text-align: center; + padding: var(--ac-space-10) var(--ac-space-4); + color: var(--ac-color-text-2); +} +.empty p { + margin: var(--ac-space-1) 0 0; +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts new file mode 100644 index 00000000..fe209a96 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/deck/deck-board/deck-board.ts @@ -0,0 +1,141 @@ +import { Component, DestroyRef, ElementRef, afterNextRender, computed, inject, input, signal } from '@angular/core'; +import { DeckStore, displayName } from '../../../core/deck-store'; +import type { Card, HydratedLine } from '../../../core/models'; +import { familyKey } from '../../../core/ownership-api.service'; +import { AcCardPile } from '../../../ui/metier'; +import { AcSkeleton } from '../../../ui/containers'; +import { AcOverlayService } from '../../../ui/overlay'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; +import { lineIssues } from '../../editor/editor-legality'; +import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../../editor/alt-art-picker/alt-art-picker.overlay'; +import { boardGroups, fitColumns, layoutBoard } from './board-layout'; + +/** Narrowest card the board shows, to keep it readable: a wider board gets more columns, so fewer rows. */ +const MIN_CARD = 150; +/** The board's CSS: a group's side padding (--ac-space-5) and the gap between cards (--ac-space-3). */ +const PADDING = 20; +const GAP = 12; +/** Columns before the board is measured. */ +const DEFAULT_COLUMNS = 8; + +/** + * « Aperçu » from 768 px: the whole deck at a glance, as its image. One column group per type, each card a pile of its + * copies. Editable (editor): a stepper on each pile; a card brought to 0 leaves the board (« Annuler » in the toast). + * « Regrouper les illustrations » (editor): the prints of a card share one pile, copy 1 of its brush in front, and the + * pile's stepper adds and removes copies of the card as the search does. + */ + +/** A pile of a card's prints: its copies (copy 1 first), its family, its legality issues. */ +interface Stack { + key: string; + prints: string[]; + issues: readonly string[]; +} +@Component({ + selector: 'app-deck-board', + imports: [AcCardPile, AcSkeleton], + templateUrl: './deck-board.html', + styleUrl: './deck-board.scss', +}) +export class DeckBoard { + protected readonly deck = inject(DeckStore); + private readonly overlay = inject(AcOverlayService); + /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ + protected readonly altArts = inject(EditorAltArts, { optional: true }); + readonly readonly = input(false); + /** The board's width, measured: the number of card columns follows it. */ + private readonly width = signal(0); + + /** Steppers on the piles (the editor's board): DeckStore.steppersShown. */ + protected readonly editable = computed(() => !this.readonly() && this.deck.steppersShown()); + protected readonly issues = computed(() => lineIssues(this.deck)); + protected readonly noIssues: readonly string[] = []; + /** The board's lines, a card's prints on one pile when « Regrouper les illustrations » is on, and those piles. */ + private readonly piles = computed(() => { + const lines = this.deck.lines().filter((l) => l.quantity > 0); + const stacks = new Map(); + const alt = this.altArts; + if (!alt || !this.editable() || !alt.stackPrints()) return { lines, stacks }; + const issues = this.issues(); + const out: HydratedLine[] = []; + const done = new Set(); + for (const line of lines) { + const choice = alt.choiceFor(line.card.reference); + const members = choice ? alt.members(choice) : null; + if (!choice || !members || members.size < 2) { + out.push(line); + continue; + } + const key = familyKey(choice.family); + if (done.has(key)) continue; + done.add(key); + const prints = alt.copyPrints(line.card) ?? []; + const front = lines.find((l) => l.card.reference === prints[0])?.card ?? line.card; + out.push({ card: front, quantity: prints.length }); + stacks.set(front.reference, { key, prints, issues: [...new Set([...members].flatMap((r) => issues.get(r) ?? []))] }); + } + return { lines: out, stacks }; + }); + protected readonly stacks = computed(() => this.piles().stacks); + /** The groups, sorted: recomputed when the cards change, not when the board is resized. */ + private readonly board = computed(() => boardGroups(this.piles().lines)); + /** Card columns the width allows: a number, so a resize that keeps it changes nothing downstream. */ + private readonly columns = computed(() => { + const width = this.width(); + return width ? fitColumns(width, this.board().length, MIN_CARD, PADDING, GAP) : DEFAULT_COLUMNS; + }); + protected readonly groups = computed(() => layoutBoard(this.board(), this.columns())); + + constructor() { + const host = inject>(ElementRef).nativeElement; + const destroyRef = inject(DestroyRef); + afterNextRender(() => { + if (typeof ResizeObserver === 'undefined') return; + const observer = new ResizeObserver(([entry]) => this.width.set(Math.round(entry.contentRect.width))); + observer.observe(host); + destroyRef.onDestroy(() => observer.disconnect()); + }); + } + + /** A pile keeps its place while its front print changes. */ + protected trackOf(line: HydratedLine): string { + return this.stacks().get(line.card.reference)?.key ?? line.card.reference; + } + + /** A pile's copies: of the card's whole family on a pile of its prints. */ + protected setQuantity(card: Card, quantity: number): void { + if (this.altArts && this.stacks().has(card.reference)) this.altArts.setFamilyQuantity(card, quantity); + else this.deck.setQuantity(card, quantity); + } + + protected quantityOf(card: Card): number { + return this.stacks().get(card.reference)?.prints.length ?? this.deck.quantities().get(card.reference) ?? 0; + } + + /** Copies a pile can have: the card's limit on a pile of its prints, shared with its other prints otherwise. */ + protected maxOf(card: Card): number { + return this.stacks().has(card.reference) ? this.deck.maxFor(card) : (this.altArts?.maxFor(card, false) ?? this.deck.maxFor(card)); + } + + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return this.editable() && !!this.altArts?.canIllustrate(card); + } + + /** The brush of a card: its illustrations in this deck. */ + protected illustrate(card: Card): void { + const data = this.altArts?.pickerFor(card); + if (data) openAltArtPicker(this.overlay, displayName(card), data); + } + + /** The card large, with its copies and prints when the deck can be edited (the site's card lightbox). */ + protected zoom(card: Card): void { + const editable = this.editable(); + openCardZoom(this.overlay, { + card, + quantity: editable ? { value: this.quantityOf(card), max: this.maxOf(card), change: (n) => this.setQuantity(card, n) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, + }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html b/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html index 04fabbe8..abc95498 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-image-export/deck-image-export.html @@ -1,5 +1,6 @@ (); /** The deck's name: the saved file's. */ readonly name = input(''); + readonly size = input<'md' | 'sm'>('md'); readonly notice = output(); protected readonly busy = signal(false); diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html index 31d2503e..773a98b4 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.html @@ -1,14 +1,18 @@ @if (bp.compact()) { - + + @for (i of deck.owned() === null && !deck.error() ? [0, 1, 2] : []; track i) { + + } @if (!deck.error() && deck.owned()) { } - @if (!deck.error()) { + @if (!deck.error() && deck.owned() !== null) { } - @if (!deck.error()) { + @if (!deck.error() && deck.owned() !== null) { } @@ -47,16 +51,56 @@ Retour à mes decks -} @else if (deck.loading()) { -

    Chargement du deck…

    } @else { -
    + +
    + @if (deck.opening()) { + {{ loadingLabel }} + }
    @if (!bp.compact()) { - Mes decks + +
    + + @if (deck.owned() === null) { + @for (i of [0, 1, 2, 3, 4, 5]; track i) { + + } + } @else { + @if (deck.owned()) { + + } + + @if (imageSource(); as source) { + + } + + + @if (deck.owned()) { + + } + } +
    +
    } @else {
    + @if (deck.opening()) { +
    + +
    + + +
    +
    +
    + + + +
    +
    + } @else {
    @if (deck.hero(); as h) { @@ -79,6 +123,7 @@

    + }
    } @@ -87,6 +132,8 @@

    @case ('decklist') { @if (bp.compact()) { + } @else if (deck.opening()) { + } @else {
    @@ -102,7 +149,11 @@

    } @case ('description') {
    - @if (deck.description().trim(); as text) { + @if (deck.opening()) { + + + + } @else if (deck.description().trim(); as text) {

    {{ text }}

    } @else {

    Aucune description pour ce deck.

    @@ -110,63 +161,37 @@

    } @case ('main') { - - - + @if (deck.opening()) { + + } @else { + + @defer (on immediate) { + + + + } @placeholder { + + } + } } @case ('cartes') { @if (bp.compact()) { - - - + @if (deck.opening()) { + + } @else { + + + + } + + } @else { + } - } @default never; }

    - @if (!bp.compact()) { - - }
    @if (bp.compact()) { diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss index 6287a827..85c39474 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.scss @@ -125,8 +125,7 @@ color: var(--ac-color-text-muted); } -.m-meta h1, -.titles h1 { +.m-meta h1 { display: flex; align-items: center; gap: var(--ac-space-2); @@ -213,16 +212,6 @@ gap: var(--ac-space-5); } - .back { - display: inline-flex; - align-items: center; - gap: var(--ac-space-1); - font-size: var(--ac-font-size-body); - font-weight: 700; - color: var(--ac-color-text-2); - margin-bottom: calc(-1 * var(--ac-space-2)); - } - .grouping { display: flex; justify-content: flex-end; @@ -240,72 +229,15 @@ bottom: var(--ac-space-6); } - .aside { - width: 300px; - flex-shrink: 0; - background: var(--ac-color-surface); - border: 1px solid var(--ac-color-border); - border-radius: var(--ac-radius-card); - overflow: hidden; - position: sticky; - top: calc(var(--ac-app-bar-height) + var(--ac-space-6)); - } - - .banner { - position: relative; - height: 170px; - background: var(--ac-color-text-2); - } - - button.banner { - display: block; - width: 100%; - padding: 0; - border: 0; - cursor: pointer; - } - - button.banner:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: -2px; - } - - .aside-body { - padding: 18px; - display: flex; - flex-direction: column; - gap: var(--ac-space-4); - } - - .titles { - display: flex; - flex-direction: column; - gap: var(--ac-space-1); - font-size: var(--ac-font-size-body); - color: var(--ac-color-text-muted); - } - - .titles h1 { - margin: 0; - font-size: 24px; - font-weight: 800; - letter-spacing: var(--ac-letter-spacing-display); - color: var(--ac-color-text); - } - - .image-export { - margin-top: calc(-1 * var(--ac-space-2)); - } - - .actions { + /* Deck bar actions: two columns of small buttons. */ + .bar-actions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: var(--ac-space-2); - margin-top: calc(-1 * var(--ac-space-2)); + grid-template-columns: repeat(2, minmax(var(--deck-bar-action-width), max-content)); + gap: var(--ac-space-1-5); } - .actions button { - padding: 0 var(--ac-space-2); + .bar-actions > button { + padding: 0 var(--ac-space-2-5); } } @@ -315,8 +247,21 @@ } } -@media (min-width: 1200px) { - .aside { - width: 360px; - } + + +/* While the deck opens: blocks of the size of what they stand for. */ +.skeleton-meta { + flex: 1 1 auto; + gap: var(--ac-space-1-5); +} +.badge-skeleton { + height: 26px; + border-radius: var(--ac-radius-pill); +} +.stats-skeleton { + height: 190px; } +.tables-skeleton { + height: 320px; +} + diff --git a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts index 72693296..a1828103 100644 --- a/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts +++ b/plugins/rebuilder/app/src/app/features/deck/deck-page/deck.page.ts @@ -7,14 +7,12 @@ import { AuthSession } from '../../../core/auth-session'; import { PageTitle } from '../../../core/page-title'; import { factionSrc } from '../../../core/assets'; import { DeckStore } from '../../../core/deck-store'; -import { deckImageSource } from '../../../core/deck-image'; -import { decklistText, groupByCost } from '../../../core/deck-view'; +import { groupByCost } from '../../../core/deck-view'; import { formatInfo } from '../../../core/formats'; import { GuestDeckService } from '../../../core/guest-deck.service'; -import { uiLocale } from '../../../core/i18n'; import { AcButton, AcIconButton } from '../../../ui/buttons'; import { AcBadge, AcRaritySummary } from '../../../ui/chips'; -import { AcCollapsible } from '../../../ui/containers'; +import { AcCollapsible, AcSkeleton } from '../../../ui/containers'; import { AcSegmented } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; import { AcBreakpointService } from '../../../ui/layout.services'; @@ -26,23 +24,28 @@ import { DeckPreview } from '../../editor/deck-preview/deck-preview'; import { DecklistTable } from '../decklist-table/decklist-table'; import { DeckActionsSheet, type DeckActionsData, type DeckActionsResult } from '../deck-actions-sheet/deck-actions-sheet'; import { deckImageBusyMessage } from '../deck-image-actions'; +import { confirmDeleteDeck, copyDecklist, deckImageSourceFor } from '../deck-actions'; import { DeckImageExport } from '../deck-image-export/deck-image-export'; +import { DeckBar } from '../deck-bar/deck-bar'; +import { DeckBoard } from '../deck-board/deck-board'; import { openDuplicateDeck } from '../duplicate-deck/duplicate-deck.overlay'; -import { openConfirm } from '../../shared/confirm/confirm.overlay'; import { openLegalityDetails } from '../../shared/legality-details/legality-details.overlay'; -import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; import { openShareDeck } from '../share-deck/share-deck.overlay'; import { HandCalculators } from '../hand-calculators/hand-calculators'; import { HandStats } from '../hand-stats/hand-stats'; import { TestHand } from '../test-hand/test-hand'; import { deckShareUrl } from './share-url'; +import { HandSkeleton } from '../../shared/hand-skeleton/hand-skeleton'; type DeckTab = 'cartes' | 'decklist' | 'description' | 'main'; /** Path of each tab under `/decks/:id` (`cartes` is the deck page itself). */ const TAB_PATHS: Record = { cartes: null, decklist: 'deck', description: 'description', main: 'main' }; -/** Consultation: Cartes / Decklist / Description / Main de départ tabs, read-only, summary aside with actions. */ +/** + * Consultation: Aperçu / Decklist / Description / Main de départ, read-only. From 768 px the deck bar (art, stats, + * actions) sits above the tabs and « Aperçu » is the deck board; on phones, the summary and the bottom navigation. + */ @Component({ selector: 'app-deck-page', imports: [ @@ -65,9 +68,13 @@ const TAB_PATHS: Record = { cartes: null, decklist: 'dec DeckListView, DecklistTable, DeckImageExport, + DeckBar, + DeckBoard, TestHand, HandStats, HandCalculators, + HandSkeleton, + AcSkeleton, ], templateUrl: './deck.page.html', styleUrl: './deck.page.scss', @@ -92,7 +99,7 @@ export class DeckPage { private toastTimer?: ReturnType; protected readonly tabs = [ - { id: 'cartes', label: $localize`:@@deck.page.tabCards:Cartes` }, + { id: 'cartes', label: $localize`:@@deck.page.tabPreview:Aperçu` }, { id: 'decklist', label: 'Decklist' }, { id: 'description', label: $localize`:@@deck.page.tabDescription:Description` }, { id: 'main', label: $localize`:@@deck.page.tabHand:Main de départ` }, @@ -114,13 +121,10 @@ export class DeckPage { protected readonly legality = this.deck.legality; protected readonly legal = computed(() => this.legality().state === 'legal'); protected readonly groups = computed(() => (this.grouping() === 'type' ? this.deck.groups() : groupByCost(this.deck.lines()))); - protected readonly created = computed(() => { - const d = this.deck.createdAt(); - return d ? new Date(d).toLocaleDateString(uiLocale()) : ''; - }); + protected readonly loadingLabel = $localize`:@@deck.page.loading:Chargement du deck…`; protected readonly navItems = computed(() => [ { route: `/decks/${this.id()}`, icon: 'eye', label: $localize`:@@deck.page.navPreview:Aperçu` }, - { route: `/decks/${this.id()}/deck`, icon: 'layers', label: 'Deck', badge: this.deck.total(), badgeTone: this.legal() ? 'success' : 'dark' }, + { route: `/decks/${this.id()}/deck`, icon: 'layers', label: 'Deck', badge: this.deck.opening() ? undefined : this.deck.total(), badgeTone: this.legal() ? 'success' : 'dark' }, { route: `/decks/${this.id()}/description`, icon: 'text', label: $localize`:@@deck.page.navDescription:Infos` }, { route: `/decks/${this.id()}/main`, icon: 'hand', label: $localize`:@@deck.page.navHand:Main` }, ]); @@ -142,23 +146,11 @@ export class DeckPage { }); } - /** The deck's image (« Copier en image »): a deck of the decks API by its id, a guest deck by its cards. */ - protected readonly imageSource = computed(() => { - const id = this.id(); - if (!GuestDeckService.isGuestId(id)) return deckImageSource(id); - const guest = this.guestDecks.get(id); - return guest ? deckImageSource(id, guest) : null; - }); + /** The deck's image (« Copier en image »). */ + protected readonly imageSource = computed(() => deckImageSourceFor(this.id(), this.guestDecks)); protected readonly factionLogo = computed(() => factionSrc(this.deck.hero()?.faction)); protected readonly factionLabel = computed(() => factionName(this.deck.hero()?.faction)); - protected readonly heroZoomLabel = computed(() => $localize`:@@ui.cardTile.zoom:Agrandir ${this.deck.hero()?.name ?? ''}:name:`); - - /** The hero banner: the hero card large, as on the site's deck page. */ - protected zoomHero(): void { - const hero = this.deck.hero(); - if (hero) openCardZoom(this.overlay, { card: { reference: hero.reference, name: hero.name, faction: { code: hero.faction, name: hero.faction }, cardType: { reference: 'HERO' } } }); - } protected setTab(id: string): void { const path = TAB_PATHS[id as DeckTab] ?? null; @@ -176,13 +168,7 @@ export class DeckPage { } protected async copyList(): Promise { - const text = decklistText(this.deck.lines(), this.deck.hero()); - try { - await navigator.clipboard.writeText(text); - this.flash($localize`:@@deck.page.listCopied:Liste copiée dans le presse-papiers.`); - } catch { - this.flash($localize`:@@deck.page.copyFailed:Copie impossible dans ce navigateur.`); - } + this.flash(await copyDecklist(this.deck)); } /** @@ -203,24 +189,15 @@ export class DeckPage { protected duplicate(): void { openDuplicateDeck(this.overlay).afterClosed.subscribe((id) => { if (!id) return; - this.flash($localize`:@@deck.page.duplicated:Deck dupliqué.`); - void this.router.navigate(['/decks', id]); + // The copy is the user's: it opens in the editor, on « Aperçu ». + void this.router.navigate(['/decks', id, 'edit', 'apercu']); }); } protected remove(): void { - openConfirm(this.overlay, { - title: $localize`:@@deck.page.deleteTitle:Supprimer le deck ?`, - message: $localize`:@@deck.page.deleteConfirm:« ${this.deck.name()}:name: » sera supprimé. Cette action est irréversible.`, - confirmLabel: $localize`:@@deck.page.deleteAction:Supprimer`, - icon: 'trash-2', - danger: true, - }).subscribe((confirmed) => { - if (!confirmed) return; - this.deck.delete().subscribe((ok) => { - if (ok) void this.router.navigateByUrl('/decks'); - else this.flash(this.deck.actionError() ?? $localize`:@@deck.page.deleteFailed:Suppression impossible.`); - }); + confirmDeleteDeck(this.overlay, this.deck).subscribe((result) => { + if (result === true) void this.router.navigateByUrl('/decks'); + else if (result) this.flash(result); }); } diff --git a/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html b/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html index ef666eb4..5db3da11 100644 --- a/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html +++ b/plugins/rebuilder/app/src/app/features/deck/decklist-table/decklist-table.html @@ -13,7 +13,7 @@

    {{ group().label }}

    @for (r of rows(); track r.ref) {
    ×{{ r.qty }} - + {{ r.main }} {{ r.res }} diff --git a/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html b/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html index ab3fbd4c..52ca1a8d 100644 --- a/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html +++ b/plugins/rebuilder/app/src/app/features/deck/hand-calculators/hand-calculators.html @@ -11,7 +11,7 @@

    Calculateurs

    Chances d’avoir

    cartes parmi

    - +

    en piochant {{ drawn() }} cartes

    @if (cardRatio(); as r) {

    {{ r }}

    @@ -23,11 +23,11 @@

    Chances d’avoir au moins une de

    parmi

    - +
    - +

    en piochant {{ drawn() }} cartes

    @if (comboRatio(); as r) { diff --git a/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts b/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts index f947c508..4ac951ce 100644 --- a/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts +++ b/plugins/rebuilder/app/src/app/features/deck/test-hand/test-hand.ts @@ -1,5 +1,5 @@ import { CdkDrag, CdkDragPlaceholder, CdkDropList, CdkDropListGroup, type CdkDragDrop } from '@angular/cdk/drag-drop'; -import { Component, computed, effect, inject, input, signal, untracked } from '@angular/core'; +import { Component, afterNextRender, computed, effect, inject, input, signal, untracked } from '@angular/core'; import { uiLocale } from '../../../core/i18n'; import { contentLocale } from '../../../core/locale'; import { localizedText } from '../../../core/models'; @@ -87,6 +87,8 @@ export class TestHand { effect(() => { if (this.compact()) untracked(() => this.game.set(null)); }); + // For the site's hidden creatures (plugins/hidden-creatures): a spot can wait for this event. No effect otherwise. + afterNextRender(() => window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'test-hand' }))); } newHand(): void { diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html index d17dd1a1..2dbd49fe 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.html @@ -43,7 +43,11 @@

    Decks

    - {{ countLabel() }} + @if (listLoading() && !list().length) { + Chargement… + } @else { + {{ countLabel() }} + } @if (tab() !== 'contest') { } @@ -72,6 +76,12 @@

    Decks

    @for (f of factions; track f.code) { } + + @if (tab() === 'mine' && mineLoading()) { +

    + Chargement de vos decks… Cela peut prendre un moment si vous en avez beaucoup. +

    + }
    } @@ -88,11 +98,6 @@

    Decks

    } } - @if (tab() === 'mine' && mineLoading()) { -

    - Chargement de vos decks… Cela peut prendre un moment si vous en avez beaucoup. -

    - } @if (tab() === 'mine' && (serverError() || auth.sessionNotice())) {

    {{ serverError() || auth.sessionNotice() }}

    } @@ -103,21 +108,23 @@

    Decks

    } -
    +
    @for (d of list(); track d.id) { - + } - @if (tab() === 'mine' && !bp.compact()) { + + @if (listLoading()) { + @for (i of skeletons(); track i) { + + } + } + + @if (tab() === 'mine' && !bp.compact() && !listLoading()) { } - @if ((tab() === 'mine' && mineLoading()) || (tab() === 'community' && communityLoading()) || (tab() === 'contest' && contestLoading())) { - @for (i of [1, 2, 3, 4]; track i) { - - } - }
    @if (tab() === 'mine' && !mineLoading() && !list().length) { diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss index 8c33d892..7087c401 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.scss @@ -185,23 +185,15 @@ justify-content: center; } +/* A deck card's height: grid tile from 768 px, row on phones. */ .skeleton { - height: 188px; - border-radius: var(--ac-radius-card); - background: var(--ac-color-track); - animation: pulse 1.4s ease-in-out infinite; + height: 227px; } .spin { animation: spin 0.9s linear infinite; } -@keyframes pulse { - 50% { - opacity: 0.55; - } -} - @keyframes spin { to { transform: rotate(360deg); @@ -209,7 +201,6 @@ } @media (prefers-reduced-motion: reduce) { - .skeleton, .spin { animation: none; } @@ -295,3 +286,29 @@ margin-right: var(--ac-space-1); } } + +.grid.rows .skeleton { + height: 149px; +} + +.counting { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); +} + +/* The loading notice in the factions row: on its right, without taking a row of its own. */ +/* In the row's free space, never on a row of its own (the list would move up when it goes): no width of its own, it + takes what the chips leave, its text cut. */ +.factions .notice { + flex: 1 1 0; + min-width: 0; + flex-wrap: nowrap; + justify-content: flex-end; +} +.factions .notice > span { + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} diff --git a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts index 6e8a66ec..64dcf663 100644 --- a/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts +++ b/plugins/rebuilder/app/src/app/features/decks/decks-page/decks.page.ts @@ -12,6 +12,7 @@ import { uiLocale } from '../../../core/i18n'; import { contentLocale } from '../../../core/locale'; import { DeckStore } from '../../../core/deck-store'; import { AcButton, AcIconButton } from '../../../ui/buttons'; +import { AcSkeleton } from '../../../ui/containers'; import { AcChip, AcCount } from '../../../ui/chips'; import { AcInput, AcSegmented, AcSelect, AcSwitch } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; @@ -74,7 +75,7 @@ type LikeState = Pick; @Component({ selector: 'app-decks-page', - imports: [RouterLink, AcAppBar, AcBottomNav, AcTabs, AcButton, AcIconButton, AcInput, AcSelect, AcSegmented, AcSwitch, AcChip, AcDeckCard, AcIcon, AcCount, AcInfiniteSentinel], + imports: [RouterLink, AcAppBar, AcBottomNav, AcTabs, AcButton, AcIconButton, AcInput, AcSelect, AcSegmented, AcSwitch, AcChip, AcDeckCard, AcIcon, AcCount, AcInfiniteSentinel, AcSkeleton], templateUrl: './decks.page.html', styleUrl: './decks.page.scss', }) @@ -97,6 +98,8 @@ export class DecksPage { !this.auth.isLoggedIn() && !this.auth.sessionRestoring() && !this.guests.decks().length ? 'community' : 'mine', ); protected readonly tab = computed(() => this.queryTab() ?? this.defaultTab()); + /** « Mes decks »: a deck opens in the editor, on « Aperçu »; elsewhere on its page. */ + protected readonly openInEditor = computed(() => this.tab() === 'mine'); /** Bumped when the list is shown again, so its filters go back to the URL. */ private readonly urlSync = signal(0); protected readonly filters = signal(EMPTY_DECK_FILTERS); @@ -111,6 +114,19 @@ export class DecksPage { }); /** Account decks on their way (first load, or another session): skeletons instead of the empty state. A reload keeps the list. */ protected readonly mineLoading = computed(() => this.serverRes.status() === 'loading'); + /** The open tab's list is loading: skeletons in its place, « Chargement… » for its count. */ + protected readonly listLoading = computed(() => { + const tab = this.tab(); + return (tab === 'mine' && this.mineLoading()) || (tab === 'community' && this.communityLoading()) || (tab === 'contest' && this.contestLoading()); + }); + /** + * Two rows of deck cards from 768 px, a screen of rows on phones. Keyed by the list's length: decks that arrive + * above them take new skeletons below, rather than pushing the same ones down (a layout shift for the browser). + */ + protected readonly skeletons = computed(() => { + const listed = this.list().length; + return Array.from({ length: this.bp.compact() ? 5 : 10 }, (_, i) => `${listed}-${i}`); + }); private readonly serverDecks = computed(() => (this.serverRes.hasValue() ? this.serverRes.value() : [])); protected readonly serverError = computed(() => { const err = this.serverRes.error(); @@ -214,7 +230,7 @@ export class DecksPage { return (tab === 'mine' ? this.mineFiltered() : this.community()).map((d) => (likes[d.id] ? { ...d, ...likes[d.id] } : d)); }); protected readonly tabs = computed(() => [ - { id: 'mine', label: this.mineLoading() ? $localize`:@@decks.page.tabMineLoading:Mes decks · …` : $localize`:@@decks.page.tabMine:Mes decks · ${this.mine().length}:count:` }, + { id: 'mine', label: $localize`:@@decks.page.tabMine:Mes decks`, count: this.mineLoading() ? null : this.mine().length }, { id: 'community', label: $localize`:@@decks.page.tabCommunity:Communauté` }, { id: 'contest', label: $localize`:@@decks.page.tabContest:Concours deck de démarrage` }, ]); @@ -427,7 +443,8 @@ export class DecksPage { // Decks created, edited, duplicated or deleted meanwhile: the kept list is refreshed in place. if (this.auth.isLoggedIn()) this.serverRes.reload(); const y = this.listScroll; - const apply = () => window.scrollTo(0, y); + // Instant: the site's Bootstrap makes the root scroll smooth, and the app scrolls to the top of each page it opens. + const apply = () => window.scrollTo({ top: y, behavior: 'instant' }); apply(); requestAnimationFrame(() => { apply(); diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts index 206fec88..b2662425 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.spec.ts @@ -5,7 +5,7 @@ import { of, throwError, type Observable } from 'rxjs'; import { AuthSession } from '../../../core/auth-session'; import { DecksApiService } from '../../../core/decks-api.service'; import type { Deck, DeckWrite } from '../../../core/models'; -import { OwnershipApiService, type CardQuantity } from '../../../core/ownership-api.service'; +import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; import { EquinoxImport } from './equinox-import'; /** A ZIP with one stored entry. */ @@ -62,8 +62,8 @@ describe('EquinoxImport', () => { let created: DeckWrite[]; let failNext: number; let mine: Deck[]; - let global: boolean; - let altArts: (cards: CardQuantity[]) => CardQuantity[]; + /** The player's families, by reference (their default alt arts). */ + let choices: Record; let mineError: number; function setup(): Harness { @@ -89,8 +89,7 @@ describe('EquinoxImport', () => { { provide: OwnershipApiService, useValue: { - globalAltArts: () => of(global), - applyAltArts: (cards: CardQuantity[]) => of(altArts(cards)), + altArtChoices: (refs: string[]) => of(Object.fromEntries(refs.filter((r) => choices[r]).map((r) => [r, choices[r]]))), }, }, ], @@ -104,9 +103,8 @@ describe('EquinoxImport', () => { vi.useFakeTimers(); failNext = 0; mine = []; - global = false; mineError = 0; - altArts = (cards) => cards.map((c) => ({ ...c, cardReference: `${c.cardReference}_ALT` })); + choices = {}; }); afterEach(() => vi.useRealTimers()); @@ -128,16 +126,22 @@ describe('EquinoxImport', () => { expect(cmp.phase()).toBe('done'); }); - it('applies the « Global » alt-art preference to the exported cards, not to the hero', async () => { - global = true; + it('gives the cards and the hero the player’s default alt arts, within the copies owned', async () => { + choices = { + ALT_A: family(1, ['ALT_A', 'ALT_A_ALT'], 2, ['ALT_A_ALT', 'ALT_A_ALT', 'ALT_A_ALT']), + ALT_HERO: family(2, ['ALT_HERO', 'ALT_HERO_P'], 1, ['ALT_HERO_P']), + }; const cmp = setup(); await run(cmp); - expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A_ALT', quantity: 3 }]); + expect(created[0].deckCards).toEqual([ + { cardReference: 'ALT_HERO_P', quantity: 1 }, + { cardReference: 'ALT_A_ALT', quantity: 2 }, + { cardReference: 'ALT_A', quantity: 1 }, + ]); }); - it('keeps the exported cards when the alt-art answer holds an invalid card', async () => { - global = true; - altArts = (cards) => cards.map((c) => ({ ...c, quantity: 0 })); + it('keeps the exported cards when the defaults give an invalid card', async () => { + choices = { ALT_A: family(1, ['ALT_A', 'oops'], null, ['oops']) }; const cmp = setup(); await run(cmp); expect(created[0].deckCards).toEqual([{ cardReference: 'ALT_HERO', quantity: 1 }, { cardReference: 'ALT_A', quantity: 3 }]); @@ -215,3 +219,11 @@ describe('EquinoxImport', () => { expect(cmp.rows().filter((r) => r.status === 'cancelled').length).toBe(2 - importedWhilePaused); }); }); + +/** A family of `prints` (the plain one first, unlimited; the others owned `owned` times) with the default `slots`. */ +function family(id: number, prints: string[], owned: number | null, slots: string[]): AltArtChoice { + return { + family: { familyId: id, faction: 'AX', rarity: 'C' }, + options: { options: prints.map((reference, i) => ({ reference, ownedQuantity: i === 0 ? null : owned })), slots: slots.map((reference, i) => ({ slotIndex: i + 1, reference })) }, + }; +} diff --git a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts index 26cea41d..8ee18e96 100644 --- a/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts +++ b/plugins/rebuilder/app/src/app/features/decks/equinox/equinox-import.ts @@ -13,6 +13,7 @@ import { uiLocale } from '../../../core/i18n'; import { AcBreakpointService } from '../../../ui/layout.services'; import { type DeckCardRef, type EquinoxDeck, parseEquinoxCsv, sameDeck, validCards, withHero } from './equinox-csv'; import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { cardsWithDefaults } from '../../../core/deck-store'; export type ImportStatus = 'pending' | 'current' | 'imported' | 'skipped' | 'failed' | 'failedFinal' | 'cancelled'; @@ -304,15 +305,12 @@ export class EquinoxImport { // A cancel, or a new file, while the request was in flight: its answer is dropped. const stale = () => this.cancelled() || run !== this.run; try { - // « Global » alt-art preference (read for each deck, as the site's importer does): the user's - // alt arts replace the exported cards, unless the answer holds an invalid card; the hero is - // added afterwards, as it was exported. - let exported = row.deck.cards; - if (await lastValueFrom(this.ownership.globalAltArts())) { - const applied = await lastValueFrom(this.ownership.applyAltArts(exported)); - if (validCards(applied)) exported = applied; - } - const deck = { name: row.deck.name, cards: withHero(row.deck.hero, exported) }; + // The cards and the hero take the user's default alt arts (`cardsWithDefaults`), unless that gives an invalid card. + let cards = withHero(row.deck.hero, row.deck.cards); + const choices = await lastValueFrom(this.ownership.altArtChoices(cards.map((c) => c.cardReference))); + const applied = cardsWithDefaults(cards, choices); + if (validCards(applied)) cards = applied; + const deck = { name: row.deck.name, cards }; let status: ImportStatus = 'skipped'; if (!(await this.alreadyThere(deck))) { await lastValueFrom( diff --git a/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss b/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss index 2b4ab9dd..5e0d534e 100644 --- a/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss +++ b/plugins/rebuilder/app/src/app/features/decks/new-deck-page/new-deck.page.scss @@ -1,5 +1,7 @@ +/* The page's height, also on phones where the form covers the screen and the backdrop stays empty: the page has a first + screen in the flow, which tells the site's runtime to take the server's skeleton away (js/altered-core-host.js). */ .skeleton { - display: none; + height: calc(100dvh - var(--ac-sticky-top)); } @media (min-width: 768px) { .skeleton { @@ -7,7 +9,6 @@ flex-direction: column; gap: var(--ac-space-4); padding: var(--ac-space-6); - height: calc(100dvh - var(--ac-sticky-top)); } .bar { height: 34px; diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts b/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts new file mode 100644 index 00000000..d4d33d66 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-labels.ts @@ -0,0 +1,3 @@ +/** Labels shared by the illustration windows (brush, tokens). */ +export const printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; +export const saveFailed = () => $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html new file mode 100644 index 00000000..3d3ace50 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.html @@ -0,0 +1,61 @@ +
    +

    Ces choix ne valent que pour ce deck : vos arts par défaut ne changent pas.

    +
    +
    +

    + + Choisissez l’illustration de chaque carte +

    +

    Cliquez sur une case au-dessus d’une illustration pour la lui attribuer.

    +
      + @for (a of arts(); track a.reference) { +
    • +
      + @for (k of a.keys; track k.rank) { + + } +
      + + {{ a.label }} + {{ a.note }} +
    • + } +
    + @if (error(); as message) { + + } +
    + +
    +
    + + diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss new file mode 100644 index 00000000..8ee5174b --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.scss @@ -0,0 +1,198 @@ +/* + * Two steps on one level: the illustrations on a band ending in a chevron, which fits into the notch of « Ce que le + * deck utilise »; on a phone, one under the other, the chevron pointing down. + */ +.ac-overlay-body { + display: flex; + flex-direction: column; + gap: var(--ac-space-4); +} +.stage { + --chevron: 44px; + display: flex; + align-items: stretch; +} +p, +h3, +ul { + margin: 0; +} +ul { + padding: 0; + list-style: none; +} +.pool, +.deck { + position: relative; + isolation: isolate; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.pool::before, +.deck::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; +} +.pool { + flex: 1; + min-width: 0; + padding: var(--ac-space-4) calc(var(--chevron) + var(--ac-space-6)) var(--ac-space-4) var(--ac-space-5); +} +.pool::before { + border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); + background: var(--ac-color-primary-tint); + clip-path: polygon(0 0, calc(100% - var(--chevron)) 0, 100% 50%, calc(100% - var(--chevron)) 100%, 0 100%); +} +.step { + display: flex; + align-items: center; + gap: var(--ac-space-2); + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.step-num { + flex: none; + display: inline-grid; + place-items: center; + width: var(--ac-space-6); + aspect-ratio: 1; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); + font: var(--ac-font-caption); +} +.deck .step-num { + background: var(--ac-color-inverse); + color: var(--ac-color-on-inverse); +} +.defaults-note { + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.prints { + flex: 1; + display: flex; + flex-wrap: wrap; + justify-content: center; + align-content: center; + gap: var(--ac-space-6) var(--ac-space-10); +} +/* An illustration under its three keys. */ +.print { + width: min(200px, calc(50% - var(--ac-space-3))); + display: flex; + flex-direction: column; + align-items: center; + gap: var(--ac-space-0-5); + text-align: center; +} +.art { + display: block; + width: 100%; + margin-bottom: var(--ac-space-2); + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-lg); +} +.unowned .art { + opacity: 0.4; +} +.print-label { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.print-note { + font: var(--ac-font-caption); + color: var(--ac-color-text-muted); +} +/* The cards' keys (ac-keys, design system): the 1st card on the left, the 2nd in the middle, the 3rd on the right. */ +.keys { + align-self: stretch; + margin-bottom: var(--ac-space-2); +} +.keys-hint { + margin-top: calc(-1 * var(--ac-space-2)); + padding-left: calc(var(--ac-space-6) + var(--ac-space-2)); + font: var(--ac-font-small); + color: var(--ac-color-text-muted); +} +.error { + font: var(--ac-font-small); + color: var(--ac-color-danger); +} +/* What the deck uses: its copies fanned out, copy 1 on the left. */ +.deck { + flex: none; + width: 300px; + margin-left: calc(var(--ac-space-4) - var(--chevron)); + padding: var(--ac-space-4) var(--ac-space-5) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-5)); +} +.deck::before { + border-radius: 0 var(--ac-radius-card) var(--ac-radius-card) 0; + background: var(--ac-color-bg-app); + clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%, var(--chevron) 50%); +} +.result { + flex: 1; + display: flex; + flex-direction: column; + justify-content: center; + gap: var(--ac-space-4); +} +.fan { + --card: 92px; + --step: 62px; + position: relative; + height: calc(var(--card) * 7 / 5 + var(--ac-space-6)); +} +.fan img { + position: absolute; + top: var(--ac-space-3); + left: calc(50% - (var(--card) + (var(--n) - 1) * var(--step)) / 2 + var(--i) * var(--step)); + width: var(--card); + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); + box-shadow: var(--ac-shadow-card); + transform: rotate(calc((var(--i) - (var(--n) - 1) / 2) * 6deg)); + transform-origin: 50% 120%; +} +.summary { + font: var(--ac-font-body-strong); + color: var(--ac-color-text); +} +.settings { + margin-right: auto; + align-self: center; + font-size: var(--ac-font-size-small); + color: var(--ac-color-link); +} +@media (max-width: 767px) { + .stage { + --chevron: 28px; + flex-direction: column; + } + .pool { + padding: var(--ac-space-4) var(--ac-space-4) calc(var(--chevron) + var(--ac-space-4)); + } + .pool::before { + border-radius: var(--ac-radius-card) var(--ac-radius-card) 0 0; + clip-path: polygon(0 0, 100% 0, 100% calc(100% - var(--chevron)), 50% 100%, 0 calc(100% - var(--chevron))); + } + .deck { + width: auto; + margin: calc(var(--ac-space-3) - var(--chevron)) 0 0; + padding: calc(var(--chevron) + var(--ac-space-4)) var(--ac-space-5) var(--ac-space-5); + } + .deck::before { + border-radius: 0 0 var(--ac-radius-card) var(--ac-radius-card); + clip-path: polygon(0 0, 50% var(--chevron), 100% 0, 100% 100%, 0 100%); + } + .prints { + gap: var(--ac-space-5) var(--ac-space-4); + } +} diff --git a/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts new file mode 100644 index 00000000..3ada993c --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/alt-art-picker/alt-art-picker.overlay.ts @@ -0,0 +1,146 @@ +import { Component, computed, inject, signal } from '@angular/core'; +import type { Observable } from 'rxjs'; +import { environment } from '../../../../environments/environment'; +import { canRank, defaultPrints, rankPrints, sameCopies } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import type { AltArtChoice } from '../../../core/ownership-api.service'; +import { AcButton } from '../../../ui/buttons'; +import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { printLabel, saveFailed } from '../alt-art-labels'; + +export interface AltArtPickerData { + /** The card's family, with the player's default alt arts. */ + choice: () => AltArtChoice; + /** The family's copies in the deck, one print a copy. */ + prints: () => readonly string[]; + /** The illustration of the 1st, 2nd and 3rd card in this deck. */ + cards: () => readonly string[]; + /** The family's copies in the deck, one print a copy, copy 1 first (as on the deck board). */ + copies: () => readonly string[]; + /** The 1st, 2nd and 3rd card take `cards` in this deck, its copies too; errors reach the caller. */ + setCards: (cards: readonly string[]) => Observable; + /** The family follows the default alt arts again in this deck. */ + reset: () => void; +} + +/** + * The brush of a card: its illustrations, each under three keys, its 1st, 2nd and 3rd card (one a copy). A key pressed + * is a card that takes this illustration in the deck; pressing another key gives the illustration that card. Then what + * the deck uses (with 2 copies, the 1st and 2nd cards; an added copy takes the 3rd card's). A change here is for this + * deck only: its copies take it at once, the player's default alt arts stay as they are. + */ +@Component({ + selector: 'app-alt-art-picker', + imports: [AcButton], + host: { class: 'ac-overlay-content' }, + templateUrl: './alt-art-picker.overlay.html', + styleUrl: './alt-art-picker.overlay.scss', +}) +export class AltArtPickerOverlay { + protected readonly ref = inject>(AcOverlayRef); + private readonly data = this.ref.data!; + private readonly choice = this.data.choice; + /** The illustration of each card in this deck, 1st first. */ + private readonly ranks = this.data.cards; + private readonly copies = computed(() => this.data.prints().length); + /** The deck's cards and copies are the default alt arts. */ + protected readonly followsDefaults = computed( + () => this.ranks().join() === rankPrints(this.choice()).join() && sameCopies(this.data.prints(), defaultPrints(this.choice(), this.copies())), + ); + protected readonly saving = signal(false); + protected readonly error = signal(null); + protected readonly cardWord = cardWord(); + /** The site's « Arts alternatifs par défaut » page (plugin ownership). */ + protected readonly settingsUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/ownership-alt-arts`; + + /** The illustrations in the service's order (the plain print first): a choice does not move them. */ + protected readonly arts = computed(() => { + const choice = this.choice(); + const ranks = this.ranks(); + return choice.options.options.map((o, i) => { + const label = printLabel(i + 1); + return { + reference: o.reference, + src: cardImageUrl(o.reference), + label, + note: o.ownedQuantity === null ? unlimited() : o.ownedQuantity === 0 ? notOwned() : ownedLabel(o.ownedQuantity), + unowned: o.ownedQuantity === 0, + keysLabel: keysLabel(label), + /** The 1st card on the left, the 2nd in the middle, the 3rd on the right. */ + keys: ranks.map((r, k) => ({ + rank: k, + num: k + 1, + suffix: cardSuffix(k), + label: cardLabel(k), + pressed: r === o.reference, + disabled: r !== o.reference && !canRank(choice, ranks, k, o.reference), + })), + }; + }); + }); + + /** The deck's copies, copy 1 first (as on the deck board). */ + protected readonly preview = computed(() => this.data.copies().map((print) => cardImageUrl(print))); + + protected readonly summary = computed(() => { + const n = this.copies(); + if (n === 0) return summaryNone(); + return n === 1 ? summaryOne() : n === 2 ? summaryTwo() : summaryAll(n); + }); + + /** Card `rank` takes illustration `reference` in this deck, its copies too. */ + protected choose(reference: string, rank: number): void { + const ranks = this.ranks(); + if (ranks[rank] === reference || this.saving()) return; + this.error.set(null); + this.saving.set(true); + this.data.setCards(ranks.map((r, k) => (k === rank ? reference : r))).subscribe({ + complete: () => this.saving.set(false), + error: () => { + this.saving.set(false); + this.error.set(saveFailed()); + }, + }); + } + + /** « Arts par défaut »: the card follows the default alt arts again in this deck. */ + protected resetToDefaults(): void { + this.error.set(null); + this.data.reset(); + } +} + +const cardLabel = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.card1:1ère carte` + : k === 1 + ? $localize`:@@editor.altArtPicker.card2:2ème carte` + : $localize`:@@editor.altArtPicker.card3:3ème carte`; +/** Ordinal suffix on a key (« 1ère », « 2ème »). */ +const cardSuffix = (k: number) => + k === 0 + ? $localize`:@@editor.altArtPicker.suffix1:ère` + : k === 1 + ? $localize`:@@editor.altArtPicker.suffix2:ème` + : $localize`:@@editor.altArtPicker.suffix3:ème`; +const cardWord = () => $localize`:@@editor.altArtPicker.cardWord:carte`; +const keysLabel = (label: string) => $localize`:@@editor.altArtPicker.menu:Cartes de ${label}:label:`; +const unlimited = () => $localize`:@@editor.altArtPicker.unlimited:Illimitée`; +const notOwned = () => $localize`:@@editor.altArtPicker.notOwned:Non possédée`; +const ownedLabel = (n: number) => + n > 1 ? $localize`:@@editor.altArtPicker.ownedMany:${n}:n: possédées` : $localize`:@@editor.altArtPicker.ownedOne:1 possédée`; +const summaryNone = () => $localize`:@@editor.altArtPicker.summaryNone:Ce deck n’a pas encore d’exemplaire de cette carte.`; +const summaryOne = () => $localize`:@@editor.altArtPicker.summaryOne:Avec 1 exemplaire, le deck prend l’illustration de la 1ère carte.`; +const summaryTwo = () => $localize`:@@editor.altArtPicker.summaryTwo:Avec 2 exemplaires, le deck prend les illustrations des 1ère et 2ème cartes.`; +const summaryAll = (n: number) => $localize`:@@editor.altArtPicker.summaryAll:Avec ${n}:n: exemplaires, le deck prend les illustrations des trois cartes.`; + +/** The brush of a card of the deck, in its own window (sheet on a phone). */ +export function openAltArtPicker(overlay: AcOverlayService, name: string, data: AltArtPickerData): AcOverlayRef { + const copies = data.prints().length; + return overlay.open(AltArtPickerOverlay, { + title: name, + subtitle: copies > 1 ? $localize`:@@editor.altArtPicker.copiesMany:${copies}:n: exemplaires dans ce deck` : $localize`:@@editor.altArtPicker.copiesOne:1 exemplaire dans ce deck`, + data, + width: 1000, + }); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html index 439f470e..e0a7c7aa 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.html @@ -1,4 +1,24 @@ +@if (deck.opening()) { + @if (!readonly()) { + + } + + @if (!readonly() && altArts && altArts.tokensChoice() !== false) { +
    + + @if (altArts.defaultsChoice() !== false) { + + } +
    + } + +} @else { @if (!readonly()) { } -@if (!readonly() && altArts?.canChooseTokens()) { - +@if (!readonly() && altArts?.canChoose()) { +
    + + @if (altArts.canChooseForDeck()) { + + } +
    +} @else if (!readonly() && altArts?.tokensChoice() === null) { +
    + + @if (altArts.defaultsChoice() !== false) { + + } +
    } @for (g of deck.groups(); track g.id) { @@ -26,10 +59,10 @@ } @@ -37,3 +70,4 @@ } @empty {

    Aucune carte pour l’instant.

    } +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss index f2784c2a..1f4f9ec4 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.scss @@ -6,7 +6,34 @@ .stats { border-radius: var(--ac-radius-card); } +.alt-arts { + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} .empty { text-align: center; color: var(--ac-color-text-muted); } +/* While the deck opens: the summary, the stats, « Illustrations des jetons » and « Appliquer les arts par défaut » (when they may come) and one section of + rows, at their sizes. */ +.summary-skeleton { + height: 159px; +} +.stats-skeleton { + height: 190px; +} +.section-skeleton { + display: flex; + flex-direction: column; +} +.section-skeleton > .ac-skeleton { + margin: var(--ac-space-2) var(--ac-space-1) var(--ac-space-3); +} +.row-skeleton { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ac-space-3); + height: 51px; +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts index 54617e6b..ca4ae0c7 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-list-view/deck-list-view.ts @@ -1,10 +1,10 @@ import { Component, computed, inject, input, output, signal } from '@angular/core'; import { DeckStore } from '../../../core/deck-store'; import { formatInfo } from '../../../core/formats'; -import { AcCollapsible } from '../../../ui/containers'; +import { AcCollapsible, AcSkeleton } from '../../../ui/containers'; import { AcDeckRow, AcDeckSection, AcDeckStats, AcDeckSummary } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; -import { EditorAltArts } from '../editor-alt-arts'; +import { EditorAltArts, confirmAltArtDefaults } from '../editor-alt-arts'; import { openTokenArts } from '../token-arts/token-arts.overlay'; import { AcButton } from '../../../ui/buttons'; import { editorLegality, lineIssues, openEditorLegality } from '../editor-legality'; @@ -12,7 +12,7 @@ import { editorLegality, lineIssues, openEditorLegality } from '../editor-legali /** Mobile « Mon deck »: summary card, Stats card, flat grouped lists (steppers or read-only). */ @Component({ selector: 'app-deck-list-view', - imports: [AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow, AcDeckSection, AcButton], + imports: [AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow, AcDeckSection, AcButton, AcSkeleton], templateUrl: './deck-list-view.html', styleUrl: './deck-list-view.scss', }) @@ -23,6 +23,9 @@ export class DeckListView { private readonly overlay = inject(AcOverlayService); readonly readonly = input(false); readonly openSettings = output(); + readonly changeHero = output(); + /** A message for the page's notice (« Arts par défaut »). */ + readonly notice = output(); protected readonly statsOpen = signal(true); protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); protected readonly legality = computed(() => editorLegality(this.deck)); @@ -36,4 +39,11 @@ export class DeckListView { protected chooseTokenArts(): void { openTokenArts(this.overlay); } + + protected applyDefaults(): void { + if (!this.altArts) return; + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.notice.emit(message); + }); + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html new file mode 100644 index 00000000..369216d9 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.html @@ -0,0 +1,58 @@ +
    +
    + + @if (deck.hero(); as h) { + + } + +
    + {{ deck.hero()?.name || noHero }} + {{ meta() }} + +
    +
    + +
    + + @if (problems().length) { +
      + @for (p of problems(); track $index) { +
    • {{ p }}
    • + } +
    + } +
    + + @if (limits().length) { +
    +

    {{ rarityTitle() }}

    +
      + @for (r of limits(); track r.key) { +
    • + + {{ r.name }} + + {{ r.count }}/{{ r.limit }} +
    • + } +
    +
    + } + +
    +

    Composition

    +

    + {{ deck.total() }}&ngsp;{deck.total(), plural, =1 {carte} other {cartes}} + @for (g of deck.groups(); track g.id) { + {{ g.count }} {{ g.label }} + } +

    +
    +
    + diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss new file mode 100644 index 00000000..07ed3232 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.scss @@ -0,0 +1,136 @@ +.ac-overlay-body { + display: flex; + flex-direction: column; + gap: var(--ac-space-5); + padding-bottom: var(--ac-space-5); +} +section { + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} +h3 { + margin: 0; + font-size: var(--ac-font-size-micro); + font-weight: 700; + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text-muted); +} + +/* Hero: the card's art, then its name, « faction · format · visibility » and « Changer de héros ». */ +.hero { + flex-direction: row; + align-items: center; + gap: var(--ac-space-4); +} +.art { + position: relative; + width: 76px; + aspect-ratio: 63 / 88; + flex-shrink: 0; + border-radius: var(--ac-radius-card); + overflow: hidden; + background: var(--ac-color-track); + box-shadow: var(--ac-shadow-card); +} +.who { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ac-space-1); +} +.name { + font: var(--ac-font-heading); +} +.meta { + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-2); + margin-bottom: var(--ac-space-1); +} + +/* Legality: the badge opens the format's rules; the failed ones are listed below. */ +.legality { + padding: var(--ac-space-3); + border-radius: var(--ac-radius-md); + background: var(--ac-color-warning-soft); +} +.legality.ok { + background: var(--ac-color-success-soft); +} +.legality-head { + display: flex; + align-items: center; + gap: var(--ac-space-2); + min-height: var(--ac-hit-min); + margin: calc(-1 * var(--ac-space-2)) 0; + padding: 0; + border: 0; + background: none; + color: var(--ac-color-text-2); + font: var(--ac-font-small); + text-align: left; + cursor: pointer; +} +.legality-head:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; + border-radius: var(--ac-radius-sm); +} +.details { + flex-grow: 1; + text-align: right; +} +.legality ul { + margin: 0; + padding-left: var(--ac-space-5); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text); +} + +/* Rarities against the caps: icon, name, bar, count. */ +.limits { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.limits li { + display: grid; + grid-template-columns: 28px 72px minmax(0, 1fr) 48px; + align-items: center; + gap: var(--ac-space-2); + font-size: var(--ac-font-size-small); +} +.limits img { + height: 14px; + width: auto; + max-width: 100%; + justify-self: center; + display: block; +} +.limits strong { + text-align: right; + font-variant-numeric: tabular-nums; +} +.limits .at strong { + color: var(--ac-color-success); +} +.limits .over strong { + color: var(--ac-color-danger); +} + +.counts { + margin: 0; + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-1) var(--ac-space-4); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-2); +} +.counts strong { + color: var(--ac-color-text); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts new file mode 100644 index 00000000..dc5ba37a --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/deck-overview/deck-overview.overlay.ts @@ -0,0 +1,73 @@ +import { Component, computed, inject } from '@angular/core'; +import { illegalLabel, legalityCheckDetail, legalityCheckLabel, legalityRuleLabel } from '../../../core/deck-legality'; +import { rarityLimits } from '../../../core/deck-rules'; +import { DeckStore } from '../../../core/deck-store'; +import { formatInfo } from '../../../core/formats'; +import { AcButton } from '../../../ui/buttons'; +import { AcBadge } from '../../../ui/chips'; +import { AcProgressBar } from '../../../ui/containers'; +import { AcIcon } from '../../../ui/icon'; +import { AcCardArt, factionName, rarityLimitItems } from '../../../ui/metier'; +import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { editorLegality } from '../editor-legality'; + +/** What the summary leads to once closed: the hero picker, the legality details or the « Deck » view. */ +export type DeckOverviewAction = 'hero' | 'legality' | 'deck'; + +/** The bar of a capped rarity: green at the cap, red above it. */ +const BAR_TONES = { under: 'primary', at: 'success', over: 'danger' } as const; + +/** + * « Résumé du deck » on phones, from the app bar of the search view: the hero (and « Changer de héros »), the legality, + * the rarities against the format's caps and the counts per type. It follows the deck while open. + */ +@Component({ + selector: 'app-deck-overview', + imports: [AcButton, AcBadge, AcCardArt, AcIcon, AcProgressBar], + host: { class: 'ac-overlay-content' }, + templateUrl: './deck-overview.overlay.html', + styleUrl: './deck-overview.overlay.scss', +}) +export class DeckOverviewOverlay { + protected readonly ref = inject>(AcOverlayRef); + protected readonly deck = inject(DeckStore); + + protected readonly meta = computed(() => + [ + factionName(this.deck.hero()?.faction), + formatInfo(this.deck.format()).label, + this.deck.isPublic() ? $localize`:@@editor.public:Public` : $localize`:@@editor.private:Privé`, + ] + .filter(Boolean) + .join(' · '), + ); + protected readonly legality = computed(() => editorLegality(this.deck)); + protected readonly legal = computed(() => this.legality().state === 'legal'); + protected readonly legalityLabel = computed(() => + this.legal() ? $localize`:@@deck.page.legal:Légal` : illegalLabel(this.legality().rules.length + this.legality().errors.length), + ); + /** The failed rules, with their figures when the editor computed them (« Rares : 16 / 15 »). */ + protected readonly problems = computed(() => { + const l = this.legality(); + const failed = l.checks ? l.checks.filter((c) => !c.ok).map((c) => [legalityCheckLabel(c.rule), legalityCheckDetail(c)].filter(Boolean).join(' : ')) : l.rules.map(legalityRuleLabel); + return [...failed, ...l.errors]; + }); + /** Rares, Exalteds and Uniques against the format's caps: a bar each, green at the cap, red above it. */ + protected readonly limits = computed(() => + rarityLimitItems(rarityLimits(this.deck.status(), this.deck.format())).map((r) => ({ + ...r, + tone: BAR_TONES[r.state], + value: r.limit > 0 ? Math.min(100, (r.count / r.limit) * 100) : r.count > 0 ? 100 : 0, + })), + ); + protected readonly rarityTitle = computed(() => $localize`:@@editor.overview.rarities:Raretés · ${formatInfo(this.deck.format()).label}:format:`); + protected readonly changeHeroLabel = $localize`:@@ui.deckSummary.changeHero:Changer de héros`; + protected readonly noHero = $localize`:@@ui.deckSummary.noHero:Aucun héros`; +} + +export function openDeckOverview(overlay: AcOverlayService): AcOverlayRef { + return overlay.open(DeckOverviewOverlay, { + title: $localize`:@@editor.overview.title:Résumé du deck`, + width: 480, + }); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html index bbeed3c7..3aaa616e 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.html @@ -1,29 +1,12 @@ -
    - -
    -
    - - - - @if (altArts?.canChooseTokens()) { -
    - -
    - } -
    + @if (deck.opening()) { +
    +
    + @for (r of [0, 1, 2, 3, 4]; track r) { +
    + } +
    + } @else { @for (g of deck.groups(); track g.id) {
    {{ g.label }}{{ g.count }}
    @@ -32,10 +15,10 @@ appearance="panel" [card]="l.card" [quantity]="l.quantity" - [max]="deck.maxFor(l.card)" + [max]="altArts?.maxFor(l.card, false) ?? deck.maxFor(l.card)" [issues]="issues().get(l.card.reference) ?? noIssues" [warning]="altArts?.warnings()?.get(l.card.reference) ?? null" - [readonly]="!deck.editable()" + [readonly]="!deck.steppersShown()" (quantityChange)="deck.setQuantity(l.card, $event)" /> } @@ -43,4 +26,5 @@ } @empty {

    Ajoutez des cartes depuis la recherche avec le bouton « + ».

    } + }
    diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss index 90fabad2..894a32c0 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.scss @@ -7,15 +7,6 @@ overflow: hidden; min-height: 0; } -.summary { - padding: var(--ac-space-4); - border-bottom: 1px solid var(--ac-color-border); - flex-shrink: 0; -} -.stats { - border-bottom: 1px solid var(--ac-color-border); - flex-shrink: 0; -} .rows { flex: 1 1 auto; min-height: 0; @@ -37,7 +28,15 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } -/* Part of the Stats block, above its bottom border: space on both sides of the button. */ -.tokens { - padding: var(--ac-space-1) var(--ac-space-4) var(--ac-space-4); +.group-head .ac-skeleton { + margin: 0.2em 0; +} +/* While the deck opens: a card row's height (ac-deck-row, panel appearance). */ +.row-skeleton { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--ac-space-3); + height: 36px; + padding: 0 var(--ac-space-1); } diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts index f4b5b6d7..756d829d 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-panel/deck-panel.ts @@ -1,38 +1,21 @@ -import { Component, computed, inject, output, signal } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; import { DeckStore } from '../../../core/deck-store'; -import { formatInfo } from '../../../core/formats'; -import { AcCollapsible } from '../../../ui/containers'; -import { AcDeckRow, AcDeckStats, AcDeckSummary } from '../../../ui/metier'; -import { AcOverlayService } from '../../../ui/overlay'; +import { AcDeckRow } from '../../../ui/metier'; import { EditorAltArts } from '../editor-alt-arts'; -import { openTokenArts } from '../token-arts/token-arts.overlay'; -import { AcButton } from '../../../ui/buttons'; -import { editorLegality, lineIssues, openEditorLegality } from '../editor-legality'; +import { lineIssues } from '../editor-legality'; +import { AcSkeleton } from '../../../ui/containers'; -/** Desktop right-hand panel: embedded summary, Stats, grouped rows with steppers. */ +/** Desktop right-hand panel of the editor: the deck's cards grouped by type, with steppers (the deck bar holds the rest). */ @Component({ selector: 'app-deck-panel', - imports: [AcButton, AcDeckSummary, AcCollapsible, AcDeckStats, AcDeckRow], + imports: [AcDeckRow, AcSkeleton], templateUrl: './deck-panel.html', styleUrl: './deck-panel.scss', }) export class DeckPanel { - /** Editor only (not the deck page): illustrations used more times than owned. */ + /** Illustrations used more times than owned. */ protected readonly altArts = inject(EditorAltArts, { optional: true }); protected readonly deck = inject(DeckStore); - private readonly overlay = inject(AcOverlayService); - readonly openSettings = output(); - protected readonly statsOpen = signal(true); - protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); - protected readonly legality = computed(() => editorLegality(this.deck)); protected readonly issues = computed(() => lineIssues(this.deck)); protected readonly noIssues: readonly string[] = []; - - protected showLegality(): void { - openEditorLegality(this.overlay, this.deck); - } - - protected chooseTokenArts(): void { - openTokenArts(this.overlay); - } } diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html index 070673b1..34c0ef20 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.html @@ -1,3 +1,17 @@ +@if (deck.opening()) { + + +} @else {

    {deck.total(), plural, =1 {1 carte} other {{{ deck.total() }} cartes}} · {deck.distinct(), plural, =1 {1 différente} other {{{ deck.distinct() }} différentes}}

    @if (deck.groups().length) { @@ -18,11 +32,13 @@ @@ -35,3 +51,4 @@

    Ajoutez des cartes depuis la recherche.

    } +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss index 78b01422..a6bfaf4b 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.scss @@ -57,3 +57,14 @@ padding: var(--ac-space-10) var(--ac-space-4); color: var(--ac-color-text-2); } +/* While the deck opens: the bar and one section (ac-deck-section, an ac-card) of four cards, in place. */ +.section-skeleton { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.head-skeleton { + display: flex; + align-items: center; + min-height: var(--ac-hit-min); +} diff --git a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts index 24cc0b7b..56cb7e71 100644 --- a/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts +++ b/plugins/rebuilder/app/src/app/features/editor/deck-preview/deck-preview.ts @@ -1,17 +1,19 @@ import { Component, computed, inject, input, signal } from '@angular/core'; -import { DeckStore } from '../../../core/deck-store'; import { AcButton } from '../../../ui/buttons'; +import { AcSkeleton } from '../../../ui/containers'; import { AcCardTile, AcDeckSection } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { EditorAltArts } from '../editor-alt-arts'; import type { Card } from '../../../core/models'; +import { DeckStore, displayName } from '../../../core/deck-store'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; +import { openAltArtPicker } from '../alt-art-picker/alt-art-picker.overlay'; /** « Aperçu » / « Cartes »: count line, « Tout replier », one section per type with tiles. */ @Component({ selector: 'app-deck-preview', - imports: [AcDeckSection, AcCardTile, AcButton], + imports: [AcDeckSection, AcCardTile, AcButton, AcSkeleton], templateUrl: './deck-preview.html', styleUrl: './deck-preview.scss', }) @@ -20,7 +22,7 @@ export class DeckPreview { private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); /** Editor only: prints of the deck's cards (per-deck alt-art mode). */ - private readonly altArts = inject(EditorAltArts, { optional: true }); + protected readonly altArts = inject(EditorAltArts, { optional: true }); readonly readonly = input(false); /** Three columns on compact (consultation « Cartes »). */ readonly dense = input(false); @@ -43,14 +45,24 @@ export class DeckPreview { /** A tile's visual: the card large, with its copies when the deck can be edited (the site's card lightbox). */ protected zoom(card: Card): void { const editable = !this.readonly() && this.deck.editable(); - const choice = this.altArts?.choiceFor(card.reference) ?? null; openCardZoom(this.overlay, { card, quantity: editable ? { value: this.deck.quantities().get(card.reference) ?? 0, max: this.deck.maxFor(card), change: (n) => this.deck.setQuantity(card, n) } : undefined, - illustrations: editable && choice ? { choice, pick: (ref) => this.deck.swapReference(card, ref) } : undefined, + illustrations: editable ? (this.altArts?.pickerFor(card) ?? undefined) : undefined, }); } + /** `card` has a brush (several illustrations, the deck editable). */ + protected canIllustrate(card: Card): boolean { + return !this.readonly() && !!this.altArts?.canIllustrate(card); + } + + /** The brush of a card: its illustrations in this deck. */ + protected illustrate(card: Card): void { + const data = this.altArts?.pickerFor(card); + if (data) openAltArtPicker(this.overlay, displayName(card), data); + } + toggleAll(): void { this.closed.set(this.allClosed() ? new Set() : new Set(this.deck.groups().map((g) => g.id))); } diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts new file mode 100644 index 00000000..b4a38313 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.spec.ts @@ -0,0 +1,193 @@ +import { TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { Subject, of, throwError } from 'rxjs'; +import { DeckAltArtsApiService } from '../../core/deck-alt-arts-api.service'; +import { AuthSession } from '../../core/auth-session'; +import { DeckStore } from '../../core/deck-store'; +import type { Card, HydratedLine } from '../../core/models'; +import { OwnershipApiService, type AltArtChoice } from '../../core/ownership-api.service'; +import { EditorAltArts } from './editor-alt-arts'; + +const BASE = 'ALT_CORE_B_AX_04_C'; +const ALT = 'ALT_DUSTERTOP_P_AX_04_C'; +const NONE = 'ALT_CORE_A_AX_04_C'; +const kelon: AltArtChoice = { + family: { familyId: 302, faction: 'AX', rarity: 'C' }, + options: { + options: [ + { reference: BASE, ownedQuantity: null }, + { reference: ALT, ownedQuantity: 2 }, + { reference: NONE, ownedQuantity: 0 }, + ], + slots: [1, 2, 3].map((slotIndex) => ({ slotIndex, reference: ALT })), + }, +}; +const card = (reference: string): Card => ({ reference, name: 'Élémentaire de Kélon' }); + +describe('EditorAltArts', () => { + let answers: Subject>; + let lines: ReturnType>; + let familyAdds: number[]; + let familyChoices: AltArtChoice[]; + let saved: [string, string, readonly string[] | null][]; + let saveFails: boolean; + let loaded: Record; + let applied: Record[]; + + function setup(cards: Record = {}, global = false): EditorAltArts { + loaded = cards; + applied = []; + answers = new Subject(); + lines = signal([]); + familyAdds = []; + familyChoices = []; + saved = []; + saveFails = false; + TestBed.configureTestingModule({ + providers: [ + EditorAltArts, + { provide: AuthSession, useValue: { isLoggedIn: () => true } }, + { + provide: OwnershipApiService, + useValue: { + baseUrl: '/ownership', + altArtChoices: () => answers, + globalAltArts: () => of(global), + }, + }, + { + provide: DeckAltArtsApiService, + useValue: { + load: () => of(loaded), + save: (deck: string, family: string, cards: readonly string[] | null) => + saveFails ? throwError(() => new Error('down')) : (saved.push([deck, family, cards]), of(undefined)), + clear: () => of(undefined), + }, + }, + { + provide: DeckStore, + useValue: { + lines, + hero: signal(null), + editable: signal(true), + deckId: signal('d1'), + isGuest: signal(false), + maxFor: () => 3, + quantityOf: (ref: string) => lines().find((l) => l.card.reference === ref)?.quantity ?? 0, + setQuantity: () => undefined, + applyAltArtDefaults: (choices: Record) => (applied.push(choices), true), + setFamilyQuantity: (_card: Card, choice: AltArtChoice, _members: Set, n: number) => (familyAdds.push(n), familyChoices.push(choice)), + setFamilyPrints: (_members: Set, _card: Card, prints: string[]) => + lines.set([...new Set(prints)].map((reference) => ({ card: card(reference), quantity: prints.filter((p) => p === reference).length }))), + }, + }, + ], + }); + return TestBed.inject(EditorAltArts); + } + + it('waits for a card’s family before adding a copy, so that it takes its default alt art', () => { + const alt = setup(); + alt.request([BASE]); + alt.setQuantity(card(BASE), 1); + expect(familyAdds).toEqual([]); + answers.next({ [BASE]: kelon }); + expect(familyAdds).toEqual([1]); + }); + + it('gives a print the copies its family leaves, and a family card the whole limit', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([ + { card: card(BASE), quantity: 2 }, + { card: card(ALT), quantity: 1 }, + ]); + expect(alt.maxFor(card(ALT), false)).toBe(1); + expect(alt.maxFor(card(BASE), false)).toBe(2); + expect(alt.maxFor(card(BASE), true)).toBe(3); + expect(alt.familyQuantity(card(BASE))).toBe(3); + }); + + const KEY = '302:AX:C'; + const slotsOf = (choice: AltArtChoice) => choice.options.slots.map((slot) => slot.reference); + + it('keeps the brush’s cards for the deck, its copies taking them, and an added copy takes the next card’s', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([{ card: card(BASE), quantity: 2 }]); + alt.setCards(card(BASE), [ALT, BASE, BASE]).subscribe(); + expect(saved).toEqual([['d1', KEY, [ALT, BASE, BASE]]]); + expect(lines().map((l) => [l.card.reference, l.quantity])).toEqual([[ALT, 1], [BASE, 1]]); + expect(alt.cardsFor(kelon)).toEqual([ALT, BASE, BASE]); + // The default alt arts (ALT on every slot) do not change; the 3rd copy takes the deck's 3rd card. + expect(slotsOf(kelon)).toEqual([ALT, ALT, ALT]); + alt.setFamilyQuantity(card(BASE), 3); + expect(slotsOf(familyChoices[0])).toEqual([ALT, BASE, BASE]); + }); + + it('keeps no choice for the deck when its three cards take the plain print', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([{ card: card(BASE), quantity: 2 }]); + alt.setCards(card(BASE), [BASE, BASE, BASE]).subscribe(); + expect(saved).toEqual([['d1', KEY, null]]); + expect(alt.cardsFor(kelon)).toEqual([BASE, BASE, BASE]); + }); + + it('reads the deck’s cards, and leaves them while the deck’s copies do not match them', () => { + const alt = setup({ [KEY]: [BASE, ALT, BASE] }); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([ + { card: card(BASE), quantity: 1 }, + { card: card(ALT), quantity: 1 }, + ]); + TestBed.tick(); + expect(alt.cardsFor(kelon)).toEqual([BASE, ALT, BASE]); + // Copies changed elsewhere: the cards come from the copies, then the defaults. + lines.set([{ card: card(BASE), quantity: 2 }]); + expect(alt.cardsFor(kelon)).toEqual([BASE, BASE, ALT]); + }); + + it('puts the deck’s previous cards and copies back when the choice cannot be saved', () => { + const alt = setup(); + alt.request([BASE]); + answers.next({ [BASE]: kelon }); + lines.set([{ card: card(BASE), quantity: 2 }]); + saveFails = true; + let failed = false; + alt.setCards(card(BASE), [ALT, ALT, BASE]).subscribe({ error: () => (failed = true) }); + expect(failed).toBe(true); + expect(alt.cardsFor(kelon)).toEqual([BASE, BASE, ALT]); + expect(lines()).toEqual([{ card: card(BASE), quantity: 2 }]); + }); + + it('« par deck » mode: a card with several illustrations has a brush, and the deck keeps its prints', () => { + const alt = setup(); + lines.set([{ card: card(BASE), quantity: 2 }]); + TestBed.tick(); + answers.next({ [BASE]: kelon }); + TestBed.tick(); + expect(alt.canChooseForDeck()).toBe(true); + expect(alt.canIllustrate(card(BASE))).toBe(true); + expect(applied).toEqual([]); + }); + + it('« Global » mode: the deck takes the default alt arts once its families are known and after each change, without a brush', () => { + const alt = setup({}, true); + lines.set([{ card: card(BASE), quantity: 2 }]); + TestBed.tick(); + expect(applied).toEqual([]); + answers.next({ [BASE]: kelon }); + TestBed.tick(); + expect(applied).toEqual([{ [BASE]: kelon }]); + lines.set([{ card: card(BASE), quantity: 3 }]); + TestBed.tick(); + expect(applied.length).toBe(2); + expect(alt.canChooseForDeck()).toBe(false); + expect(alt.canIllustrate(card(BASE))).toBe(false); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts index b709744b..559a87ac 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-alt-arts.ts @@ -1,39 +1,78 @@ -import { Service, computed, effect, inject, untracked } from '@angular/core'; -import { rxResource, toSignal } from '@angular/core/rxjs-interop'; -import { of } from 'rxjs'; +import { Service, computed, effect, inject, signal, untracked } from '@angular/core'; +import { EMPTY, type Observable, catchError, filter, map, of, switchMap, take, tap, throwError } from 'rxjs'; +import { toObservable, toSignal } from '@angular/core/rxjs-interop'; +import { AuthSession } from '../../core/auth-session'; +import { basePrint, defaultPrints, familyCopies, familyPrints, rankPrints, sameCopies, withRanks } from '../../core/alt-art-defaults'; +import { DeckAltArtsApiService } from '../../core/deck-alt-arts-api.service'; import { DeckStore } from '../../core/deck-store'; +import { storedFlag } from '../../core/stored-flag'; +import type { Card } from '../../core/models'; import { OwnershipApiService, familyKey, type AltArtChoice } from '../../core/ownership-api.service'; +import type { AltArtPickerData } from './alt-art-picker/alt-art-picker.overlay'; +import { openConfirm } from '../shared/confirm/confirm.overlay'; +import type { AcOverlayService } from '../../ui/overlay'; + +/** References asked to the site at once (`deck-alt-arts`, a GET with one parameter a reference). */ +const CHUNK = 60; /** - * Illustrations in the editor, as the site's deck builder: in « Global » alt-art mode the deck follows the player's - * preferred prints; otherwise each card's print can be chosen, and a print used more times than owned is flagged - * (Board Game Arena shows the base art for the missing copies). Needs the ownership service; nothing happens without. + * Illustrations in the editor (`core/alt-art-defaults.ts`): a card added from the search takes the player's default alt + * art for its copy, the brush of a card chooses the illustration of its 1st, 2nd and 3rd card in this deck (kept by + * `DeckAltArtsApiService`; an added copy takes the next card's), « Appliquer les arts par défaut » rewrites the deck + * with the defaults, and a print used more times than owned is flagged (Board Game Arena shows the base art for the + * missing copies). In the « Global » alt-art mode, the deck follows the defaults instead: they are written in it when + * it opens and after each change, and there is no brush. Needs the ownership service and a signed-in player; nothing + * happens without. */ @Service({ autoProvided: false }) export class EditorAltArts { private readonly deck = inject(DeckStore); private readonly ownership = inject(OwnershipApiService); - readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); + private readonly auth = inject(AuthSession); + private readonly deckAltArts = inject(DeckAltArtsApiService); + readonly enabled = computed(() => !!this.ownership.baseUrl && this.auth.isLoggedIn()); + /** Alt-art mode: `true` in « Global », `undefined` until the ownership service has answered (or without it). */ + private readonly mode = toSignal(toObservable(this.enabled).pipe(switchMap((on) => (on ? this.ownership.globalAltArts() : of(undefined))))); + + /** Family of each reference asked so far (`null`: one illustration only); the prints of a family share it. */ + private readonly known = signal>(new Map()); + private readonly asked = new Set(); + /** The brush's choices for this deck (1st, 2nd, 3rd card), by family key. */ + private readonly deckCards = signal>(new Map()); + /** Changes waiting for the family of their reference (a card added before its family arrived). */ + private readonly waiting = new Map void)[]>(); - /** References of the deck (hero included), sorted: the families are fetched again only when they change. */ - private readonly refs = computed( + /** References of the deck (hero included). */ + private readonly deckRefs = computed(() => { + const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); + const hero = this.deck.hero()?.reference; + return hero ? [...refs, hero] : refs; + }); + /** Families of the deck's references, by reference: unchanged while the deck's are (search results add others). */ + readonly choices = computed>( () => { - const refs = this.deck.lines().filter((l) => l.quantity > 0).map((l) => l.card.reference); - const hero = this.deck.hero()?.reference; - return [...new Set(hero ? [...refs, hero] : refs)].sort(); + const known = this.known(); + const out: Record = {}; + for (const ref of this.deckRefs()) { + const choice = known.get(ref); + if (choice) out[ref] = choice; + } + return out; + }, + { + equal: (a, b) => { + const keys = Object.keys(a); + return keys.length === Object.keys(b).length && keys.every((k) => a[k] === b[k]); + }, }, - { equal: (a, b) => a.join() === b.join() }, ); - private readonly choicesRes = rxResource({ - params: () => (this.ownership.baseUrl && this.deck.editable() ? this.refs() : undefined), - stream: ({ params }) => (params.length ? this.ownership.altArtChoices(params) : of({} as Record)), - }); - readonly choices = computed>(() => (this.choicesRes.hasValue() ? this.choicesRes.value() : {})); + /** Every reference of the deck has been asked to the service. */ + readonly ready = computed(() => this.deckRefs().every((r) => this.known().has(r))); + private readonly ready$ = toObservable(this.ready).pipe(filter(Boolean), take(1)); - /** Per-deck mode: why a line's print is short (owned copies under the copies of the deck), by reference. */ + /** Why a line's print is short (owned copies under the copies of the deck), by reference. */ readonly warnings = computed(() => { const out = new Map(); - if (this.global()) return out; const choices = this.choices(); for (const l of this.deck.lines()) { const owned = choices[l.card.reference]?.options.options.find((o) => o.reference === l.card.reference)?.ownedQuantity; @@ -44,28 +83,283 @@ export class EditorAltArts { return out; }); + /** The deck board (« Aperçu »): the prints of a card on one pile, copy 1 in front. Remembered in this browser. */ + readonly stackPrints = storedFlag('rebuilder.board.stackPrints', true); + + /** « Arts des jetons »: on an editable deck. */ + readonly canChoose = computed(() => this.enabled() && this.deck.editable()); + /** « Arts par défaut » and the brush: on an editable deck, in the « par deck » mode. */ + readonly canChooseForDeck = computed(() => this.canChoose() && this.mode() === false); + /** The deck's id when the player's choices for it are kept on the site: a saved deck of theirs; `null` otherwise. */ + private readonly ownDeckId = computed(() => { + const id = this.deck.deckId(); + return id && this.enabled() && !this.deck.isGuest() && this.deck.editable() === true ? id : null; + }); + constructor() { - // Global mode: the deck takes the preferred prints once the families are known (and after each change). effect(() => { - const choices = this.choices(); - if (!this.global() || !Object.keys(choices).length) return; - const slots = new Map( - Object.entries(choices).map(([ref, c]) => [ref, { key: familyKey(c.family), slots: [...c.options.slots].sort((a, b) => a.slotIndex - b.slotIndex).map((s) => s.reference) }]), - ); - untracked(() => this.deck.applyAltArtSlots(slots)); + const refs = this.deckRefs(); + if (this.enabled() && this.deck.editable()) untracked(() => this.request(refs)); }); - // Global mode: a preference saved meanwhile (in the card zoom) is fetched again, for the deck to follow it. - const seen = this.ownership.altArtVersion(); + // The brush's choices for the deck. A new deck gets an id on its first save: the choices made before are sent then. + let cardsOf: string | null = null; effect(() => { - if (this.ownership.altArtVersion() !== seen && untracked(this.global)) untracked(() => this.choicesRes.reload()); + const id = this.ownDeckId(); + if (!id || id === cardsOf) return; + const previous = cardsOf; + cardsOf = id; + untracked(() => { + if (previous === null && this.deckCards().size) { + for (const [key, cards] of this.deckCards()) this.deckAltArts.save(id, key, cards).subscribe({ error: () => undefined }); + return; + } + this.deckCards.set(new Map()); + this.deckAltArts.load(id).subscribe((families) => { + if (this.deck.deckId() === id) this.deckCards.set(new Map(Object.entries(families))); + }); + }); + }); + // « Global » mode: the deck takes the default alt arts once its families are known, and again after each change. + effect(() => { + if (this.mode() !== true || !this.canChoose() || !this.ready()) return; + const choices = this.choices(); + this.deck.lines(); + untracked(() => this.deck.applyAltArtDefaults(choices)); }); } - /** « Choisir les arts des jetons »: per-deck mode with the ownership service, on an editable deck. */ - readonly canChooseTokens = computed(() => !!this.ownership.baseUrl && !this.global() && this.deck.editable()); + /** Asks the families of `refs` not asked yet (search results, deck lines). */ + request(refs: readonly string[]): void { + if (!this.enabled()) return; + const missing = [...new Set(refs)].filter((r) => !this.asked.has(r)); + for (let i = 0; i < missing.length; i += CHUNK) { + const chunk = missing.slice(i, i + CHUNK); + chunk.forEach((r) => this.asked.add(r)); + this.ownership.altArtChoices(chunk).subscribe((found) => { + this.known.update((m) => { + const next = new Map(m); + for (const ref of chunk) next.set(ref, found[ref] ?? next.get(ref) ?? null); + // Every print of a family is known with it (a print added by the brush or the search). + for (const choice of Object.values(found)) { + for (const o of choice.options.options) if (!next.get(o.reference)) next.set(o.reference, choice); + } + return next; + }); + for (const ref of chunk) { + const run = this.waiting.get(ref); + this.waiting.delete(ref); + run?.forEach((f) => f()); + } + }); + } + } - /** Per-deck mode: the prints a card of the deck can take (`null`: one illustration only, or Global mode). */ + /** + * « Arts des jetons » (`canChoose`): `null` while the deck's owner is not known yet, so that its place is kept (in + * skeleton) and the list under it does not move when it comes. + */ + readonly tokensChoice = computed(() => { + if (!this.enabled() || this.deck.owned() === false) return false; + return this.deck.owned() === null ? null : true; + }); + /** « Arts par défaut » (`canChooseForDeck`): `null` while the deck's owner or the alt-art mode is not known yet. */ + readonly defaultsChoice = computed(() => { + if (this.tokensChoice() === false || this.mode() === true) return false; + return this.tokensChoice() === null || this.mode() === undefined ? null : true; + }); + + /** The family of `reference` (`null`: one illustration, or not known yet). */ choiceFor(reference: string): AltArtChoice | null { - return this.global() ? null : this.choices()[reference] ?? null; + return this.known().get(reference) ?? null; + } + + /** References of the deck's lines in the family of `choice`. */ + members(choice: AltArtChoice): Set { + const key = familyKey(choice.family); + const known = this.known(); + return new Set( + this.deck + .lines() + .map((l) => l.card.reference) + .filter((r) => { + const c = known.get(r); + return !!c && familyKey(c.family) === key; + }), + ); + } + + /** + * Copies `card` can have: a print shares its family's limit with the other prints in the deck (never under the copies + * it has); a family card (`asFamily`, its plain print) has the whole limit. + */ + maxFor(card: Card, asFamily: boolean): number { + const max = this.deck.maxFor(card); + const choice = this.choiceFor(card.reference); + if (!choice || (asFamily && this.isFamilyCard(card))) return max; + const others = familyPrints(this.deck.lines(), this.members(choice)).filter((r) => r !== card.reference).length; + return Math.max(this.deck.quantityOf(card.reference), max - others); + } + + /** Copies of `card`'s family in the deck, or of `card` alone. */ + familyQuantity(card: Card): number { + const choice = this.choiceFor(card.reference); + if (!choice) return this.deck.quantityOf(card.reference); + return familyPrints(this.deck.lines(), this.members(choice)).length; + } + + /** + * The copies of `card`'s family in the deck, one print a copy, in the brush's order (copy 1 first: the copies on + * their default alt art on their slot); `null` when its family is not known. + */ + copyPrints(card: Card): string[] | null { + const choice = this.choiceFor(card.reference); + if (!choice) return null; + return familyCopies(this.deckChoice(choice), familyPrints(this.deck.lines(), this.members(choice))); + } + + /** + * The illustration of the 1st, 2nd and 3rd card of `choice`'s family in this deck: the brush's choices while the + * deck's copies match them, else its copies (in the brush's order) and the default alt arts for the cards past them. + */ + cardsFor(choice: AltArtChoice): string[] { + const prints = familyPrints(this.deck.lines(), this.members(choice)); + const saved = this.deckCards().get(familyKey(choice.family)); + if (saved && sameCopies(prints, defaultPrints(withRanks(choice, saved), prints.length))) return [...saved]; + const copies = familyCopies(choice, prints); + return rankPrints(choice).map((rank, i) => copies[i] ?? rank); + } + + /** `choice` with this deck's cards as its slots: what an added copy of the family takes, and the copies' order. */ + private deckChoice(choice: AltArtChoice): AltArtChoice { + return withRanks(choice, this.cardsFor(choice)); + } + + /** Copies of `card`'s whole family (a pile of its prints): an added copy takes the illustration of its card. */ + setFamilyQuantity(card: Card, quantity: number): void { + const choice = this.choiceFor(card.reference); + if (choice) this.deck.setFamilyQuantity(card, this.deckChoice(choice), this.members(choice), quantity); + else this.deck.setQuantity(card, quantity); + } + + /** `card` stands for its whole family: the plain print of a multi-art card. */ + isFamilyCard(card: Card): boolean { + const choice = this.choiceFor(card.reference); + return !!choice && basePrint(choice) === card.reference; + } + + /** Copies of `card` from the search: by the defaults for a family card (`setFamilyQuantity`), that print otherwise. */ + setQuantity(card: Card, quantity: number): void { + // Asked but not answered yet: the change waits for the family, so that the copy takes its default alt art. + if (this.asked.has(card.reference) && !this.known().has(card.reference)) { + this.waiting.set(card.reference, [...(this.waiting.get(card.reference) ?? []), () => this.setQuantity(card, quantity)]); + return; + } + if (this.isFamilyCard(card)) this.setFamilyQuantity(card, quantity); + else this.deck.setQuantity(card, quantity); + } + + /** `card` has a brush: several illustrations, in an editable deck, in the « par deck » mode. */ + canIllustrate(card: Card): boolean { + return this.canChooseForDeck() && (this.choiceFor(card.reference)?.options.options.length ?? 0) > 1; + } + + /** The brush of `card` (`canIllustrate`), `null` otherwise. */ + pickerFor(card: Card): AltArtPickerData | null { + const choice = this.choiceFor(card.reference); + if (!choice || !this.canIllustrate(card)) return null; + return { + choice: () => this.choiceFor(card.reference) ?? choice, + prints: () => familyPrints(this.deck.lines(), this.members(choice)), + cards: () => this.cardsFor(this.choiceFor(card.reference) ?? choice), + copies: () => this.copyPrints(card) ?? [], + setCards: (cards) => this.setCards(card, cards), + reset: () => this.resetCards(card), + }; + } + + /** + * The 1st, 2nd and 3rd card of `card`'s family take `cards` in this deck: its copies take them at once, then the choice + * is saved for the deck (the default alt arts do not change; three plain prints are no choice to keep); on an error, + * the previous choice and copies come back. + */ + setCards(card: Card, cards: readonly string[]): Observable { + const choice = this.choiceFor(card.reference); + if (!choice) return EMPTY; + const key = familyKey(choice.family); + const members = this.members(choice); + const before = this.deckCards().get(key); + const beforePrints = familyPrints(this.deck.lines(), members); + this.keepCards(key, [...cards]); + this.deck.setFamilyPrints(members, card, defaultPrints(withRanks(choice, cards), beforePrints.length)); + const id = this.ownDeckId(); + if (!id) return of(undefined); + const base = basePrint(choice); + return this.deckAltArts.save(id, key, cards.every((c) => c === base) ? null : cards).pipe( + catchError((err: unknown) => { + this.keepCards(key, before); + this.deck.setFamilyPrints(this.members(choice), card, beforePrints); + return throwError(() => err); + }), + ); + } + + /** « Arts par défaut » in the brush: `card`'s family follows the default alt arts again in this deck. */ + resetCards(card: Card): void { + const choice = this.choiceFor(card.reference); + if (!choice) return; + const key = familyKey(choice.family); + const members = this.members(choice); + this.deck.setFamilyPrints(members, card, defaultPrints(choice, familyPrints(this.deck.lines(), members).length)); + if (!this.deckCards().has(key)) return; + this.keepCards(key, undefined); + const id = this.ownDeckId(); + if (id) this.deckAltArts.save(id, key, null).subscribe({ error: () => undefined }); + } + + /** The brush's choices for family `key` in this deck (`undefined`: none, it follows the default alt arts). */ + private keepCards(key: string, cards: readonly string[] | undefined): void { + this.deckCards.update((m) => { + const next = new Map(m); + if (cards) next.set(key, cards); + else next.delete(key); + return next; + }); + } + + /** The whole deck follows the default alt arts again: the brush's choices for it are dropped. */ + private clearCards(): void { + this.deckCards.set(new Map()); + const id = this.ownDeckId(); + if (id) this.deckAltArts.clear(id).subscribe(); + } + + /** « Appliquer les arts par défaut », once the deck's families are known: `false` when the deck already has them. */ + applyDefaults(): Observable { + return this.ready$.pipe( + map(() => this.deck.applyAltArtDefaults(this.choices())), + tap(() => this.clearCards()), + ); } } + +/** + * « Arts par défaut » (deck bar, « Mon deck »): after a confirmation, the deck takes the player's default alt arts. + * Emits the message to show, `null` when cancelled. + */ +export function confirmAltArtDefaults(overlay: AcOverlayService, altArts: EditorAltArts): Observable { + return openConfirm(overlay, { + title: $localize`:@@editor.altArtDefaults.title:Appliquer vos arts par défaut ?`, + message: $localize`:@@editor.altArtDefaults.message:Chaque carte du deck, et le héros, prend vos arts alternatifs par défaut. Les illustrations choisies pour ce deck sont remplacées.`, + confirmLabel: $localize`:@@editor.altArtDefaults.confirm:Appliquer`, + icon: 'wand-sparkles', + }).pipe( + switchMap((ok) => (ok ? altArts.applyDefaults() : of(null))), + map((applied) => { + if (applied === null) return null; + return applied + ? $localize`:@@editor.altArtDefaults.applied:Arts par défaut appliqués.` + : $localize`:@@editor.altArtDefaults.unchanged:Le deck a déjà vos arts par défaut.`; + }), + ); +} + diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html index dc9295a2..600ac7a2 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.html @@ -1,10 +1,64 @@ @if (bp.compact()) { - + @let deckTitle = deckAppBar(); + - @if (deck.editable()) { + + @if (appBarPending() && deckTitleView()) { + + } + @if (appBarPending()) { + + } + + @if (deckTitle) { + + } + @if (deckTitle) { + + } + + @if (deck.editable() || (deck.owned() === null && !deck.loadError())) { } - @if (!deck.loadError()) { + + @for (i of deck.owned() === null && !deck.loadError() ? (deckTitleView() ? [0] : [0, 1]) : []; track i) { + + } + + @if (deck.owned() !== null && !deck.loadError() && !deckTitle) { } - @if (!deck.loadError()) { - + + @if (deck.owned() !== null && !deck.loadError()) { + } } @@ -29,12 +84,35 @@ Retour à mes decks
    } @else { -
    -
    - @if (!bp.compact()) { -
    - -
    + @if (deck.opening()) { + {{ labels.loading }} + } +
    + @if (!bp.compact()) { +
    + +
    + + @if (deck.owned() === null) { + @if (effectiveView() === 'apercu') { + @for (i of [0, 1, 2, 3, 4, 5]; track i) { + + } + } @else { + + + + + } + } @else { - -
    -
    -
    -
    -
    - + + @if (deck.editable()) { - + } -
    + } @else { + @if (imageSource()) { + + } + + } + }
    + +
    + + + @if (effectiveView() === 'apercu' && deck.steppersShown()) { + Survolez une pile pour changer le nombre d’exemplaires. + } + + @if (deck.steppersShown()) { + + } + + @if (effectiveView() === 'apercu' && !bp.compact()) { + @if (altArts.canChoose()) { + + } @else if (altArts.tokensChoice() === null) { + + } + } + @if (altArts.canChoose()) { + + } @else if (altArts.tokensChoice() === null) { + + } + @if (altArts.canChooseForDeck()) { + + } @else if (altArts.defaultsChoice() === null) { + + }
    - } +
    + } +
    @if (deck.saveError(); as saveError) {
    @if (!bp.compact()) { - @if (deckDrawer.open()) { -
    - - + @let drawer = deckDrawer(); + @if (drawer.open()) { +
    + +
    } @else { - } } @@ -131,9 +264,32 @@ @if (bp.compact()) { } - @if (toast(); as t) { + @if (notice(); as n) { + {{ n }} + } @else if (toast(); as t) { {{ t.name }} ×{{ t.quantity }} } } + + + +
    + @if (deckAppBar()) { + +
    + } + @if (imageSource()) { + + +
    + } + + + @if (deck.editable()) { +
    + + } +
    +
    diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss index bc9e15a9..bf5c2433 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.scss @@ -2,10 +2,6 @@ display: block; } -ac-app-bar.static { - position: static; -} - .state { padding: var(--ac-space-10) var(--ac-page-padding); text-align: center; @@ -35,6 +31,12 @@ ac-app-bar.static { --app-search-bottom-inset: calc(var(--ac-bottom-nav-height) + env(safe-area-inset-bottom)); } +/* The app bar stays on screen with the hero and the rarities (phones, editable deck): the search head sticks below it. */ +.search.under-app-bar { + --app-search-top: var(--ac-app-bar-height); + --app-search-safe-top: 0px; +} + .banner { display: flex; align-items: center; @@ -66,7 +68,7 @@ ac-app-bar.static { white-space: pre-line; } -/* « Partager » / « Terminer » while the deck is being saved first. */ +/* « Partager » while the deck is being saved first. */ .waiting ::ng-deep .ac-icon { animation: ac-icon-spin 1s linear infinite; } @@ -77,79 +79,77 @@ ac-app-bar.static { } } -.done { - padding-inline: var(--ac-space-2-5); -} - /* ---------- medium and up ---------- */ @media (min-width: 768px) { + /* The deck bar and the modes row span the page; the main column and the deck panel share the row below. */ .editor { + flex-wrap: wrap; + row-gap: var(--ac-space-4); padding: var(--ac-space-6); align-items: flex-start; } - .main { + .top { + flex: 0 0 100%; + min-width: 0; + display: flex; + flex-direction: column; gap: var(--ac-space-4); } - .head-top { - display: flex; - align-items: center; - justify-content: space-between; + .main { + flex: 1 1 0; gap: var(--ac-space-4); - margin-bottom: calc(-1 * var(--ac-space-3)); } - .head-top .actions { + /* Deck bar actions: two columns of small buttons in « Aperçu », one row in the reduced bar. */ + .bar-actions { + display: grid; + grid-template-columns: repeat(2, minmax(var(--deck-bar-action-width), max-content)); + gap: var(--ac-space-1-5); + } + + .bar-actions.row { display: flex; align-items: center; - gap: var(--ac-space-2); - flex-shrink: 0; } - .head { + .bar-actions > button[acButton] { + padding: 0 var(--ac-space-2-5); + } + + /* Modes, the board's hint, the save state, the tokens' illustrations. */ + .modes { display: flex; - align-items: flex-end; - justify-content: space-between; - gap: var(--ac-space-6); + align-items: center; + flex-wrap: wrap; + gap: var(--ac-space-3) var(--ac-space-6); } - /* « Recherche » sits next to « Voir le deck ». The label must stay whole: - the segmented control ellipsizes spans once a button is allowed to shrink. */ - .head ac-segmented { + /* The mode labels stay whole: the segmented control ellipsizes spans once a button may shrink. */ + .modes ac-segmented { flex-shrink: 0; } - .head ac-segmented ::ng-deep button { + .modes ac-segmented ::ng-deep button { min-width: max-content; overflow: visible; } - .head ac-segmented ::ng-deep button span { + .modes ac-segmented ::ng-deep button span { overflow: visible; text-overflow: clip; } - .title-block { - display: flex; - flex-direction: column; - gap: var(--ac-space-1); - min-width: 0; - } - - .title-row { - display: flex; - align-items: center; - gap: var(--ac-space-3); - min-width: 0; + .hint { + flex: 1 1 12rem; + font: var(--ac-font-small); + color: var(--ac-color-text-muted); } - .title-row ac-editable-title { - min-width: 0; - } - - .title-row ac-save-status { - flex-shrink: 0; + /* No hint (« Recherche », « Main de départ »): only the space between the modes and the buttons. */ + .hint:empty { + flex-basis: 0; } /* The deck panel and its « Masquer » handle, on the panel's left edge. */ @@ -158,11 +158,15 @@ ac-app-bar.static { flex-shrink: 0; position: sticky; top: calc(var(--ac-sticky-top) + var(--ac-space-6)); - height: calc(100dvh - var(--ac-sticky-top) - 2 * var(--ac-space-6)); + /* As tall as its cards, the window's height at most (then its list scrolls). */ + display: flex; + flex-direction: column; + max-height: calc(100dvh - var(--ac-sticky-top) - 2 * var(--ac-space-6)); } .panel app-deck-panel { - height: 100%; + flex: 0 1 auto; + min-height: 0; } /* Deck hidden: the tab takes its place, against the right edge of the page. */ @@ -177,13 +181,29 @@ ac-app-bar.static { color: var(--ac-color-success); } + /* The count takes two digits' room, so the tab keeps its length from 0 to 99 cards. */ + .deck-tab .count { + display: inline-block; + min-inline-size: 2ch; + text-align: center; + font-variant-numeric: tabular-nums; + } + + /* While the deck opens: the label's text hidden in its skeleton, which keeps the label's length. */ + .deck-tab .ac-drawer-tab__label.ac-skeleton { + width: auto; + color: transparent; + border-radius: var(--ac-radius-pill); + } + /* The filters tab of the card search reaches the left edge of the page the same way. */ .search { --app-drawer-bleed: var(--ac-space-6); } } -@media (min-width: 1200px) { +/* 300 px up to 1440 px: the search results keep three columns of cards from 1332 px (1366 px laptops). */ +@media (min-width: 1440px) { .panel { width: var(--ac-deck-panel-width); } @@ -193,3 +213,88 @@ ac-app-bar.static { flex-direction: column; gap: var(--ac-space-6); } + +/* ---------- App bar of the search and « Aperçu » views (phones) ---------- */ +/* Projected into ac-app-bar: the hero opens « Choisir un héros », the title « Résumé du deck ». */ +.bar-hero { + position: relative; + flex-shrink: 0; + width: var(--ac-hit-min); + height: var(--ac-hit-min); + padding: 0; + border: 0; + background: none; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} +.bar-hero-art { + position: relative; + width: 36px; + height: 36px; + border-radius: var(--ac-radius-pill); + overflow: hidden; + background: var(--ac-color-track); + box-shadow: 0 0 0 2px var(--ac-color-surface), 0 0 0 3px var(--ac-color-border-control); +} +.bar-hero-swap { + position: absolute; + right: 0; + bottom: 0; + width: 18px; + height: 18px; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-surface); + border: 1px solid var(--ac-color-border); + color: var(--ac-color-primary-strong); + display: inline-flex; + align-items: center; + justify-content: center; +} +.bar-deck { + flex-grow: 1; + min-width: 0; + overflow: hidden; + min-height: var(--ac-hit-min); + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 3px; + padding: 0 var(--ac-space-1); + border: 0; + border-radius: var(--ac-radius-md); + background: none; + color: var(--ac-color-text); + text-align: left; + cursor: pointer; +} +.bar-deck-name { + max-width: 100%; + font-size: var(--ac-font-size-heading); + font-weight: 800; + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bar-deck-sub { + max-width: 100%; + display: flex; + align-items: center; + gap: var(--ac-space-1); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); + white-space: nowrap; +} +/* The hero and the deck title in skeleton (appBarPending): nothing to click yet. */ +.bar-hero:not(button), +.bar-deck--pending { + cursor: default; +} +.bar-hero:focus-visible, +.bar-deck:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: -2px; +} diff --git a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts index 1a40ffe9..12e18aaa 100644 --- a/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts +++ b/plugins/rebuilder/app/src/app/features/editor/editor-page/editor.page.ts @@ -1,5 +1,6 @@ import { Component, DestroyRef, ElementRef, Injector, afterNextRender, computed, effect, inject, signal, untracked } from '@angular/core'; import { LocationStrategy } from '@angular/common'; +import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { ActivatedRoute, Router, RouterLink } from '@angular/router'; import { map } from 'rxjs'; @@ -7,41 +8,53 @@ import { AuthSession } from '../../../core/auth-session'; import { DeckStore } from '../../../core/deck-store'; import { GuestDeckService } from '../../../core/guest-deck.service'; import { formatInfo } from '../../../core/formats'; -import { AcButton, AcIconButton } from '../../../ui/buttons'; -import { AcEditableTitle, AcSegmented } from '../../../ui/fields'; +import { rarityLimits } from '../../../core/deck-rules'; +import { AcButton, AcIconButton, MENU_POSITIONS } from '../../../ui/buttons'; +import { AcSegmented, AcSwitch } from '../../../ui/fields'; import { AcIcon } from '../../../ui/icon'; -import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcToast } from '../../../ui/containers'; +import { AcDrawerHandle, AcDrawerState, AcDrawerTab, AcSkeleton, AcToast } from '../../../ui/containers'; import { storedFlag } from '../../../core/stored-flag'; import { contentLocale } from '../../../core/locale'; import { localizedText, type Card } from '../../../core/models'; -import { AcSaveStatus } from '../../../ui/metier'; +import { AcCardArt, AcRarityLimits, AcSaveStatus, rarityLimitItems } from '../../../ui/metier'; import { AcBreakpointService } from '../../../ui/layout.services'; -import { AcAppBar, AcBackButton, AcBottomNav, AcBreadcrumb, type AcBottomNavItem } from '../../../ui/nav'; +import { AcAppBar, AcBackButton, AcBottomNav, type AcBottomNavItem } from '../../../ui/nav'; import { AcOverlayService } from '../../../ui/overlay'; import { openDeckSettings } from '../../shared/deck-settings/deck-settings.overlay'; -import { EditorAltArts } from '../editor-alt-arts'; +import { openHeroPicker } from '../../shared/hero-picker/hero-picker.overlay'; +import { openDeckOverview } from '../deck-overview/deck-overview.overlay'; +import { EditorAltArts, confirmAltArtDefaults } from '../editor-alt-arts'; import { CardSearchStore } from '../../search/card-search.store'; import { CardSearch } from '../../search/card-search/card-search'; import { DeckListView } from '../deck-list-view/deck-list-view'; import { DeckPanel } from '../deck-panel/deck-panel'; import { DeckPreview } from '../deck-preview/deck-preview'; +import { HandSkeleton } from '../../shared/hand-skeleton/hand-skeleton'; import { TestHand } from '../../deck/test-hand/test-hand'; import { HandStats } from '../../deck/hand-stats/hand-stats'; import { HandCalculators } from '../../deck/hand-calculators/hand-calculators'; -import { editorLegality } from '../editor-legality'; +import { editorLegality, openEditorLegality } from '../editor-legality'; +import { DeckBar } from '../../deck/deck-bar/deck-bar'; +import { DeckBoard } from '../../deck/deck-board/deck-board'; +import { DeckImageExport } from '../../deck/deck-image-export/deck-image-export'; +import { deckImageBusyMessage, runDeckImageAction, type DeckImageAction } from '../../deck/deck-image-actions'; +import { confirmDeleteDeck, copyDecklist, deckImageSourceFor } from '../../deck/deck-actions'; +import { openDuplicateDeck } from '../../deck/duplicate-deck/duplicate-deck.overlay'; +import { openTokenArts } from '../token-arts/token-arts.overlay'; import { openShareDeck } from '../../deck/share-deck/share-deck.overlay'; import { deckShareUrl } from '../../deck/deck-page/share-url'; import { openSignInToShare } from '../sign-in-to-share/sign-in-to-share.overlay'; import { rememberShareAfterSignIn, takeShareAfterSignIn } from '../share-after-sign-in'; export type EditorView = 'search' | 'apercu' | 'deck' | 'main'; -/** « Partager » and « Terminer »: both save the deck first, so the link and the deck page show the latest changes. */ -export type EditorAction = 'share' | 'done'; +/** « Partager » saves the deck first, so the link shows the latest changes. */ +export type EditorAction = 'share'; @Component({ selector: 'app-editor-page', providers: [CardSearchStore, EditorAltArts], imports: [ + AcSwitch, AcToast, AcDrawerHandle, AcDrawerTab, @@ -49,13 +62,19 @@ export type EditorAction = 'share' | 'done'; AcAppBar, AcBackButton, AcBottomNav, - AcBreadcrumb, - AcEditableTitle, + CdkMenu, + CdkMenuItem, + CdkMenuTrigger, + DeckBar, + DeckBoard, + DeckImageExport, AcSegmented, AcButton, AcIconButton, AcIcon, AcSaveStatus, + AcCardArt, + AcRarityLimits, CardSearch, DeckPanel, DeckPreview, @@ -63,6 +82,8 @@ export type EditorAction = 'share' | 'done'; HandStats, HandCalculators, DeckListView, + HandSkeleton, + AcSkeleton, ], host: { '(window:beforeunload)': 'beforeUnload($event)', @@ -78,9 +99,11 @@ export class EditorPage { private readonly overlay = inject(AcOverlayService); private readonly locationStrategy = inject(LocationStrategy); private readonly auth = inject(AuthSession); + private readonly guestDecks = inject(GuestDeckService); private readonly destroyRef = inject(DestroyRef); protected readonly bp = inject(AcBreakpointService); protected readonly deck = inject(DeckStore); + protected readonly altArts = inject(EditorAltArts); private readonly injector = inject(Injector); private readonly el = inject>(ElementRef).nativeElement; @@ -92,40 +115,85 @@ export class EditorPage { myDeck: $localize`:@@title.myDeck:Mon deck`, public: $localize`:@@editor.public:Public`, private: $localize`:@@editor.private:Privé`, - myDecks: $localize`:@@editor.myDecks:Mes decks`, - edit: $localize`:@@editor.edit:Modifier`, share: $localize`:@@editor.share:Partager`, saving: $localize`:@@editor.savingFirst:Enregistrement…`, saved: $localize`:@@editor.share.saved:Deck enregistré : le lien affiche la dernière version.`, savedToAccount: $localize`:@@editor.share.savedToAccount:Deck enregistré sur votre compte : vous pouvez le partager.`, + more: $localize`:@@editor.moreActions:Plus d’actions`, + imageBusy: deckImageBusyMessage(), + loading: $localize`:@@deck.page.loading:Chargement du deck…`, }; - /** « Mes decks / / Modifier »: the deck's name leads to its page too. */ - protected readonly breadcrumb = computed(() => [ - { label: this.labels.myDecks, route: '/decks' }, - { label: this.deck.name() || 'Deck', route: `/decks/${this.id()}` }, - { label: this.labels.edit }, - ]); + /** The « ⋯ » menu: placed as the split buttons' menus. */ + protected readonly menuPositions = MENU_POSITIONS; protected readonly modeOptions = [ { value: 'search' as const, label: $localize`:@@editor.search:Recherche`, icon: 'search' as const }, - { value: 'apercu' as const, label: $localize`:@@editor.viewDeck:Voir le deck`, icon: 'eye' as const }, + { value: 'apercu' as const, label: $localize`:@@editor.preview:Aperçu`, icon: 'eye' as const }, { value: 'main' as const, label: $localize`:@@editor.hand:Main de départ`, icon: 'hand' as const }, ]; protected readonly formatLabel = computed(() => formatInfo(this.deck.format()).label); /** From 768 px: the deck panel can be hidden (a tab on the page edge brings it back), remembered in this browser. */ protected readonly deckOpen = storedFlag('rebuilder.editor.deckOpen', true); - protected readonly legal = computed(() => editorLegality(this.deck).state === 'legal'); + protected readonly legality = computed(() => editorLegality(this.deck)); + protected readonly legal = computed(() => this.legality().state === 'legal'); + /** Mini curve of the reduced bar: the cost of the last card added. */ + protected readonly highlight = computed(() => { + const change = this.deck.lastChange(); + return change && change.delta > 0 ? Math.min(6, Math.max(0, (change.card.mainCost ?? 1) - 1)) : null; + }); + /** The deck's image (« Copier en image »), once the deck is loaded. */ + protected readonly imageSource = computed(() => { + const id = this.deck.deckId(); + return id && !this.deck.loading() ? deckImageSourceFor(id, this.guestDecks) : null; + }); + protected readonly imageBusy = signal(false); protected readonly deckTabLabel = computed(() => this.legal() ? $localize`:@@editor.showDeckLegal:Afficher le deck : ${this.deck.total()}:count: cartes, valide` : $localize`:@@editor.showDeckIssues:Afficher le deck : ${this.deck.total()}:count: cartes, à corriger`, ); /** Hides or shows the deck panel, with its motion; the focus moves to the control that undoes it. */ - protected readonly deckDrawer = new AcDrawerState(this.deckOpen, 'end', (open) => + private readonly drawerFocus = (open: boolean) => afterNextRender(() => this.el.querySelector(open ? '.panel > .ac-drawer-handle' : '.deck-tab')?.focus(), { injector: this.injector, - }), - ); + }); + private readonly searchDrawer = new AcDrawerState(this.deckOpen, 'end', this.drawerFocus); + /** « Aperçu » shows the whole deck already: the panel starts hidden there, behind its tab. */ + private readonly apercuDrawer = new AcDrawerState(signal(false), 'end', this.drawerFocus); + protected readonly deckDrawer = computed(() => (this.effectiveView() === 'apercu' ? this.apercuDrawer : this.searchDrawer)); protected readonly subtitle = computed(() => `${this.formatLabel()} · ${this.deck.isPublic() ? this.labels.public : this.labels.private}`); + /** + * Phones, search and « Aperçu » views of an editable deck: the app bar takes the hero (« Choisir un héros ») and, as its title, the deck + * name over the rarities against the format's caps (« Résumé du deck »), what the deck bar shows from 768 px. + */ + protected readonly deckAppBar = computed(() => this.deckTitleView() && this.deck.editable() && !this.deck.loadError()); + protected readonly rarityLimits = computed(() => rarityLimits(this.deck.status(), this.deck.format())); + protected readonly heroButtonLabel = computed(() => { + const name = this.deck.hero()?.name; + return name ? $localize`:@@editor.changeHeroOf:Changer de héros (${name}:name:)` : $localize`:@@ui.deckSummary.changeHero:Changer de héros`; + }); + /** Phones, search and « Aperçu » views: the app bar shows the deck title (`deckAppBar`) once the deck is the user's. */ + protected readonly deckTitleView = computed(() => this.bp.compact() && (this.view() === 'search' || this.view() === 'apercu')); + /** + * Phones: the app bar's title is not known yet (the deck opens; search and « Aperçu »: until its owner is known, which + * picks the deck title or the plain one). Its skeleton meanwhile, in the final layout, not a « Chargement du deck… » + * title then another layout. + */ + protected readonly appBarPending = computed( + () => !this.deck.loadError() && this.view() !== 'deck' && (this.deckTitleView() ? this.deck.owned() === null : this.deck.opening()), + ); + /** The button's name replaces its content: it carries the rarities too, as the badges' labels. */ + /** With the deck title (`deckAppBar`) or its skeleton, the app bar's own title and subtitle are left empty. */ + protected readonly appBarTitle = computed(() => { + if (this.deckAppBar() || this.appBarPending()) return ''; + return this.view() === 'deck' ? this.labels.myDeck : this.deck.name() || 'Deck'; + }); + protected readonly appBarSubtitle = computed(() => (this.deckAppBar() || this.appBarPending() || this.view() === 'deck' ? '' : this.subtitle())); + protected readonly overviewLabel = computed(() => + [ + $localize`:@@editor.overviewOf:Résumé du deck ${this.deck.name()}:name:`, + ...rarityLimitItems(this.rarityLimits()).map((r) => r.label), + ].join(', '), + ); protected readonly effectiveView = computed(() => (this.view() === 'deck' && !this.bp.compact() ? 'search' : this.view())); protected readonly base = computed(() => `/decks/${this.id()}/edit`); protected readonly navItems = computed(() => [ @@ -135,7 +203,7 @@ export class EditorPage { route: `${this.base()}/deck`, icon: 'layers', label: $localize`:@@editor.deck:Deck`, - badge: this.deck.total(), + badge: this.deck.opening() ? undefined : this.deck.total(), badgeTone: editorLegality(this.deck).state === 'legal' ? 'success' : 'dark', }, { route: `${this.base()}/main`, icon: 'hand', label: $localize`:@@editor.handShort:Main` }, @@ -150,6 +218,9 @@ export class EditorPage { protected readonly toast = signal<{ card: Card; name: string; quantity: number; delta: number } | null>(null); protected readonly undoLabel = $localize`:@@editor.toast.undo:Annuler`; private toastTimer?: ReturnType; + /** A message of the deck bar's actions (copied, image unavailable…), over the card toast. */ + protected readonly notice = signal(null); + private noticeTimer?: ReturnType; /** The change « Annuler » makes is not announced again. */ private undone: unknown = null; @@ -191,7 +262,7 @@ export class EditorPage { }); } - /** « Partager » / « Terminer »: saves the deck, then opens the share window or the deck page. */ + /** « Partager »: saves the deck, then opens the share window. */ protected act(action: EditorAction): void { // Before the deck is loaded, the share window would show the store's defaults (a public deck as private). if (this.waiting() || this.deck.loading()) return; @@ -208,8 +279,7 @@ export class EditorPage { this.stopped.set(action); return; } - if (action === 'done') void this.router.navigate(['/decks', this.id()]); - else this.openShare(this.labels.saved); + this.openShare(this.labels.saved); }); } @@ -259,6 +329,56 @@ export class EditorPage { void this.router.navigateByUrl(mode === 'apercu' || mode === 'main' ? `${this.base()}/${mode}` : this.base()); } + protected flash(message: string): void { + this.notice.set(message); + clearTimeout(this.noticeTimer); + this.noticeTimer = setTimeout(() => this.notice.set(null), 3200); + } + + protected showLegality(): void { + openEditorLegality(this.overlay, this.deck); + } + + protected chooseTokenArts(): void { + openTokenArts(this.overlay); + } + + protected applyAltArtDefaults(): void { + confirmAltArtDefaults(this.overlay, this.altArts).subscribe((message) => { + if (message) this.flash(message); + }); + } + + /** The deck's image: copied, saved or opened (reduced bar: the button and its « ⋯ » menu). */ + protected async image(action: DeckImageAction): Promise { + const source = this.imageSource(); + if (!source || this.imageBusy()) return; + this.imageBusy.set(true); + const message = await runDeckImageAction(action, source, this.deck.name()); + this.imageBusy.set(false); + if (message) this.flash(message); + } + + protected async copyList(): Promise { + this.flash(await copyDecklist(this.deck)); + } + + /** The copy is the user's: it opens in the editor. */ + protected duplicate(): void { + openDuplicateDeck(this.overlay).afterClosed.subscribe((id) => { + if (!id) return; + this.flash($localize`:@@deck.page.duplicated:Deck dupliqué.`); + void this.router.navigate(['/decks', id, 'edit', 'apercu']); + }); + } + + protected remove(): void { + confirmDeleteDeck(this.overlay, this.deck).subscribe((result) => { + if (result === true) void this.router.navigateByUrl('/decks'); + else if (result) this.flash(result); + }); + } + protected openSettings(): void { openDeckSettings(this.overlay, { name: this.deck.name(), @@ -272,6 +392,22 @@ export class EditorPage { }); } + /** The hero portrait's shortcut: « Choisir un héros » alone, the other settings stay behind the settings button. */ + protected changeHero(): void { + openHeroPicker(this.overlay, this.deck.hero()).afterClosed.subscribe((hero) => { + if (hero) this.deck.updateSettings({ hero }); + }); + } + + /** « Résumé du deck » (phones, from the app bar's title): it closes on the hero picker, the legality or the « Deck » view. */ + protected openOverview(): void { + openDeckOverview(this.overlay).afterClosed.subscribe((action) => { + if (action === 'hero') this.changeHero(); + else if (action === 'legality') this.showLegality(); + else if (action === 'deck') void this.router.navigateByUrl(`${this.base()}/deck`); + }); + } + /** * Tab closed, reloaded, or a site link followed: pending changes go out as a `keepalive` request * (it outlives the page); the browser asks for confirmation only when they may still be lost. @@ -294,6 +430,6 @@ export class EditorPage { protected duplicateToGuest(): void { const id = this.deck.duplicateToGuest(this.deck.name()); - void this.router.navigate(['/decks', id, 'edit']); + void this.router.navigate(['/decks', id, 'edit', 'apercu']); } } diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html index e3e93964..0bca8488 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.html @@ -1,12 +1,25 @@
    -

    Les préférences d’illustration des jetons sont communes à tous les decks : elles ne font pas partie de la liste de cartes de ce deck.

    - @if (families(); as list) { - @for (f of list; track f.family.familyId + f.family.faction + f.family.rarity) { -
    - @if (f.name) { -

    {{ f.name }}

    - } - +

    Choisissez une illustration par jeton : elle s’applique à tous ses exemplaires, dans tous vos decks.

    + @if (tokens(); as list) { + @for (t of list; track t.key) { +
    + +
    +

    {{ t.name }}

    +
    + @for (p of t.prints; track p.reference) { + + } +
    + {{ t.count }} + @if (error(); as e) { + @if (e.key === t.key) { + + } + } +
    } @empty {

    Aucun jeton n’a plus d’une illustration.

    diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss index 0bc3894d..eb00387e 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.scss @@ -8,8 +8,90 @@ font-size: var(--ac-font-size-small); color: var(--ac-color-text-muted); } +/* One token: its illustration large, then the illustrations to choose from. */ +.token { + display: flex; + gap: var(--ac-space-4); + padding: var(--ac-space-3); + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-card); + background: var(--ac-color-surface); +} +.preview { + flex-shrink: 0; + align-self: center; + width: 200px; + height: auto; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-control); + box-shadow: var(--ac-shadow-card); +} +.side { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: var(--ac-space-2); +} h3 { - margin: 0 0 var(--ac-space-2); + margin: 0; font-size: var(--ac-font-size-body); - font-weight: 700; + font-weight: 800; +} +.prints { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-2); +} +.print { + width: 84px; + padding: 0; + border: 3px solid transparent; + border-radius: var(--ac-radius-md); + background: var(--ac-color-track); + cursor: pointer; + transition: transform 0.12s; +} +.print img { + display: block; + width: 100%; + aspect-ratio: 5 / 7; + object-fit: cover; + border-radius: var(--ac-radius-sm); +} +.print.on { + border-color: var(--ac-color-primary); + transform: translateY(-4px); +} +.print:disabled { + cursor: not-allowed; +} +.print:disabled img { + opacity: 0.35; +} +.print:focus-visible { + outline: 2px solid var(--ac-color-focus); + outline-offset: 2px; +} +.error { + margin: 0; + font-size: var(--ac-font-size-caption); + color: var(--ac-color-danger); +} +@media (max-width: 767px) { + .token { + gap: var(--ac-space-3); + } + .preview { + width: 110px; + } + .print { + width: 64px; + } +} +@media (prefers-reduced-motion: reduce) { + .print { + transition: none; + } } diff --git a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts index 47f76285..f0b729f3 100644 --- a/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/editor/token-arts/token-arts.overlay.ts @@ -1,32 +1,87 @@ -import { Component, inject } from '@angular/core'; +import { HttpErrorResponse } from '@angular/common/http'; +import { Component, computed, inject, signal } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { catchError, of } from 'rxjs'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; +import { slotPrints } from '../../../core/alt-art-defaults'; +import { cardImageUrl } from '../../../core/card-art'; +import { OwnershipApiService, familyKey, type AltArtChoice } from '../../../core/ownership-api.service'; import { AcButton } from '../../../ui/buttons'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; -import { AltArtSlots } from '../../shared/alt-art-slots/alt-art-slots'; +import { printLabel, saveFailed } from '../alt-art-labels'; -/** « Illustrations des jetons »: the prints of the tokens, a preference shared by every deck (the site's window). */ +/** + * « Illustrations des jetons »: one illustration a token, shared by every deck (a token is not a deck card), saved as + * the player's preference on the ownership service at once. A large preview of the chosen one, then the others. + */ @Component({ selector: 'app-token-arts', - imports: [AltArtSlots, AcButton], + imports: [AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './token-arts.overlay.html', styleUrl: './token-arts.overlay.scss', }) export class TokenArtsOverlay { protected readonly ref = inject(AcOverlayRef); + private readonly ownership = inject(OwnershipApiService); /** `undefined` while loading, `[]` when no token has several illustrations (or on an error). */ - protected readonly families = toSignal( - inject(OwnershipApiService) - .tokenAltArts() - .pipe(catchError(() => of([]))), - ); + private readonly families = toSignal(this.ownership.tokenAltArts().pipe(catchError(() => of([])))); + /** Illustrations chosen here, by family key (before the service confirms). */ + private readonly chosen = signal>>({}); + /** A token whose choice was refused, with the reason. */ + protected readonly error = signal<{ key: string; message: string } | null>(null); + private readonly saving = new Set(); + + protected readonly tokens = computed(() => { + const list = this.families(); + if (!list) return undefined; + const chosen = this.chosen(); + return list.map((f, i) => { + const key = familyKey(f.family); + const current = chosen[key] ?? slotPrints(f)[0] ?? f.options.options[0]?.reference ?? ''; + return { + key, + family: f, + name: f.name || tokenLabel(i + 1), + current, + src: cardImageUrl(current), + count: availableLabel(f.options.options.length), + prints: f.options.options.map((o, j) => ({ + reference: o.reference, + src: cardImageUrl(o.reference), + on: o.reference === current, + unowned: o.ownedQuantity === 0, + label: o.ownedQuantity === 0 ? notOwnedLabel(j + 1) : printLabel(j + 1), + })), + }; + }); + }); + + protected pick(token: { key: string; family: AltArtChoice; current: string }, reference: string): void { + if (reference === token.current || this.saving.has(token.key)) return; + const before = this.chosen(); + this.chosen.set({ ...before, [token.key]: reference }); + this.error.set(null); + this.saving.add(token.key); + this.ownership.setAltArtPreference(token.family.family, [reference]).subscribe({ + next: () => this.saving.delete(token.key), + complete: () => this.saving.delete(token.key), + error: (err: unknown) => { + this.saving.delete(token.key); + this.chosen.set(before); + this.error.set({ key: token.key, message: err instanceof HttpErrorResponse && err.status === 409 ? notEnoughCopies() : saveFailed() }); + }, + }); + } } +const tokenLabel = (n: number) => $localize`:@@editor.tokenArts.token:Jeton ${n}:n:`; +const notOwnedLabel = (n: number) => $localize`:@@editor.tokenArts.notOwned:Illustration ${n}:n: (non possédée)`; +const availableLabel = (n: number) => $localize`:@@editor.tokenArts.available:${n}:n: illustrations disponibles`; +const notEnoughCopies = () => $localize`:@@altArt.notEnoughCopies:Vous n’avez pas assez d’exemplaires de cet art alternatif.`; + export function openTokenArts(overlay: AcOverlayService): AcOverlayRef { return overlay.open(TokenArtsOverlay, { title: $localize`:@@editor.tokenArts.title:Illustrations des jetons`, - width: 640, + width: 760, }); } diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html new file mode 100644 index 00000000..f2d891bc --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.html @@ -0,0 +1,4 @@ + + diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss new file mode 100644 index 00000000..3dc52e66 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.scss @@ -0,0 +1,4 @@ +.list { + flex: 1 1 auto; + padding: var(--ac-space-3) var(--ac-space-4) 0; +} diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts new file mode 100644 index 00000000..ecd098b6 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.spec.ts @@ -0,0 +1,49 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { AbilityRef } from '../../../core/card-filters'; +import { AcOverlayRef } from '../../../ui/overlay'; +import { AbilityPickerStep, type AbilityPickerData } from './ability-picker.overlay'; + +const OPTIONS: AbilityRef[] = [ + { id: 3, text: 'Ravitaillez' }, + { id: 4, text: 'Sabotez' }, + { id: 5, text: 'Piochez une carte.' }, +]; + +function setup(picked: AbilityRef[]) { + const data: AbilityPickerData = { + options: signal(OPTIONS), + picked: signal(picked), + label: 'effet', + searchPlaceholder: 'Rechercher un effet…', + }; + const ref = new AcOverlayRef(data); + TestBed.configureTestingModule({ imports: [AbilityPickerStep], providers: [{ provide: AcOverlayRef, useValue: ref }] }); + const fixture = TestBed.createComponent(AbilityPickerStep); + fixture.detectChanges(); + return { data, ref, el: fixture.nativeElement as HTMLElement, detect: () => fixture.detectChanges() }; +} + +describe('AbilityPickerStep', () => { + it('gives the sheet to the list, which stays open while values are checked', () => { + const { el, data, detect } = setup([]); + expect(el.querySelector('ac-or-values')).toBeNull(); + const boxes = () => [...el.querySelectorAll('ac-check-list input[type=checkbox]')]; + boxes()[1].click(); + detect(); + boxes()[0].click(); + detect(); + expect(boxes()).toHaveLength(3); + expect(data.picked().map((a) => a.id)).toEqual([4, 3]); + }); + + it('unchecks everything from the header, and « Valider » goes back', () => { + const { ref, data, el } = setup([OPTIONS[0], OPTIONS[1]]); + ref.headerAction()!.run(); + expect(data.picked()).toEqual([]); + let closed = false; + ref.dialogRef = { close: () => (closed = true) }; + el.querySelector('.ac-overlay-footer button')!.click(); + expect(closed).toBe(true); + }); +}); diff --git a/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts new file mode 100644 index 00000000..d1b4b69d --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/search/ability-picker/ability-picker.overlay.ts @@ -0,0 +1,38 @@ +import { Component, inject, type Signal, type WritableSignal } from '@angular/core'; +import type { AbilityRef } from '../../../core/card-filters'; +import { AcButton } from '../../../ui/buttons'; +import { AcCheckList } from '../../../ui/fields'; +import { AcOverlayRef } from '../../../ui/overlay'; + +export interface AbilityPickerData { + options: Signal; + /** The editor's own list: a check shows on its criterion at once, « Valider » only goes back. */ + picked: WritableSignal; + /** Accessible name of the list (« effet »). */ + label: string; + searchPlaceholder: string; +} + +/** + * ac-ability-picker — compact step of the effect editor for one criterion: the search and the checkbox + * list over the whole sheet, open while values are checked. The values show, joined by « ou », on the + * criterion once back. + */ +@Component({ + selector: 'ac-ability-picker', + imports: [AcCheckList, AcButton], + host: { class: 'ac-overlay-content' }, + templateUrl: './ability-picker.overlay.html', + styleUrl: './ability-picker.overlay.scss', +}) +export class AbilityPickerStep { + protected readonly ref = inject>(AcOverlayRef); + protected readonly data = this.ref.data; + + constructor() { + this.ref.headerAction.set({ + label: $localize`:@@search.effect.uncheckAll:Tout décocher`, + run: () => this.data.picked.set([]), + }); + } +} diff --git a/plugins/rebuilder/app/src/app/features/search/card-search.store.ts b/plugins/rebuilder/app/src/app/features/search/card-search.store.ts index 87bf57d6..da4b0665 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search.store.ts +++ b/plugins/rebuilder/app/src/app/features/search/card-search.store.ts @@ -5,6 +5,8 @@ import { CardsApiService } from '../../core/cards-api.service'; import { defaultFilters, filterChips, + isAccountSource, + legalFormat, removeChip, toSearchParams, toUniquesQuery, @@ -23,8 +25,6 @@ export const PAGE_SIZE: Record = { all: 36, uniques: 36, col export const SLOW_MS = 3000; /** Sources of the user's own cards (the site's endpoints): nothing to fetch for a guest. */ -const LOGIN_ONLY: readonly CardSource[] = ['collection', 'owned', 'favorites']; - interface SearchQuery { source: CardSource; faction: string | null; @@ -69,7 +69,7 @@ interface SearchState { const FIRST_PAGE: PageRequest = { page: 1, from: null }; function emptyState(source: CardSource, guest: boolean): SearchState { - return guest && LOGIN_ONLY.includes(source) + return guest && isAccountSource(source) ? { cards: [], total: 0, page: 0, next: null, timings: [] } : { cards: [], total: null, page: 0, next: null, timings: [] }; } @@ -91,6 +91,11 @@ export class CardSearchStore { readonly format = signal(null); readonly filters = signal(defaultFilters('all')); private readonly run = signal(0); + /** + * `configure()` has run: no request before, since the query depends on the context (the editor's deck: its hero's + * faction and its format, known once the deck is open). Meanwhile the search reads as loading. + */ + readonly configured = signal(false); private readonly wantMore = signal(false); private readonly query = computed(() => ({ @@ -106,7 +111,8 @@ export class CardSearchStore { private readonly pageRes = rxResource({ params: () => { const query = this.query(); - return LOGIN_ONLY.includes(query.source) && !this.auth.isLoggedIn() ? undefined : { query, request: this.requestedPage() }; + if (!this.configured()) return undefined; + return isAccountSource(query.source) && !this.auth.isLoggedIn() ? undefined : { query, request: this.requestedPage() }; }, stream: ({ params: { query, request } }) => { const started = performance.now(); @@ -135,7 +141,7 @@ export class CardSearchStore { readonly cards = computed(() => this.state().cards); readonly total = computed(() => this.state().total); readonly page = computed(() => this.state().page); - readonly loading = computed(() => this.pageRes.isLoading()); + readonly loading = computed(() => !this.configured() || this.pageRes.isLoading()); readonly error = computed(() => { const err = this.pageRes.error(); if (!err) return null; @@ -161,7 +167,7 @@ export class CardSearchStore { effect((onCleanup) => { this.slow.set(false); - if (!this.loading()) return; + if (!this.pageRes.isLoading()) return; const timer = setTimeout(() => this.slow.set(true), SLOW_MS); onCleanup(() => clearTimeout(timer)); }); @@ -173,6 +179,7 @@ export class CardSearchStore { this.faction.set(faction); this.format.set(format); if (changedSource) this.filters.set(defaultFilters(source)); + this.configured.set(true); this.restart(); } @@ -225,10 +232,10 @@ export class CardSearchStore { const size = PAGE_SIZE[query.source]; if (query.source === 'uniques') return this.uniquesApi.search(toUniquesQuery(query.filters, query.faction), request.from, size); const page = request.from ?? 1; - if (query.source === 'collection' || query.source === 'owned' || query.source === 'favorites') { + if (isAccountSource(query.source)) { const factions = query.faction ? (query.filters.otherFactions.length ? query.filters.otherFactions : [query.faction]) : query.filters.factions; - // Favoris « légales »: the cards the format forbids are dropped page by page (the total counts them until then). - const format = query.source === 'favorites' && query.filters.legalOnly ? query.format : null; + // Account tabs « légales »: the cards the format forbids are dropped page by page (the total counts them until then). + const format = legalFormat(query.source, query.filters, query.format); return this.owned.search(query.source, query.filters, factions, page, size, format).pipe( map((res): ResultPage => { const member = format ? res.member.filter((c) => allowedInFormat(c, format)) : res.member; diff --git a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html index 42014da3..9893d3fe 100644 --- a/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html +++ b/plugins/rebuilder/app/src/app/features/search/card-search/card-search.html @@ -1,16 +1,19 @@ @if (bp.compact()) {
    - + +
    + +
    +
    + } +
    -
    - Alors · effet - +} @else { +
    +
    + + @for (c of criteria; track c.kind) { +
    + + +
    + } +
    + @for (c of criteria; track c.kind) { + @if (active() === c.kind) { +
    +

    {{ c.label }} · {{ c.sub }}

    + + +
    + } + }
    - @if (loadError()) { - - } -
    +} + +

    Une carte correspond si elle a au moins une des valeurs de chaque critère rempli.

    +
    + + @if (loadError()) { + + } + diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss index 6c5e2332..72ba433b 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.scss @@ -1,8 +1,7 @@ -.ac-overlay-body { +.stack { display: flex; flex-direction: column; - gap: var(--ac-space-4); - min-height: 420px; + gap: var(--ac-space-5); } .intro { margin: 0; @@ -13,9 +12,10 @@ .crit { display: flex; flex-direction: column; - gap: var(--ac-space-1-5); + gap: var(--ac-space-2); } .label { + margin: 0; font-size: var(--ac-select-font-size); font-weight: 800; } @@ -23,6 +23,102 @@ font-weight: 600; color: var(--ac-color-text-muted); } +/* Window: fixed height, so neither column moves while values are checked. */ +.cols { + display: grid; + grid-template-columns: minmax(0, 320px) minmax(0, 1fr); + height: min(560px, calc(90dvh - 150px)); + padding: 0; + overflow: hidden; +} +.side { + display: flex; + flex-direction: column; + gap: var(--ac-space-2-5); + padding: var(--ac-space-4); + background: var(--ac-color-bg-app); + border-right: 1px solid var(--ac-color-border); + overflow-y: auto; + overscroll-behavior: contain; +} +.side .intro { + margin-bottom: var(--ac-space-1); +} +.card { + position: relative; + flex-shrink: 0; + cursor: pointer; + border: 1px solid var(--ac-color-border); + border-radius: var(--ac-radius-lg); + background: var(--ac-color-surface); +} +.card.on { + border-color: var(--ac-color-primary); + box-shadow: 0 0 0 1px var(--ac-color-primary); +} +.card-head { + display: flex; + align-items: center; + gap: var(--ac-space-2); + width: 100%; + min-height: var(--ac-control-md); + padding: 0 var(--ac-space-3); + border: none; + border-radius: var(--ac-radius-lg); + background: none; + color: var(--ac-color-text); + font: inherit; + text-align: left; + cursor: pointer; +} +/* The head button covers the whole card: a click on the values below opens the criterion too. */ +.card-head::after { + content: ''; + position: absolute; + inset: 0; + border-radius: inherit; +} +@media (hover: hover) { + .card:not(.on):hover { + border-color: var(--ac-color-border-control); + background: var(--ac-color-bg-subtle); + } +} +.card-head:focus-visible { + outline: none; +} +.card:has(.card-head:focus-visible) { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} +.card-head .label { + flex: 1 1 auto; +} +.card-head ac-icon { + color: var(--ac-color-text-muted); +} +.card.on .card-head ac-icon { + color: var(--ac-color-primary); +} +.card-values { + position: relative; + padding: 0 var(--ac-space-3) var(--ac-space-3); + /* Above the head's cover for its own buttons (remove a value) only. */ + pointer-events: none; +} +.card-values ::ng-deep :is(button, a, input) { + pointer-events: auto; +} +.pane { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); + min-height: 0; + padding: var(--ac-space-4) var(--ac-space-5) 0; +} +.pane-list { + flex: 1 1 auto; +} .error { color: var(--ac-color-danger); font-size: var(--ac-font-size-small); diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts index 28312f63..203b658a 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.spec.ts @@ -2,7 +2,9 @@ import { TestBed } from '@angular/core/testing'; import { of, throwError, type Observable } from 'rxjs'; import type { AbilityRef, EffectBlock } from '../../../core/card-filters'; import { NO_CONDITION, UniquesApiService, toAbilityRefs, type AbilityKind, type UniquesEffect, type UniquesQuery } from '../../../core/uniques-api.service'; -import { AcOverlayRef } from '../../../ui/overlay'; +import { AcBreakpointService } from '../../../ui/layout.services'; +import { AcOverlayRef, type AcStepConfig } from '../../../ui/overlay'; +import type { AbilityPickerData } from '../ability-picker/ability-picker.overlay'; import { EffectEditorOverlay, type EffectEditorData } from './effect-editor.overlay'; const MAIN: AbilityRef = { id: 1, text: 'Joué depuis la Main' }; @@ -10,30 +12,26 @@ const EXPEDITION: AbilityRef = { id: 2, text: 'Quitte la zone d’Expédition' } const RAVITAILLEZ: AbilityRef = { id: 3, text: 'Ravitaillez' }; const SABOTEZ: AbilityRef = { id: 4, text: 'Sabotez' }; -function sequence(combo: Element): string[] { - return [...combo.children] - .filter((el) => el.classList.contains('picked') || el.classList.contains('or') || el.classList.contains('field')) - .map((el) => { - if (el.classList.contains('or')) return 'ou'; - if (el.classList.contains('field')) return 'field'; - return `chip:${el.querySelector('span')?.textContent}`; - }); -} - /** The API rows the editor reads, through the real mapper. */ const ROWS: Record = { triggers: [ { alteredId: 22, text: { fr: '{H}' } }, { alteredId: 1, text: { fr: '{R}' } }, { alteredId: 24, text: { fr: '{J}' } }, - { alteredId: 17, text: { fr: 'Au Crépuscule\u00a0' } }, + { alteredId: 17, text: { fr: 'Au Crépuscule ' } }, ], conditions: [ { alteredId: 191, text: { fr: '[]' } }, - { alteredId: 188, text: { fr: 'Si vous contrôlez un jeton\u00a0:' } }, + { alteredId: 188, text: { fr: 'Si vous contrôlez un jeton :' } }, + ], + effects: [ + { alteredId: 3, text: { fr: 'Ravitaillez' } }, + { alteredId: 4, text: { fr: 'Sabotez' } }, + { alteredId: 5, text: { fr: 'Piochez une carte.' } }, ], - effects: [{ alteredId: 3, text: { fr: '[Ravitaillez].' } }], }; +const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); +const EMPTY: EffectBlock = { id: 'e1', triggers: [], conditions: [], effects: [] }; type Narrow = (query: UniquesQuery, index: number, block: UniquesEffect, kind: AbilityKind) => Observable>; @@ -41,6 +39,7 @@ function setup( effect: EffectBlock, abilities: (kind: AbilityKind) => Observable = () => of([]), narrow?: { query: UniquesQuery; index: number; narrowAbilities: Narrow }, + width = 1440, ) { const ref = new AcOverlayRef({ effect, index: narrow?.index ?? 0, query: narrow?.query }); TestBed.configureTestingModule({ @@ -50,93 +49,85 @@ function setup( { provide: UniquesApiService, useValue: { abilities, narrowAbilities: narrow?.narrowAbilities } }, ], }); + TestBed.inject(AcBreakpointService).setWidth(width); const fixture = TestBed.createComponent(EffectEditorOverlay); fixture.detectChanges(); - return { fixture, ref, el: fixture.nativeElement as HTMLElement }; + const el = fixture.nativeElement as HTMLElement; + const detect = () => fixture.detectChanges(); + return { fixture, ref, el, detect }; } -describe('EffectEditorOverlay « ou »', () => { - function render(effect: EffectBlock): HTMLElement { - return setup(effect).el; - } - - it('uses the same « ou » before the add field on trigger, condition, and effect', () => { - const el = render({ - id: 'e1', - triggers: [], - conditions: [MAIN], - effects: [RAVITAILLEZ, SABOTEZ], - }); - const combos = [...el.querySelectorAll('ac-combobox')]; - expect(combos).toHaveLength(3); - expect(sequence(combos[0])).toEqual(['field']); - expect(sequence(combos[1])).toEqual([`chip:${MAIN.text}`, 'ou', 'field']); - expect(sequence(combos[2])).toEqual([`chip:${RAVITAILLEZ.text}`, 'ou', `chip:${SABOTEZ.text}`, 'ou', 'field']); - // The add control now carries its label as text (« Ajouter » contains « ou »): count separators. - expect(combos[0].querySelectorAll('.or')).toHaveLength(0); - }); +const text = (node: Element) => node.textContent!.replace(/[-]/g, '').trim(); - it('keeps « ou » on every criterion that already has two values', () => { - const el = render({ - id: 'e1', - triggers: [MAIN, EXPEDITION], - conditions: [MAIN, EXPEDITION], - effects: [RAVITAILLEZ, SABOTEZ], - }); - for (const combo of el.querySelectorAll('ac-combobox')) { - const parts = sequence(combo); - expect(parts.at(-2)).toBe('ou'); - expect(parts.at(-1)).toBe('field'); - expect(parts.filter((p) => p === 'ou')).toHaveLength(2); - } - }); -}); +/** What a criterion card (or the compact criterion) shows: values and « ou », in order. */ +function sequence(values: Element): string[] { + return [...values.children].map((c) => (c.classList.contains('or') ? 'ou' : c.classList.contains('empty') ? `empty:${text(c)}` : `value:${text(c)}`)); +} -describe('EffectEditorOverlay pickers', () => { - const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); - const empty: EffectBlock = { id: 'e1', triggers: [], conditions: [], effects: [] }; - - /** Opens a criterion; its list is a CDK overlay, rendered in the overlay container. */ - function open(el: HTMLElement, name: string, fixture: { detectChanges(): void }) { - const toggle = el.querySelector(`[role=combobox][aria-label="${name}"]`)!; - toggle.click(); - fixture.detectChanges(); - const options = [...document.querySelectorAll('.cdk-overlay-container .panel [role=option]')]; - return { combo: toggle.closest('ac-combobox') as HTMLElement, toggle, options }; - } - - it('opens each criterion on a button, not on a focused text field', () => { - const { el } = setup(empty, api); - for (const combo of el.querySelectorAll('ac-combobox')) { - const first = combo.querySelector('.field')!.firstElementChild!; - expect(first.tagName).toBe('BUTTON'); - expect(first.getAttribute('role')).toBe('combobox'); - expect(combo.querySelector('input')).toBeNull(); - } +const cards = (el: HTMLElement) => [...el.querySelectorAll('.card')]; +const rows = (el: HTMLElement) => [...el.querySelectorAll('ac-check-list .option')]; +const listed = (el: HTMLElement) => rows(el).map(text); +function select(el: HTMLElement, label: string, detect: () => void) { + [...el.querySelectorAll('.card-head')].find((b) => b.textContent!.includes(label))!.click(); + detect(); +} +function check(el: HTMLElement, value: string, detect: () => void) { + rows(el) + .find((r) => text(r) === value)! + .querySelector('input')! + .click(); + detect(); +} + +describe('EffectEditorOverlay window', () => { + it('shows each criterion with its values joined by « ou », or what it matches when empty', () => { + const { el } = setup({ id: 'e1', triggers: [], conditions: [MAIN], effects: [RAVITAILLEZ, SABOTEZ] }); + expect(cards(el).map((c) => text(c.querySelector('.card-head')!))).toEqual(['Quand · déclencheur', 'Si · condition', 'Alors · effet']); + expect(cards(el).map((c) => sequence(c.querySelector('ac-or-values')!))).toEqual([ + ['empty:Tous les déclencheurs'], + [`value:${MAIN.text}`], + [`value:${RAVITAILLEZ.text}`, 'ou', `value:${SABOTEZ.text}`], + ]); }); - it('shows the hand, reserve and anywhere glyphs on the triggers', () => { - const { el, fixture } = setup(empty, api); - const { options } = open(el, 'Ajouter un déclencheur…', fixture); - const glyphs = Object.fromEntries( - options.map((o) => [o.textContent!.replace(/^[\ue000-\uf8ff]/, ''), o.querySelector('.glyph')?.textContent ?? null]), - ); + it('lists the checkboxes of the selected criterion, triggers first, with their glyphs', () => { + const { el, detect } = setup(EMPTY, api); + expect(el.querySelector('.card.on')?.textContent).toContain('Quand'); + const glyphs = Object.fromEntries(rows(el).map((r) => [text(r), r.querySelector('.glyph')?.textContent ?? null])); expect(glyphs).toEqual({ + 'Joué depuis la Main': '', + 'Joué depuis la Réserve': '', + 'Joué de partout': '', 'Au Crépuscule': null, - 'Joué de partout': '\ue026', - 'Joué depuis la Main': '\ue023', - 'Joué depuis la Réserve': '\ue024', }); + select(el, 'Alors', detect); + expect(listed(el)).toEqual(['Piochez une carte.', 'Ravitaillez', 'Sabotez']); + }); + + it('checks several values in a row: the list stays, rows keep their place, the card fills with « ou »', () => { + const { el, ref, detect } = setup(EMPTY, api); + select(el, 'Alors', detect); + const before = listed(el); + check(el, 'Sabotez', detect); + check(el, 'Ravitaillez', detect); + check(el, 'Piochez une carte.', detect); + expect(listed(el)).toEqual(before); + expect(rows(el).map((r) => r.querySelector('input')!.checked)).toEqual([true, true, true]); + expect(sequence(cards(el)[2].querySelector('ac-or-values')!)).toEqual(['value:Sabotez', 'ou', 'value:Ravitaillez', 'ou', 'value:Piochez une carte.']); + check(el, 'Ravitaillez', detect); + + let applied: EffectBlock | undefined; + ref.dialogRef = { close: (v) => (applied = v as EffectBlock) }; + el.querySelector('.ac-overlay-footer button:last-child')!.click(); + expect(applied?.effects.map((a) => a.text)).toEqual(['Sabotez', 'Piochez une carte.']); }); it('offers « Sans condition » first and applies it as the empty condition', () => { - const { el, fixture, ref } = setup(empty, api); - const { combo, options } = open(el, 'Ajouter une condition…', fixture); - const first = options[0]; - expect(first.textContent).toBe('Sans condition'); - first.click(); - fixture.detectChanges(); - expect(sequence(combo)).toEqual(['chip:Sans condition', 'ou', 'field']); + const { el, ref, detect } = setup(EMPTY, api); + select(el, 'Si', detect); + expect(listed(el)[0]).toBe('Sans condition'); + check(el, 'Sans condition', detect); + expect(sequence(cards(el)[1].querySelector('ac-or-values')!)).toEqual(['value:Sans condition']); let applied: EffectBlock | undefined; ref.dialogRef = { close: (v) => (applied = v as EffectBlock) }; @@ -144,51 +135,51 @@ describe('EffectEditorOverlay pickers', () => { expect(applied?.conditions).toEqual([{ id: 191, text: 'Sans condition' }]); }); - it('shows « ou » after the last value on Quand, Si and Alors alike, « Sans condition » included', () => { - const { el } = setup( - { - id: 'e1', - triggers: [{ id: 17, text: 'Au Crépuscule' }], - conditions: [{ id: 191, text: 'Sans condition' }], - effects: [{ id: 3, text: 'Ravitaillez' }], - }, - api, - ); - const combos = [...el.querySelectorAll('ac-combobox')]; - expect(combos.map(sequence)).toEqual([ - ['chip:Au Crépuscule', 'ou', 'field'], - ['chip:Sans condition', 'ou', 'field'], - ['chip:Ravitaillez', 'ou', 'field'], - ]); + it('removes a value from its card, and Enter in the search checks the first match', () => { + const { el, detect } = setup({ ...EMPTY, triggers: [MAIN, EXPEDITION] }, api); + cards(el)[0].querySelector('ac-or-values button')!.click(); + detect(); + expect(sequence(cards(el)[0].querySelector('ac-or-values')!)).toEqual([`value:${EXPEDITION.text}`]); + + const search = el.querySelector('ac-check-list input[type=search]')!; + search.value = 'crep'; + search.dispatchEvent(new Event('input')); + detect(); + expect(listed(el)).toEqual(['Au Crépuscule']); + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + detect(); + expect(search.value).toBe(''); + expect(listed(el)).toHaveLength(4); + expect(sequence(cards(el)[0].querySelector('ac-or-values')!)).toEqual([`value:${EXPEDITION.text}`, 'ou', 'value:Au Crépuscule']); }); +}); - it('picking a value leaves every picker closed: the next criterion does not open', () => { - const { el, fixture } = setup(empty, api); - const { toggle, options } = open(el, 'Ajouter un déclencheur…', fixture); - options[0].click(); - fixture.detectChanges(); - expect([...el.querySelectorAll('[role=combobox]')].map((t) => t.getAttribute('aria-expanded'))).toEqual(['false', 'false', 'false']); - expect(document.querySelectorAll('.cdk-overlay-container .panel')).toHaveLength(0); - expect(document.activeElement).toBe(toggle); +describe('EffectEditorOverlay compact', () => { + it('shows each criterion with « ou » and opens its checkbox list as a step sharing the picked values', () => { + const { el, ref, detect } = setup({ ...EMPTY, effects: [RAVITAILLEZ, SABOTEZ] }, api, undefined, 390); + expect(el.querySelector('.cols')).toBeNull(); + const crits = [...el.querySelectorAll('.crit')]; + expect(crits.map((c) => c.querySelector('.add')!.textContent!.trim())).toEqual(['Ajouter un déclencheur', 'Ajouter une condition', 'Ajouter un effet']); + expect(sequence(crits[2].querySelector('ac-or-values')!)).toEqual(['value:Ravitaillez', 'ou', 'value:Sabotez']); + + let step: AcStepConfig | undefined; + ref.stepOpener = (_c, config) => { + step = config as AcStepConfig; + return new AcOverlayRef(config.data); + }; + crits[2].querySelector('.add')!.click(); + expect(step?.title).toBe('Alors'); + expect(step?.subtitle).toBe('effet'); + expect(step?.data?.options().map((o) => o.text)).toEqual(['Piochez une carte.', 'Ravitaillez', 'Sabotez']); + step!.data!.picked.update((v) => [...v, { id: 5, text: 'Piochez une carte.' }]); + detect(); + expect(sequence(el.querySelectorAll('.crit')[2].querySelector('ac-or-values')!)).toEqual(['value:Ravitaillez', 'ou', 'value:Sabotez', 'ou', 'value:Piochez une carte.']); }); }); describe('EffectEditorOverlay narrowing', () => { - const api = (kind: AbilityKind) => of(toAbilityRefs(ROWS[kind], kind === 'conditions' ? NO_CONDITION : undefined)); const query: UniquesQuery = { factions: ['AX'], sets: [], mainCosts: [], recallCosts: [], effects: [] }; - function listed(el: HTMLElement, name: string, fixture: { detectChanges(): void }): string[] { - const toggle = el.querySelector(`[role=combobox][aria-label="${name}"]`)!; - toggle.click(); - fixture.detectChanges(); - const out = [...document.querySelectorAll('.cdk-overlay-container .panel [role=option]')].map((o) => - o.textContent!.replace(/^[\ue000-\uf8ff]/, ''), - ); - toggle.click(); - fixture.detectChanges(); - return out; - } - it('lists only the values the API says still give a card, with the block as picked', () => { const calls: { index: number; block: UniquesEffect; kind: AbilityKind }[] = []; const allowed: Record = { triggers: [24, 17], conditions: [188], effects: [] }; @@ -196,44 +187,43 @@ describe('EffectEditorOverlay narrowing', () => { calls.push({ index, block, kind }); return of(new Set(allowed[kind])); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [{ id: 3, text: 'Ravitaillez' }] }, api, { query, index: 1, narrowAbilities }); - expect(listed(el, 'Ajouter un déclencheur…', fixture)).toEqual(['Au Crépuscule', 'Joué de partout']); - expect(listed(el, 'Ajouter une condition…', fixture)).toEqual(['Si vous contrôlez un jeton']); - // A picked value stays as a chip even when the API no longer offers it. - expect(el.querySelectorAll('ac-combobox')[2].textContent).toContain('Ravitaillez'); + const { el, detect } = setup({ ...EMPTY, effects: [RAVITAILLEZ] }, api, { query, index: 1, narrowAbilities }); + expect(listed(el)).toEqual(['Au Crépuscule', 'Joué de partout']); + select(el, 'Si', detect); + expect(listed(el)).toEqual(['Si vous contrôlez un jeton']); + // A picked value stays on its card even when the API no longer offers it. + select(el, 'Alors', detect); + expect(listed(el)).toEqual([]); + expect(cards(el)[2].textContent).toContain('Ravitaillez'); expect(calls).toEqual( (['triggers', 'conditions', 'effects'] as AbilityKind[]).map((kind) => ({ index: 1, block: { triggers: [], conditions: [], effects: [3] }, kind })), ); }); - it('asks again when a pick changes, and keeps a whole list when the API fails for it', () => { - const blocks: UniquesEffect[] = []; + it('asks again for the other criteria when a value is checked, never for the list being checked', () => { + const asked: [AbilityKind, UniquesEffect][] = []; const narrowAbilities: Narrow = (_q, _i, block, kind) => { - if (kind === 'triggers') blocks.push(block); - return kind === 'conditions' ? throwError(() => new Error('down')) : of(new Set(kind === 'triggers' ? [24] : [])); + asked.push([kind, block]); + return kind === 'conditions' ? throwError(() => new Error('down')) : of(new Set(kind === 'triggers' ? [24, 22] : [3, 4, 5])); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [] }, api, { query, index: 0, narrowAbilities }); - expect(listed(el, 'Ajouter une condition…', fixture)).toEqual(['Sans condition', 'Si vous contrôlez un jeton']); - const toggle = el.querySelector('[role=combobox][aria-label="Ajouter un déclencheur…"]')!; - toggle.click(); - fixture.detectChanges(); - document.querySelector('.cdk-overlay-container .panel [role=option]')!.click(); - fixture.detectChanges(); - expect(blocks).toEqual([ - { triggers: [], conditions: [], effects: [] }, - { triggers: [24], conditions: [], effects: [] }, - ]); + const { el, detect } = setup(EMPTY, api, { query, index: 0, narrowAbilities }); + const before = listed(el); + asked.length = 0; + check(el, 'Joué de partout', detect); + check(el, 'Joué depuis la Main', detect); + expect(listed(el)).toEqual(before); + expect(asked.map(([kind]) => kind)).toEqual(['conditions', 'effects', 'conditions', 'effects']); + expect(asked.at(-1)![1]).toEqual({ triggers: [24, 22], conditions: [], effects: [] }); + // The API failed for the conditions: their whole list stays. + select(el, 'Si', detect); + expect(listed(el)).toEqual(['Sans condition', 'Si vous contrôlez un jeton']); }); it('shows every value without the search it belongs to, or for an exact reference', () => { const narrowAbilities: Narrow = () => { throw new Error('not called'); }; - const { el, fixture } = setup({ id: 'e1', triggers: [], conditions: [], effects: [] }, api, { - query: { ...query, reference: 'ALT_CORE_B_AX_04_U_1' }, - index: 0, - narrowAbilities, - }); - expect(listed(el, 'Ajouter un déclencheur…', fixture)).toHaveLength(4); + const { el } = setup(EMPTY, api, { query: { ...query, reference: 'ALT_CORE_B_AX_04_U_1' }, index: 0, narrowAbilities }); + expect(listed(el)).toHaveLength(4); }); }); diff --git a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts index 90b75feb..3dd8c364 100644 --- a/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/search/effect-editor/effect-editor.overlay.ts @@ -1,12 +1,15 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, inject, signal, type Signal, type WritableSignal } from '@angular/core'; import { toObservable, toSignal } from '@angular/core/rxjs-interop'; -import { catchError, concat, forkJoin, of, switchMap, type Observable } from 'rxjs'; +import { catchError, concat, of, switchMap } from 'rxjs'; import type { AbilityRef, EffectBlock } from '../../../core/card-filters'; import { UniquesApiService, type AbilityKind, type UniquesEffect, type UniquesQuery } from '../../../core/uniques-api.service'; import { AcButton } from '../../../ui/buttons'; -import { AcCombobox } from '../../../ui/fields'; +import { AcCheckList, AcOrValues } from '../../../ui/fields'; +import { AcIcon } from '../../../ui/icon'; import { AcBreakpointService } from '../../../ui/layout.services'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { AbilityPickerStep, type AbilityPickerData } from '../ability-picker/ability-picker.overlay'; export interface EffectEditorData { effect: EffectBlock; @@ -18,15 +21,59 @@ export interface EffectEditorData { query?: UniquesQuery; } -/** Ids each list is narrowed to; `null` keeps the whole list (not loaded yet, or the API failed). */ -type Narrowing = Record | null>; +const KINDS: AbilityKind[] = ['triggers', 'conditions', 'effects']; + +interface CriterionTexts { + /** « Quand », « Si », « Alors ». */ + label: string; + /** « déclencheur », « condition », « effet ». */ + sub: string; + /** Compact: button that opens the criterion's list. */ + add: string; + /** Nothing picked: the criterion does not filter. */ + any: string; + search: string; +} -const NO_NARROWING: Narrowing = { triggers: null, conditions: null, effects: null }; +const TEXTS: Record = { + triggers: { + label: $localize`:@@search.effect.whenLabel:Quand`, + sub: $localize`:@@search.effect.triggerSub:déclencheur`, + add: $localize`:@@search.effect.addTrigger:Ajouter un déclencheur`, + any: $localize`:@@search.effect.anyTrigger:Tous les déclencheurs`, + search: $localize`:@@search.effect.searchTrigger:Rechercher un déclencheur…`, + }, + conditions: { + label: $localize`:@@search.effect.ifLabel:Si`, + sub: $localize`:@@search.effect.conditionSub:condition`, + add: $localize`:@@search.effect.addCondition:Ajouter une condition`, + any: $localize`:@@search.effect.anyCondition:Toutes les conditions`, + search: $localize`:@@search.effect.searchCondition:Rechercher une condition…`, + }, + effects: { + label: $localize`:@@search.effect.thenLabel:Alors`, + sub: $localize`:@@search.effect.effectSub:effet`, + add: $localize`:@@search.effect.addEffect:Ajouter un effet`, + any: $localize`:@@search.effect.anyEffect:Tous les effets`, + search: $localize`:@@search.effect.searchEffect:Rechercher un effet…`, + }, +}; + +/** One criterion of the block: its texts, the values offered and the values picked. */ +interface Criterion extends CriterionTexts { + kind: AbilityKind; + options: Signal; + picked: WritableSignal; +} -/** ac-effect-editor — one combobox per criterion; values OR-ed, criteria AND-ed. */ +/** + * ac-effect-editor — values OR-ed inside a criterion, criteria AND-ed. Window: the three criteria on the + * left with their values, the checkbox list of the selected one on the right, so checking never moves + * the list. Compact: the criteria, each opening its checkbox list as a step (`ac-ability-picker`). + */ @Component({ selector: 'ac-effect-editor', - imports: [AcCombobox, AcButton], + imports: [AcCheckList, AcOrValues, AcButton, AcIcon, NgTemplateOutlet], host: { class: 'ac-overlay-content' }, templateUrl: './effect-editor.overlay.html', styleUrl: './effect-editor.overlay.scss', @@ -37,43 +84,37 @@ export class EffectEditorOverlay { protected readonly bp = inject(AcBreakpointService); protected readonly loadError = signal(false); - protected readonly triggers = this.load('triggers'); - protected readonly conditions = this.load('conditions'); - protected readonly effects = this.load('effects'); - - protected readonly pickedTriggers = signal(this.ref.data.effect.triggers); - protected readonly pickedConditions = signal(this.ref.data.effect.conditions); - protected readonly pickedEffects = signal(this.ref.data.effect.effects); - - private readonly narrowing = toSignal( - toObservable( - computed(() => ({ - triggers: this.pickedTriggers().map((a) => a.id), - conditions: this.pickedConditions().map((a) => a.id), - effects: this.pickedEffects().map((a) => a.id), - })), - ).pipe(switchMap((block) => this.narrow(block))), - { initialValue: NO_NARROWING }, - ); - - protected readonly triggerOptions = computed(() => narrowed(this.triggers(), this.narrowing().triggers)); - protected readonly conditionOptions = computed(() => narrowed(this.conditions(), this.narrowing().conditions)); - protected readonly effectOptions = computed(() => narrowed(this.effects(), this.narrowing().effects)); + private readonly picked: Record> = { + triggers: signal(this.ref.data.effect.triggers), + conditions: signal(this.ref.data.effect.conditions), + effects: signal(this.ref.data.effect.effects), + }; + + protected readonly criteria: Criterion[] = KINDS.map((kind) => ({ kind, ...TEXTS[kind], options: this.options(kind), picked: this.picked[kind] })); + + /** Window: the criterion whose list is shown. */ + protected readonly active = signal('triggers'); constructor() { this.ref.title.set(effectTitle(this.ref.data.index)); this.ref.headerAction.set({ label: $localize`:@@search.effect.clear:Effacer`, - run: () => { - this.pickedTriggers.set([]); - this.pickedConditions.set([]); - this.pickedEffects.set([]); - }, + run: () => KINDS.forEach((k) => this.picked[k].set([])), }); } - private load(kind: AbilityKind) { - return toSignal( + /** Compact: the criterion's checkbox list as a step of the same sheet. */ + protected openPicker(c: Criterion): void { + this.ref.openStep(AbilityPickerStep, { + title: c.label, + subtitle: c.sub, + data: { options: c.options, picked: c.picked, label: c.sub, searchPlaceholder: c.search }, + }); + } + + /** The values of `kind`, narrowed to those that still give a card. */ + private options(kind: AbilityKind): Signal { + const all = toSignal( this.api.abilities(kind).pipe( catchError(() => { this.loadError.set(true); @@ -82,32 +123,44 @@ export class EffectEditorOverlay { ), { initialValue: [] as AbilityRef[] }, ); + const ids = this.narrowing(kind); + return computed(() => { + const keep = ids(); + return keep ? all().filter((o) => keep.has(o.id)) : all(); + }); } /** - * The ids each list keeps with `block` as picked so far. The whole lists come back first, so a - * value that a change makes possible again is never missing while the answer is on its way. - * An exact reference looks one card up, whatever the other criteria: nothing to narrow. + * The ids `kind` keeps with the block as picked, `null` for the whole list. The server leaves the + * criterion being narrowed out, so only the two other criteria ask again: checking values in a list + * never reshapes that list. The whole list comes back first, so a value that a change makes possible + * again is never missing while the answer is on its way. An exact reference looks one card up, + * whatever the other criteria: nothing to narrow. */ - private narrow(block: UniquesEffect): Observable { + private narrowing(kind: AbilityKind): Signal | null> { const { query, index } = this.ref.data; - if (!query || query.reference) return of(NO_NARROWING); - const ids = (kind: AbilityKind) => this.api.narrowAbilities(query, index, block, kind).pipe(catchError(() => of(null))); - return concat(of(NO_NARROWING), forkJoin({ triggers: ids('triggers'), conditions: ids('conditions'), effects: ids('effects') })); + if (!query || query.reference) return signal(null); + const others = computed(() => KINDS.filter((k) => k !== kind).map((k) => this.picked[k]())); + const ids = (values: Record): UniquesEffect => ({ + triggers: values.triggers.map((a) => a.id), + conditions: values.conditions.map((a) => a.id), + effects: values.effects.map((a) => a.id), + }); + return toSignal( + toObservable(others).pipe( + switchMap(() => concat(of(null), this.api.narrowAbilities(query, index, ids(this.values()), kind).pipe(catchError(() => of(null))))), + ), + { initialValue: null }, + ); } - apply(): void { - this.ref.close({ - ...this.ref.data.effect, - triggers: this.pickedTriggers(), - conditions: this.pickedConditions(), - effects: this.pickedEffects(), - }); + private values(): Record { + return { triggers: this.picked.triggers(), conditions: this.picked.conditions(), effects: this.picked.effects() }; } -} -function narrowed(options: AbilityRef[], ids: Set | null): AbilityRef[] { - return ids ? options.filter((o) => ids.has(o.id)) : options; + apply(): void { + this.ref.close({ ...this.ref.data.effect, ...this.values() }); + } } /** « Effet n », 1-based. */ @@ -119,7 +172,7 @@ export function openEffectEditor(overlay: AcOverlayService, data: EffectEditorDa return overlay.open(EffectEditorOverlay, { title: effectTitle(data.index), data, - width: 520, + width: 880, sheetHeight: 'full', }); } diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html index ffa99a5f..41697f0b 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.html @@ -1,16 +1,22 @@ -@if (mode() === 'aside') { - +@if (limited()) { +

    Dans les favoris, seules la faction, l’extension et la rareté filtrent les cartes.

    } +
    + @if (mode() === 'aside') { + + } +
    + @if (factionFilter()) {
    @@ -21,23 +27,25 @@ } - -
    - - -
    -
    - - -@if (source() !== 'uniques') { - -
    - - - +
    + +
    + +
    -} + + + @if (source() !== 'uniques') { + +
    + + + +
    +
    + } +
    @if (source() === 'uniques' && mode() === 'aside') { @if (advancedOpen()) { -
    - - -
    +
    +
    + + +
    +
    @if (!factionFilter()) {
    @@ -122,21 +134,23 @@
    } - @if (value().altArts && promoOptions().length) { -
    - Éditions promo -
    - @for (p of promoOptions(); track p.code) { - - } +
    + @if (value().altArts && promoOptions().length) { +
    + Éditions promo +
    + @for (p of promoOptions(); track p.code) { + + } +
    + } +
    + Sous-type +
    - } -
    - Sous-type - -
    - + +
    }
    diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss index 23350e1e..d6fe8c5c 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.scss @@ -111,3 +111,20 @@ .three ::ng-deep .label { font-weight: 600; } + +/* Fields the source cannot filter by (Favoris): in the flow as if there were no fieldset, disabled and faded. */ +.unsupported { + display: contents; +} +.unsupported:disabled > *, +.unsupported:disabled .adv-field { + opacity: 0.45; +} +.limited { + margin: 0; + padding: var(--ac-space-2) var(--ac-space-3); + border-radius: var(--ac-radius-control); + background: var(--ac-color-bg-subtle); + font: var(--ac-font-small); + color: var(--ac-color-text-2); +} diff --git a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts index f136d54d..1e027659 100644 --- a/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts +++ b/plugins/rebuilder/app/src/app/features/search/filters-panel/filters-panel.ts @@ -1,5 +1,5 @@ import { Component, computed, input, model, output, signal } from '@angular/core'; -import { COST_RELATIONS, TYPE_OPTIONS, rarityOptionsFor, newEffectBlock, parseCostExpression, promoSetsOf, setsFor, type CardSource, type EffectBlock, type SearchFilters } from '../../../core/card-filters'; +import { COST_RELATIONS, TYPE_OPTIONS, isAccountSource, rarityOptionsFor, newEffectBlock, newEffectId, parseCostExpression, promoSetsOf, setsFor, type CardSource, type EffectBlock, type SearchFilters } from '../../../core/card-filters'; import { PROMO_SETS, SUBTYPES, termLabel } from '../../../core/card-vocabulary'; import { formatInfo } from '../../../core/formats'; import type { DeckFormat } from '../../../core/models'; @@ -22,7 +22,7 @@ import { AcEffectSummary, AcExtensionTile, FACTIONS } from '../../../ui/metier'; export class FiltersPanel { readonly source = input('all'); readonly mode = input<'aside' | 'sheet'>('aside'); - /** Editor: the deck's format, for the « légales » filter of Favoris. */ + /** Editor: the deck's format, for the « légales » filter of the account tabs. */ readonly format = input(null); /** Card browser only: the editor locks the faction to the hero's. */ readonly factionFilter = input(false); @@ -30,6 +30,7 @@ export class FiltersPanel { readonly editEffect = output(); protected readonly rarities = computed(() => rarityOptionsFor(this.source()).map((r) => ({ value: r.value, icon: r.icon, label: r.label, short: r.short }))); + protected readonly legalFilter = computed(() => isAccountSource(this.source())); protected readonly legalLabel = computed(() => $localize`:@@search.filters.legalOnly:Légales en ${formatInfo(this.format()).label}:format:`); protected readonly types = TYPE_OPTIONS; protected readonly factions = FACTIONS; @@ -42,6 +43,8 @@ export class FiltersPanel { all: $localize`:@@search.filters.all:Toutes`, }; protected readonly sets = computed(() => setsFor(this.source())); + /** Favoris: the site filters them by faction, set and rarity only; the other fields are disabled. */ + protected readonly limited = computed(() => this.source() === 'favorites'); protected readonly allSets = computed(() => this.sets().map((s) => s.reference)); protected readonly setsOpen = signal(false); protected readonly advancedOpen = signal(false); @@ -110,6 +113,13 @@ export class FiltersPanel { this.editEffect.emit(effects.length - 1); } + /** A copy of effect `i` right after it, with its own id. */ + duplicateEffect(i: number): void { + const effects = [...this.value().effects]; + effects.splice(i + 1, 0, { ...effects[i], id: newEffectId() }); + this.set({ effects }); + } + removeEffect(i: number): void { this.set({ effects: this.value().effects.filter((_, idx) => idx !== i) }); } diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html index 878586d9..85c1ca66 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.html @@ -1,4 +1,4 @@ -@if ((search.source() === 'collection' || search.source() === 'owned' || search.source() === 'favorites') && !auth.isLoggedIn()) { +@if (accountTab() && !auth.isLoggedIn()) {
    @if (search.source() === 'collection') { Collection physique @@ -14,7 +14,7 @@
    } @else { @if (layout() === 'grid') { - + @for (c of grid.slice(); track c.reference) { @@ -47,10 +47,10 @@ appearance="panel" [plain]="browse()" [card]="c" - [quantity]="deck.quantities().get(c.reference) ?? 0" - [max]="deck.maxFor(c)" + [quantity]="quantityOf(c)" + [max]="maxOf(c)" [blockedReason]="blockedReason(c)" - (quantityChange)="deck.setQuantity(c, $event)" + (quantityChange)="setQuantity(c, $event)" /> }
    diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss index f1e15192..b1e73e0b 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.scss @@ -20,20 +20,16 @@ } @media (min-width: 1200px) { .grid { - grid-template-columns: repeat(auto-fill, minmax(210px, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); column-gap: var(--ac-space-2); row-gap: var(--ac-space-5); } } -.skeleton { - aspect-ratio: 5 / 7; - border-radius: var(--ac-radius-lg); - background: var(--ac-color-track); - animation: pulse 1.2s ease-in-out infinite alternate; -} -@keyframes pulse { - to { - opacity: 0.55; +/* Uniques: their text is read, so at most 7 columns; the cards only grow where 7 would be narrower + than the usual 200 px (very wide windows). */ +@media (min-width: 1200px) { + .grid--readable { + grid-template-columns: repeat(auto-fill, minmax(max(200px, calc((100% - 6 * var(--ac-space-2)) / 7)), 1fr)); } } .list { diff --git a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts index 731315d7..92f137e4 100644 --- a/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts +++ b/plugins/rebuilder/app/src/app/features/search/search-results/search-results.ts @@ -1,21 +1,23 @@ -import { Component, computed, inject, input } from '@angular/core'; +import { Component, computed, effect, inject, input, untracked } from '@angular/core'; import { RouterLink } from '@angular/router'; +import { isAccountSource } from '../../../core/card-filters'; import { addBlockedReason } from '../../../core/deck-rules'; import { DeckStore } from '../../../core/deck-store'; import { AuthSession } from '../../../core/auth-session'; import type { Card } from '../../../core/models'; import { AcButton } from '../../../ui/buttons'; -import { AcVirtualGrid } from '../../../ui/containers'; +import { AcSkeleton, AcVirtualGrid } from '../../../ui/containers'; import { AcInfiniteSentinel } from '../../../ui/infinite'; import { AcCardTile, AcDeckRow } from '../../../ui/metier'; import { AcOverlayService } from '../../../ui/overlay'; import { FavoritesService } from '../../../core/favorites.service'; import { openCardZoom } from '../../shared/card-zoom/card-zoom.overlay'; import { CardSearchStore } from '../card-search.store'; +import { EditorAltArts } from '../../editor/editor-alt-arts'; @Component({ selector: 'app-search-results', - imports: [AcCardTile, AcDeckRow, AcInfiniteSentinel, AcButton, RouterLink, AcVirtualGrid], + imports: [AcCardTile, AcDeckRow, AcInfiniteSentinel, AcButton, RouterLink, AcVirtualGrid, AcSkeleton], templateUrl: './search-results.html', styleUrl: './search-results.scss', }) @@ -25,11 +27,37 @@ export class SearchResults { protected readonly auth = inject(AuthSession); private readonly overlay = inject(AcOverlayService); protected readonly favorites = inject(FavoritesService); + /** Editor: a card's plain print stands for all its prints, a copy added takes its default alt art. */ + private readonly altArts = inject(EditorAltArts, { optional: true }); readonly layout = input<'grid' | 'list'>('grid'); /** Card browser: cards without quantity controls. */ readonly browse = input(false); + protected readonly accountTab = computed(() => isAccountSource(this.search.source())); protected readonly skeletons = computed(() => Array.from({ length: this.search.cards().length ? 3 : 6 })); + constructor() { + // The families of the results: the copies a card counts and the print an added copy takes. + effect(() => { + const cards = this.search.cards(); + if (!this.browse() && this.altArts?.enabled()) untracked(() => this.altArts?.request(cards.map((c) => c.reference))); + }); + } + + /** Copies in the deck: the whole family for a plain print, that print otherwise. */ + protected quantityOf(card: Card): number { + if (this.altArts?.isFamilyCard(card)) return this.altArts.familyQuantity(card); + return this.deck.quantities().get(card.reference) ?? 0; + } + + protected maxOf(card: Card): number { + return this.altArts ? this.altArts.maxFor(card, true) : this.deck.maxFor(card); + } + + protected setQuantity(card: Card, quantity: number): void { + if (this.altArts) this.altArts.setQuantity(card, quantity); + else this.deck.setQuantity(card, quantity); + } + /** Why a card cannot be added in the deck's format (a Unique in a No Unique format). */ protected blockedReason(card: Card): string | null { return addBlockedReason(card, this.deck.format()); @@ -40,7 +68,7 @@ export class SearchResults { const editable = !this.browse() && this.deck.editable() && !this.blockedReason(card); openCardZoom(this.overlay, { card, - quantity: editable ? { value: this.deck.quantities().get(card.reference) ?? 0, max: this.deck.maxFor(card), change: (n) => this.deck.setQuantity(card, n) } : undefined, + quantity: editable ? { value: this.quantityOf(card), max: this.maxOf(card), change: (n) => this.setQuantity(card, n) } : undefined, }); } diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html deleted file mode 100644 index 0287d804..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.html +++ /dev/null @@ -1,19 +0,0 @@ -
      - @for (t of tiles(); track t.reference) { -
    • - - @if (t.markers.length) { - - @for (m of t.markers; track m.slotIndex) { - - } - - } -
    • - } -
    -@if (error(); as e) { - -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss deleted file mode 100644 index 956c78aa..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.scss +++ /dev/null @@ -1,68 +0,0 @@ -:host { - display: block; -} -.strip { - margin: 0; - padding: 0 0 var(--ac-space-1); - list-style: none; - display: flex; - gap: var(--ac-space-2); - overflow-x: auto; -} -li { - position: relative; - flex-shrink: 0; -} -.tile { - display: block; - width: 64px; - padding: 0; - border: 0; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.tile img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.tile.unowned { - opacity: 0.35; - cursor: not-allowed; -} -.tile:focus-visible, -.marker:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} -.markers { - position: absolute; - left: 50%; - bottom: var(--ac-space-1); - transform: translateX(-50%); - display: flex; - gap: 2px; -} -.marker { - width: 18px; - height: 18px; - padding: 0; - border: 1px solid var(--ac-color-on-strong); - border-radius: var(--ac-radius-pill); - background: var(--ac-color-overlay-control); - color: var(--ac-color-on-strong); - font-size: 10px; - font-weight: 700; - cursor: pointer; -} -.marker.active { - background: var(--ac-color-primary); -} -.error { - margin: var(--ac-space-2) 0 0; - font-size: var(--ac-font-size-caption); - color: var(--ac-color-danger); -} diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts deleted file mode 100644 index fe5b0bc4..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.spec.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { HttpErrorResponse } from '@angular/common/http'; -import { TestBed } from '@angular/core/testing'; -import { of, throwError } from 'rxjs'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from './alt-art-slots'; - -const CHOICE: AltArtChoice = { - family: { familyId: 7, faction: 'AX', rarity: 'COMMON' }, - options: { - options: [ - { reference: 'ALT_CORE_B_AX_04_C', ownedQuantity: null }, - { reference: 'ALT_CORE_A_AX_04_C', ownedQuantity: 2 }, - { reference: 'ALT_CORE_P_AX_04_C', ownedQuantity: 0 }, - ], - slots: [0, 1, 2].map((slotIndex) => ({ slotIndex, reference: 'ALT_CORE_B_AX_04_C' })), - }, -}; - -describe('AltArtSlots', () => { - function setup(result: 'ok' | 409) { - const saved: string[][] = []; - TestBed.configureTestingModule({ - providers: [ - { - provide: OwnershipApiService, - useValue: { - setAltArtPreference: (_f: unknown, refs: string[]) => { - saved.push(refs); - return result === 'ok' ? of(undefined) : throwError(() => new HttpErrorResponse({ status: 409, error: [{ reference: 'ALT_CORE_A_AX_04_C', requested: 3, owned: 2 }] })); - }, - }, - }, - ], - }); - const fixture = TestBed.createComponent(AltArtSlots); - fixture.componentRef.setInput('choice', CHOICE); - fixture.detectChanges(); - const el = fixture.nativeElement as HTMLElement; - return { fixture, el, saved }; - } - - it('moves the active copy onto an owned illustration and saves the slots', () => { - const { fixture, el, saved } = setup('ok'); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(saved).toEqual([['ALT_CORE_B_AX_04_C', 'ALT_CORE_B_AX_04_C', 'ALT_CORE_A_AX_04_C']]); - expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(1); - expect(el.querySelectorAll('.tile')[2].disabled).toBe(true); - }); - - it('puts the copy back with the reason when the site refuses', () => { - const { fixture, el } = setup(409); - el.querySelectorAll('.tile')[1].click(); - fixture.detectChanges(); - expect(el.querySelectorAll('li')[1].querySelectorAll('.marker')).toHaveLength(0); - expect(el.querySelector('.error')?.textContent).toContain('ALT_CORE_A_AX_04_C (3/2)'); - }); -}); diff --git a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts b/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts deleted file mode 100644 index 22fc2482..00000000 --- a/plugins/rebuilder/app/src/app/features/shared/alt-art-slots/alt-art-slots.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { Component, computed, inject, input, linkedSignal, signal } from '@angular/core'; -import { HttpErrorResponse } from '@angular/common/http'; -import { finalize } from 'rxjs'; -import { cardImageUrl } from '../../../core/card-art'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; - -/** - * The illustrations of a card family with the player's copies on them (one marker a copy), as the site's alt-art - * widget in the card lightbox: an owned illustration takes the active marker, the active marker then moves on; the - * site saves the choice and a refusal puts the marker back, with the reason. - */ -@Component({ - selector: 'app-alt-art-slots', - templateUrl: './alt-art-slots.html', - styleUrl: './alt-art-slots.scss', -}) -export class AltArtSlots { - private readonly ownership = inject(OwnershipApiService); - readonly choice = input.required(); - protected readonly slots = linkedSignal(() => [...this.choice().options.slots].sort((a, b) => a.slotIndex - b.slotIndex)); - protected readonly active = linkedSignal(() => this.slots().at(-1)?.slotIndex ?? 0); - protected readonly error = signal(null); - /** A save is running: the tiles wait for it, so that saves never cross (a late refusal would undo a later choice). */ - protected readonly saving = signal(false); - protected readonly tiles = computed(() => - this.choice().options.options.map((o) => ({ - reference: o.reference, - src: cardImageUrl(o.reference), - owned: o.ownedQuantity === null || o.ownedQuantity > 0, - markers: this.slots().filter((s) => s.reference === o.reference), - })), - ); - protected readonly tileLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - protected readonly markerLabel = (n: number) => $localize`:@@altArt.marker:Exemplaire ${n}:n:`; - - /** An owned illustration: the active marker (or the next one not already there) moves onto it. */ - protected pick(reference: string, owned: boolean): void { - if (!owned || this.saving()) return; - const slots = this.slots(); - const start = Math.max(0, slots.findIndex((s) => s.slotIndex === this.active())); - let moved: { slotIndex: number; reference: string } | undefined; - for (let i = 0; i < slots.length; i++) { - const candidate = slots[(start + i) % slots.length]; - if (candidate.reference !== reference) { - moved = candidate; - break; - } - } - if (!moved) return; - const before = slots; - const beforeActive = this.active(); - const next = slots.map((s) => (s.slotIndex === moved.slotIndex ? { ...s, reference } : s)); - const index = next.findIndex((s) => s.slotIndex === moved.slotIndex); - this.slots.set(next); - this.active.set(next[(index + 1) % next.length].slotIndex); - this.error.set(null); - this.saving.set(true); - this.ownership.setAltArtPreference(this.choice().family, next.map((s) => s.reference)).pipe(finalize(() => this.saving.set(false))).subscribe({ - error: (err: unknown) => { - this.slots.set(before); - this.active.set(beforeActive); - this.error.set(saveError(err)); - }, - }); - } - - protected selectMarker(slotIndex: number, event: Event): void { - event.stopPropagation(); - this.active.set(slotIndex); - } -} - -function saveError(err: unknown): string { - const head = $localize`:@@altArt.saveError:Impossible d’enregistrer votre choix.`; - if (err instanceof HttpErrorResponse && err.status === 409 && Array.isArray(err.error)) { - const detail = (err.error as { reference?: string; requested?: number; owned?: number }[]) - .map((s) => `${s.reference} (${s.requested}/${s.owned})`) - .join(', '); - return detail ? `${head} — ${detail}` : head; - } - return head; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html index b2baa434..24572340 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.html @@ -1,35 +1,20 @@
    -
    - +
    +
    - - @if (altArts() || data.quantity || (data.illustrations && prints().length > 1)) { + + @if (data.quantity || data.illustrations) {
    - @if (altArts(); as choice) { - - } @if (data.quantity; as q) {
    Dans le deck
    } - @if (data.illustrations && prints().length > 1) { - - @if (choosing()) { -
      - @for (p of prints(); track p.reference) { -
    • - -
    • - } -
    - - } + @if (data.illustrations) { + }
    } - Accéder au détail + Accéder au détail
    diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss index dbb92bb8..1dd7bcb9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.scss @@ -3,6 +3,12 @@ flex-direction: column; gap: var(--ac-space-3); padding: 0; + container-type: inline-size; +} +/* The card gives way to the controls and the link, which always show whole (phone browsers' bars eat the height). */ +.controls, +.detail { + flex-shrink: 0; } .controls { display: flex; @@ -13,14 +19,15 @@ background: var(--ac-color-surface); } .stage { - align-self: center; - width: 100%; - perspective: 900px; + flex: 0 1 calc(100cqw * 7 / 5); + /* Never smaller than 40 % of the screen: below that, the window scrolls. */ + min-height: min(40dvh, 100cqw * 7 / 5); + container-type: size; } .card { display: block; - transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)); - transition: transform 0.12s ease-out; + width: min(100cqw, 100cqh * 5 / 7); + margin-inline: auto; } .qty { display: flex; @@ -30,37 +37,3 @@ font-size: var(--ac-font-size-small); font-weight: 700; } -.prints { - margin: 0; - padding: 0; - list-style: none; - display: grid; - grid-template-columns: repeat(auto-fill, minmax(72px, 1fr)); - gap: var(--ac-space-2); -} -.print { - display: block; - width: 100%; - padding: 0; - border: 2px solid transparent; - border-radius: var(--ac-radius-md); - overflow: hidden; - background: var(--ac-color-track); - cursor: pointer; -} -.print img { - display: block; - width: 100%; - aspect-ratio: 5 / 7; - object-fit: cover; -} -.print.selected { - border-color: var(--ac-color-primary); -} -.print.unowned { - opacity: 0.5; -} -.print:focus-visible { - outline: 2px solid var(--ac-color-primary); - outline-offset: 2px; -} diff --git a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts index a807304b..d7fc749e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/card-zoom/card-zoom.overlay.ts @@ -1,80 +1,51 @@ import { Component, computed, inject, signal } from '@angular/core'; -import { toSignal } from '@angular/core/rxjs-interop'; -import { map, of, switchMap } from 'rxjs'; import { environment } from '../../../../environments/environment'; import { contentLocale } from '../../../core/locale'; -import { cardImageUrl } from '../../../core/card-art'; import type { Card } from '../../../core/models'; import { localizedText } from '../../../core/models'; import { AcButton, AcStepper } from '../../../ui/buttons'; import { AcCardTile } from '../../../ui/metier'; -import { OwnershipApiService, type AltArtChoice } from '../../../core/ownership-api.service'; -import { AltArtSlots } from '../alt-art-slots/alt-art-slots'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; +import { openAltArtPicker, type AltArtPickerData } from '../../editor/alt-art-picker/alt-art-picker.overlay'; export interface CardZoomData { card: Card; /** Editor: copies in the deck, changed from the window (as the site's deck builder lightbox). */ quantity?: { value: number; max: number; change: (quantity: number) => void }; - /** Editor, per-deck alt-art mode: « Choisir une illustration » for a card of the deck (the site's lightbox). */ - illustrations?: { choice: AltArtChoice; pick: (reference: string) => void }; + /** Editor, a card with several illustrations: « Choisir les illustrations » opens its brush in place of this window. */ + illustrations?: AltArtPickerData; } /** A card shown large, with a link to its sheet on the site (the site's card lightbox); in the editor, its copies. */ @Component({ selector: 'app-card-zoom', - imports: [AcCardTile, AcStepper, AcButton, AltArtSlots], + imports: [AcCardTile, AcStepper, AcButton], host: { class: 'ac-overlay-content' }, templateUrl: './card-zoom.overlay.html', styleUrl: './card-zoom.overlay.scss', }) export class CardZoomOverlay { private readonly ref = inject>(AcOverlayRef); - private readonly ownership = inject(OwnershipApiService); + private readonly overlay = inject(AcOverlayService); protected readonly data = this.ref.data; - /** - * « Global » alt-art preference (as on the site): the card tilts under the pointer and its illustrations show with the - * player's copies, `null` otherwise or for a card with one illustration. - */ - private readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); - protected readonly altArts = toSignal( - this.ownership.globalAltArts().pipe( - switchMap((global) => (global ? this.ownership.altArtChoices([this.data.card.reference]) : of>({}))), - map((choices) => choices[this.data.card.reference] ?? null), - ), - { initialValue: null }, - ); - protected readonly tilt = signal({ x: 0, y: 0 }); - protected readonly choosing = signal(false); - protected readonly chosen = signal(this.data.card.reference); - protected readonly prints = computed(() => - (this.data.illustrations?.choice.options.options ?? []).map((o) => ({ reference: o.reference, src: cardImageUrl(o.reference), unowned: o.ownedQuantity === 0 })), - ); - protected readonly printLabel = (n: number) => $localize`:@@altArt.tile:Illustration ${n}:n:`; - - /** Unowned prints stay selectable: Board Game Arena shows the base art for the missing copies (as on the site). */ - protected confirmPrint(): void { - const ref = this.chosen(); - if (ref !== this.data.card.reference) this.data.illustrations?.pick(ref); - this.ref.close(); - } protected readonly quantity = signal(this.data.quantity?.value ?? 0); protected readonly name = computed(() => localizedText(this.data.card.name, contentLocale()) || this.data.card.reference); /** The site's card sheet (`/pages/card`, plugin core-altered-cards). */ protected readonly detailUrl = `${environment.siteUrl.replace(/\/$/, '')}/pages/card?ref=${encodeURIComponent(this.data.card.reference)}&card_lang=${contentLocale()}`; - protected onTilt(event: PointerEvent, el: HTMLElement): void { - if (!this.global() || event.pointerType === 'touch') return; - const r = el.getBoundingClientRect(); - const x = (event.clientX - r.left) / r.width - 0.5; - const y = (event.clientY - r.top) / r.height - 0.5; - this.tilt.set({ x: Math.round(-y * 16), y: Math.round(x * 16) }); - } - protected setQuantity(n: number): void { this.quantity.set(n); this.data.quantity?.change(n); } + + /** The brush is a window of its own (the zoom has no frame for it): the zoom closes first, never two windows. */ + protected chooseIllustrations(): void { + const data = this.data.illustrations; + if (!data) return; + const name = this.name(); + this.ref.close(); + openAltArtPicker(this.overlay, name, data); + } } export function openCardZoom(overlay: AcOverlayService, data: CardZoomData): AcOverlayRef { diff --git a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html index 6d5d4d2a..38635b68 100644 --- a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.html @@ -10,7 +10,7 @@
    @if (hero(); as h) { - + } diff --git a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts index c7224690..baf58f6b 100644 --- a/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/deck-settings/deck-settings.overlay.ts @@ -1,4 +1,5 @@ -import { Component, inject, signal } from '@angular/core'; +import { Component, computed, inject, signal } from '@angular/core'; +import { heroArtSources } from '../../../core/card-art'; import { BGA_LABEL, bgaTag, heroOnBga, visibleFormats, type FormatInfo } from '../../../core/formats'; import type { DeckFormat, DeckHero } from '../../../core/models'; import { AcButton } from '../../../ui/buttons'; @@ -44,6 +45,11 @@ export class DeckSettingsOverlay { protected readonly name = signal(this.ref.data.name); protected readonly description = signal(this.ref.data.description); protected readonly hero = signal(this.ref.data.hero); + /** The hero's illustration alone, without the card frame (the deck bar's art). */ + protected readonly heroArt = computed(() => { + const ref = this.hero()?.reference; + return ref ? heroArtSources(ref) : null; + }); protected readonly format = signal(this.ref.data.format); protected readonly isPublic = signal(this.ref.data.isPublic); diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html new file mode 100644 index 00000000..bb5d10c4 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.html @@ -0,0 +1,23 @@ + + diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss new file mode 100644 index 00000000..e1f858eb --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.scss @@ -0,0 +1,30 @@ +:host { + display: flex; + flex-direction: column; + gap: var(--ac-space-6); +} +/* The test hand's layout (features/deck/test-hand): toolbar of buttons, summary line, grid of cards. */ +.test-hand, +.stats { + display: flex; + flex-direction: column; + gap: var(--ac-space-3); +} +.toolbar { + display: flex; + flex-wrap: wrap; + gap: var(--ac-space-2); +} +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 150px), 1fr)); + gap: var(--ac-space-3); +} +.panels { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--ac-space-3); +} +.panel { + height: 200px; +} diff --git a/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts new file mode 100644 index 00000000..9045b902 --- /dev/null +++ b/plugins/rebuilder/app/src/app/features/shared/hand-skeleton/hand-skeleton.ts @@ -0,0 +1,18 @@ +import { Component, inject } from '@angular/core'; +import { AcSkeleton } from '../../../ui/containers'; +import { AcBreakpointService } from '../../../ui/layout.services'; + +/** + * « Main de départ » while the deck opens (DeckStore.opening): the test hand's toolbar, a hand of cards and the stats + * panels, in their layout, rather than an empty deck. + */ +@Component({ + selector: 'app-hand-skeleton', + imports: [AcSkeleton], + host: { 'aria-busy': 'true' }, + templateUrl: './hand-skeleton.html', + styleUrl: './hand-skeleton.scss', +}) +export class HandSkeleton { + protected readonly bp = inject(AcBreakpointService); +} diff --git a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html index 695817d7..1977cdc9 100644 --- a/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/hero-picker/hero-picker.overlay.html @@ -3,14 +3,12 @@

    Le héros détermine votre faction et les cartes disponibles.

    } - @if (!global()) { -
    - - @if (altArts()) { - - } -
    - } +
    + + @if (altArts()) { + + } +
    ; error: Signal } { @@ -65,11 +64,16 @@ function heroPickerTitle(): string { return $localize`:@@shared.heroPicker.title:Choisir un héros`; } +/** Same title and data for the step and the window. */ +function heroPickerConfig(selected?: DeckHero | null): { title: string; data: HeroPickerData } { + return { title: heroPickerTitle(), data: { selected } }; +} + export interface HeroPickerData { selected?: DeckHero | null; } -/** « Choisir un héros » — a step of « Réglages du deck », shown in the same window / sheet. */ +/** « Choisir un héros » — a step of « Réglages du deck », or its own window from the hero portrait. */ @Component({ selector: 'app-hero-picker', imports: [AcFactionTabs, AcHeroSelector, AcButton, AcChip], @@ -78,15 +82,12 @@ export interface HeroPickerData { styleUrl: './hero-picker.overlay.scss', }) export class HeroPickerOverlay { - private readonly ownership = inject(OwnershipApiService); protected readonly ref = inject>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); private readonly load = injectHeroes(); protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - /** « Alt arts » is hidden in « Global » alt-art mode: the preferred print comes from the player's preferences. */ - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; protected readonly picked = signal(this.ref.data?.selected ?? null); protected readonly faction = signal(this.ref.data?.selected?.faction || 'AX'); @@ -102,14 +103,17 @@ export class HeroPickerOverlay { confirm(): void { const p = this.picked(); - if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction }); + // Without « Alt arts », the hero takes the player's default alt art. + if (p) this.ref.close({ reference: p.reference, name: p.name, faction: p.faction, ...(this.altArts() ? {} : { defaultArt: true }) }); } } /** Shows « Choisir un héros » in place of the content of `parent` (no second window). */ export function openHeroPickerStep(parent: Pick, selected?: DeckHero | null): AcOverlayRef { - return parent.openStep(HeroPickerOverlay, { - title: heroPickerTitle(), - data: { selected }, - }); + return parent.openStep(HeroPickerOverlay, heroPickerConfig(selected)); +} + +/** « Choisir un héros » in its own window / sheet: the shortcut of the hero portrait in the editor. */ +export function openHeroPicker(overlay: AcOverlayService, selected?: DeckHero | null): AcOverlayRef { + return overlay.open(HeroPickerOverlay, { ...heroPickerConfig(selected), width: 520 }); } diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts index c89cfd32..ddc5b815 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.form.ts @@ -24,9 +24,10 @@ export class NewDeckForm { readonly ready = computed(() => !!this.hero() && !!this.name().trim()); private generatedName = ''; - selectHero(hero: DeckHero | null): void { + /** `defaultArt`: picked without « Alt arts », the hero takes the player's default alt art. */ + selectHero(hero: DeckHero | null, defaultArt = false): void { if (!hero) return; - this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction }); + this.hero.set({ reference: hero.reference, name: hero.name, faction: hero.faction, ...(defaultArt ? { defaultArt } : {}) }); const current = this.name(); if (!current.trim() || current === this.generatedName) { this.generatedName = $localize`:@@newDeck.defaultName:Deck ${hero.name}:hero:`; diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts index e17e3ce3..4e897b07 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck.spec.ts @@ -131,7 +131,8 @@ describe('NewDeckOverlay', () => { input.value = 'Mon deck'; input.dispatchEvent(new Event('input')); input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); - expect(closed).toEqual([{ name: 'Mon deck', hero: TREYST, format: 'standard', isPublic: false, description: '' }]); + // Picked without « Alt arts »: the deck takes the player's default alt art for the hero. + expect(closed).toEqual([{ name: 'Mon deck', hero: { ...TREYST, defaultArt: true }, format: 'standard', isPublic: false, description: '' }]); }); }); diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html index edeb49a7..8f217a5e 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.html @@ -13,14 +13,12 @@ controls="new-deck-heroes" [(active)]="faction" /> - @if (!global()) { -
    - - @if (altArts()) { - - } -
    - } +
    + + @if (altArts()) { + + } +
    diff --git a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts index 88704b33..86fc2401 100644 --- a/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts +++ b/plugins/rebuilder/app/src/app/features/shared/new-deck/new-deck.overlay.ts @@ -7,8 +7,6 @@ import { AcBreakpointService } from '../../../ui/layout.services'; import { AcFactionTabs, AcHeroSelector, factionName } from '../../../ui/metier'; import { AcOverlayRef, AcOverlayService } from '../../../ui/overlay'; import { heroChoices, injectHeroes } from '../hero-picker/hero-picker.overlay'; -import { OwnershipApiService } from '../../../core/ownership-api.service'; -import { toSignal } from '@angular/core/rxjs-interop'; import { NewDeckForm, type NewDeckResult } from '../new-deck.form'; export type { NewDeckResult } from '../new-deck.form'; @@ -34,7 +32,6 @@ export const VISIBILITY_OPTIONS = [ styleUrl: './new-deck.overlay.scss', }) export class NewDeckOverlay { - private readonly ownership = inject(OwnershipApiService); private readonly destroyRef = inject(DestroyRef); protected readonly ref = inject>(AcOverlayRef); protected readonly bp = inject(AcBreakpointService); @@ -50,7 +47,6 @@ export class NewDeckOverlay { protected readonly altArts = signal(false); protected readonly serialized = signal(false); protected readonly heroes = computed(() => heroChoices(this.load.heroes(), { altArts: this.altArts(), serialized: this.serialized() })); - protected readonly global = toSignal(this.ownership.globalAltArts(), { initialValue: false }); protected readonly error = this.load.error; readonly form = new NewDeckForm(); diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html index 4f11a757..b8101b3d 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.html @@ -1,9 +1,9 @@
    @@ -17,7 +17,7 @@ [attr.aria-label]="moreLabel()" (cdkMenuOpened)="opened(split)" > - +
    diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss index 15fdcc01..5499d02b 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.scss @@ -61,6 +61,17 @@ cursor: progress; } +:host(.sm) .split { + height: var(--ac-control-sm); +} +:host(.sm) .main { + gap: var(--ac-space-1-5); + font-size: var(--ac-font-size-small); +} +:host(.sm) .more { + width: var(--ac-control-sm); +} + .sep { width: 1px; background: var(--ac-color-border-control); diff --git a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts index 16634048..33d60444 100644 --- a/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts +++ b/plugins/rebuilder/app/src/app/ui/buttons/split-button/split-button.ts @@ -1,4 +1,4 @@ -import { Component, input, output, signal } from '@angular/core'; +import { Component, computed, input, output, signal } from '@angular/core'; import { CdkMenu, CdkMenuItem, CdkMenuTrigger } from '@angular/cdk/menu'; import type { ConnectedPosition } from '@angular/cdk/overlay'; import { AcIcon, type AcIconName } from '../../icon'; @@ -14,8 +14,8 @@ export interface AcSplitButtonItem { badge?: string; } -/** Below the button, right edges aligned; above when there is no room below. */ -const POSITIONS: ConnectedPosition[] = [ +/** A button's menu: below it, right edges aligned; above when there is no room below. */ +export const MENU_POSITIONS: ConnectedPosition[] = [ { originX: 'end', originY: 'bottom', overlayX: 'end', overlayY: 'top', offsetY: 8 }, { originX: 'end', originY: 'top', overlayX: 'end', overlayY: 'bottom', offsetY: -8 }, ]; @@ -28,6 +28,7 @@ const POSITIONS: ConnectedPosition[] = [ @Component({ selector: 'ac-split-button', imports: [AcIcon, CdkMenuTrigger, CdkMenu, CdkMenuItem], + host: { '[class.sm]': "size() === 'sm'" }, templateUrl: './split-button.html', styleUrl: './split-button.scss', }) @@ -35,6 +36,9 @@ export class AcSplitButton { readonly icon = input(undefined); /** Working: a spinner instead of the icon, both parts disabled (the label is the caller's, e.g. « Génération… »). */ readonly busy = input(false); + /** `sm`: the height and type of the small buttons (deck bar actions). */ + readonly size = input<'md' | 'sm'>('md'); + protected readonly iconSize = computed(() => (this.size() === 'sm' ? 16 : 18)); readonly items = input.required(); /** The arrow's aria-label. */ readonly moreLabel = input.required(); @@ -45,7 +49,7 @@ export class AcSplitButton { /** The whole button's width: the menu's minimum. */ protected readonly width = signal(0); - protected readonly positions = POSITIONS; + protected readonly positions = MENU_POSITIONS; protected opened(split: HTMLElement): void { this.width.set(split.offsetWidth); diff --git a/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts b/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts index a7e56b35..578cbe64 100644 --- a/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts +++ b/plugins/rebuilder/app/src/app/ui/chips/badge/badge.ts @@ -14,6 +14,7 @@ import type { AcTone } from '../../fields'; '[class.ac-badge--red]': "tone() === 'red'", '[class.ac-badge--orange]': "tone() === 'orange'", '[class.ac-badge--lg]': 'size() === 28', + '[class.ac-badge--dense]': 'dense()', }, templateUrl: './badge.html', }) @@ -21,4 +22,6 @@ export class AcBadge { readonly tone = input('blue'); readonly icon = input(undefined); readonly size = input<24 | 28>(24); + /** Tighter padding and gap, for a row of badges where room is short. */ + readonly dense = input(false); } diff --git a/plugins/rebuilder/app/src/app/ui/chips/rarity-summary/rarity-summary.html b/plugins/rebuilder/app/src/app/ui/chips/rarity-summary/rarity-summary.html index b8d35fc1..00c38229 100644 --- a/plugins/rebuilder/app/src/app/ui/chips/rarity-summary/rarity-summary.html +++ b/plugins/rebuilder/app/src/app/ui/chips/rarity-summary/rarity-summary.html @@ -1,6 +1,6 @@ @for (r of icons; track r.key) { - + {{ counts()[r.key] }} } diff --git a/plugins/rebuilder/app/src/app/ui/chips/rarity.ts b/plugins/rebuilder/app/src/app/ui/chips/rarity.ts index ff4ba807..fb17ceb0 100644 --- a/plugins/rebuilder/app/src/app/ui/chips/rarity.ts +++ b/plugins/rebuilder/app/src/app/ui/chips/rarity.ts @@ -1,14 +1,18 @@ import { assetUrl } from '../../core/asset-url'; +/** + * Rarity icons with their files' sizes (`width` / `height` attributes): an icon sized in CSS by its height only takes + * its width before the image loads, so a row of icons does not grow (or wrap) when they arrive. + */ export const RARITY_ICONS = [ - { key: 'C', label: $localize`:@@ui.rarity.common:Commune`, src: assetUrl('assets/icons/rarete-commune.png') }, - { key: 'R', label: $localize`:@@ui.rarity.rare:Rare`, src: assetUrl('assets/icons/rarete-rare.png') }, - { key: 'U', label: $localize`:@@ui.rarity.unique:Unique`, src: assetUrl('assets/icons/rarete-unique.png') }, - { key: 'E', label: $localize`:@@ui.rarity.exalted:Exaltée`, src: assetUrl('assets/icons/rarete-exaltee.png') }, + { key: 'C', label: $localize`:@@ui.rarity.common:Commune`, src: assetUrl('assets/icons/rarete-commune.png'), width: 40, height: 23 }, + { key: 'R', label: $localize`:@@ui.rarity.rare:Rare`, src: assetUrl('assets/icons/rarete-rare.png'), width: 40, height: 23 }, + { key: 'U', label: $localize`:@@ui.rarity.unique:Unique`, src: assetUrl('assets/icons/rarete-unique.png'), width: 40, height: 23 }, + { key: 'E', label: $localize`:@@ui.rarity.exalted:Exaltée`, src: assetUrl('assets/icons/rarete-exaltee.png'), width: 200, height: 100 }, ] as const; -export function rarityIcon(rarity: string): { src: string; label: string } { +export function rarityIcon(rarity: string): { src: string; label: string; width: number; height: number } { const key = rarity.toUpperCase().startsWith('R') ? 'R' : rarity.toUpperCase().startsWith('U') ? 'U' : rarity.toUpperCase().startsWith('E') ? 'E' : 'C'; const hit = RARITY_ICONS.find((r) => r.key === key)!; - return { src: hit.src, label: hit.label }; + return { src: hit.src, label: hit.label, width: hit.width, height: hit.height }; } diff --git a/plugins/rebuilder/app/src/app/ui/containers/index.ts b/plugins/rebuilder/app/src/app/ui/containers/index.ts index dc69c835..02888cfb 100644 --- a/plugins/rebuilder/app/src/app/ui/containers/index.ts +++ b/plugins/rebuilder/app/src/app/ui/containers/index.ts @@ -5,3 +5,4 @@ export * from './virtual-grid/virtual-grid'; export * from './progress-bar/progress-bar'; export * from './toast/toast'; export * from './drawer/drawer'; +export * from './skeleton/skeleton'; diff --git a/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts b/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts index 86a83417..ba4d0711 100644 --- a/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts +++ b/plugins/rebuilder/app/src/app/ui/containers/progress-bar/progress-bar.ts @@ -10,6 +10,8 @@ import { Component, input } from '@angular/core'; 'aria-valuemax': '100', '[attr.aria-valuenow]': 'value()', '[attr.aria-label]': 'ariaLabel() || null', + '[class.ac-progress--success]': "tone() === 'success'", + '[class.ac-progress--danger]': "tone() === 'danger'", }, templateUrl: './progress-bar.html', styleUrl: './progress-bar.scss', @@ -17,4 +19,6 @@ import { Component, input } from '@angular/core'; export class AcProgressBar { readonly value = input(0); readonly ariaLabel = input(''); + /** A bar measured against a cap: `success` when it is reached, `danger` when it is exceeded. */ + readonly tone = input<'primary' | 'success' | 'danger'>('primary'); } diff --git a/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts b/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts new file mode 100644 index 00000000..fb43d9ee --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/containers/skeleton/skeleton.ts @@ -0,0 +1,23 @@ +import { Directive, computed, input } from '@angular/core'; + +export type AcSkeletonShape = '' | 'text' | 'title' | 'control' | 'control-sm' | 'badge' | 'circle' | 'card' | 'panel'; + +/** + * Shape of content that is loading (`ac-skeleton`, design system feedback), hidden from screen readers: the loading + * region carries `aria-busy` and the page a status text. ``; `width` sets + * `--ac-skeleton-width` (100 % by default). No shape: a block the layout sizes. + */ +@Directive({ + selector: '[acSkeleton]', + host: { + class: 'ac-skeleton', + '[class]': 'variant()', + '[style.--ac-skeleton-width]': 'width()', + 'aria-hidden': 'true', + }, +}) +export class AcSkeleton { + readonly acSkeleton = input(''); + readonly width = input(null); + protected readonly variant = computed(() => (this.acSkeleton() ? `ac-skeleton--${this.acSkeleton()}` : '')); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss b/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss new file mode 100644 index 00000000..d3f275af --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/_glyph.scss @@ -0,0 +1,13 @@ +/* `Altered Icons` glyph before an option or value text (main, réserve, partout). */ +.glyph { + display: inline-block; + width: 1.4em; + font-family: var(--ac-card-icons); + font-style: normal; + font-weight: 400; + font-size: 1.15em; + line-height: 1; + text-align: center; + margin-right: var(--ac-space-1-5); + vertical-align: -0.12em; +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html new file mode 100644 index 00000000..c1e2996a --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.html @@ -0,0 +1,28 @@ + +
    + @for (o of filtered(); track o.id) { + @if (groupStarts().has(o.id)) { + + } + + } @empty { +

    {{ query() ? noResults : noValues }}

    + } +
    diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss new file mode 100644 index 00000000..e13e1401 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.scss @@ -0,0 +1,55 @@ +@use '../glyph'; + +:host { + display: flex; + flex-direction: column; + min-height: 0; +} +.search { + flex-shrink: 0; + margin-bottom: var(--ac-space-2); +} +.list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overscroll-behavior: contain; + display: flex; + flex-direction: column; +} +.option { + display: flex; + gap: var(--ac-space-3); + padding: var(--ac-space-2) var(--ac-space-2-5); + border-radius: var(--ac-radius-md); + font-size: var(--ac-field-font-size); + line-height: 1.35; + flex-shrink: 0; +} +@media (hover: hover) { + .option:hover { + background: var(--ac-color-primary-tint); + } +} +.option.on { + font-weight: 700; + color: var(--ac-color-primary-strong); +} +.option input { + flex-shrink: 0; +} +.group { + padding: var(--ac-space-3) var(--ac-space-2-5) var(--ac-space-1); + font-size: var(--ac-font-size-micro); + font-weight: 700; + letter-spacing: var(--ac-letter-spacing-overline); + text-transform: uppercase; + color: var(--ac-color-text-muted); + flex-shrink: 0; +} +.empty { + margin: 0; + padding: var(--ac-space-2-5); + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts new file mode 100644 index 00000000..f7324d37 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/check-list/check-list.ts @@ -0,0 +1,53 @@ +import { Component, computed, input, model, signal } from '@angular/core'; +import { AcIcon } from '../../icon'; +import type { ComboOption } from '../combobox/combobox'; +import { groupStarts, normalizeSearch } from '../option-list'; + +/** + * Multi-select list that stays on screen: a search field over one checkbox per option, in the order of + * `options`. Checking or unchecking leaves every row where it is; Enter in the search field checks the + * first unchecked match and empties the field, so several values go in from the keyboard in a row. + * A checked value goes to the end of `values`, the order the user picked them in. + */ +@Component({ + selector: 'ac-check-list', + imports: [AcIcon], + templateUrl: './check-list.html', + styleUrl: './check-list.scss', +}) +export class AcCheckList { + readonly options = input([]); + readonly values = model([]); + /** Accessible name of the list (« effet »). */ + readonly label = input.required(); + readonly searchPlaceholder = input($localize`:@@ui.combobox.search:Rechercher…`); + + protected readonly noResults = $localize`:@@ui.combobox.noResults:Aucun résultat`; + protected readonly noValues = $localize`:@@ui.combobox.noValues:Aucune valeur disponible`; + protected readonly query = signal(''); + protected readonly checked = computed(() => new Set(this.values().map((v) => v.id))); + + protected readonly filtered = computed(() => { + const q = normalizeSearch(this.query()); + return q ? this.options().filter((o) => normalizeSearch(o.text).includes(q)) : this.options(); + }); + + /** Where a group heading goes: the first listed option of each group. */ + protected readonly groupStarts = computed(() => groupStarts(this.filtered())); + + setChecked(o: ComboOption, on: boolean): void { + this.values.update((list) => { + const without = list.filter((v) => v.id !== o.id); + return on ? [...without, o] : without; + }); + } + + protected onSearchKey(e: KeyboardEvent): void { + if (e.key !== 'Enter') return; + e.preventDefault(); + const first = this.filtered().find((o) => !this.checked().has(o.id)); + if (!this.query() || !first) return; + this.setChecked(first, true); + this.query.set(''); + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html index 1031e753..175d3ddd 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.html @@ -1,9 +1,14 @@ -@for (v of values(); track v.id) { +
    @if (v.glyph) {}{{ v.text }}
    - +
    +@if (!keepOpen()) { + @for (v of values(); track v.id) { + + + } }
    +@if (keepOpen()) { + @for (v of values(); track v.id) { + @if (!$first) { + + } + + } +}
    @if (filtered().length) { -
    +
    @for (o of filtered(); track o.id) { @if (groupStarts().has(o.id)) { } -
    @if (o.thumb) {}@if (o.glyph) {}{{ o.text }}
    +
    @if (keepOpen()) {}@if (o.thumb) {}@if (o.glyph) {}{{ o.text }}
    }
    } @else { diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss index 90f05ba1..4a308bd9 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.scss @@ -1,3 +1,5 @@ +@use '../glyph'; + :host { display: flex; flex-direction: column; @@ -73,18 +75,6 @@ .chev.up { transform: translateY(-50%) rotate(180deg); } -.glyph { - display: inline-block; - width: 1.4em; - font-family: var(--ac-card-icons); - font-style: normal; - font-weight: 400; - font-size: 1.15em; - line-height: 1; - text-align: center; - margin-right: var(--ac-space-1-5); - vertical-align: -0.12em; -} /* In the CDK overlay container (plugin shadow root), sized to the button (matchWidth). */ .panel { width: 100%; @@ -178,3 +168,26 @@ outline: 2px solid var(--ac-color-primary); outline-offset: -2px; } +/* `keepOpen`: a check box before each option, filled when the value is checked. */ +.box { + width: 18px; + height: 18px; + margin-right: var(--ac-space-2-5); + border: 1.5px solid var(--ac-color-border-control); + border-radius: var(--ac-radius-sm); + background: var(--ac-color-surface); + color: transparent; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} +.option[aria-selected='true'] { + font-weight: 700; + color: var(--ac-color-primary-strong); +} +.option[aria-selected='true'] .box { + border-color: var(--ac-color-primary); + background: var(--ac-color-primary); + color: var(--ac-color-on-primary); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts index f50079fb..107b8990 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.spec.ts @@ -203,6 +203,71 @@ describe('AcCombobox picker', () => { }); }); +describe('AcCombobox keepOpen', () => { + const panel = () => document.querySelector('.cdk-overlay-container .panel'); + const options = () => [...(panel()?.querySelectorAll('[role=option]') ?? [])]; + + function setup(values: ComboOption[] = []) { + const fixture = TestBed.createComponent(AcCombobox); + fixture.componentRef.setInput('options', [MAIN, EXPEDITION]); + fixture.componentRef.setInput('values', values); + fixture.componentRef.setInput('keepOpen', true); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + document.body.appendChild(el); + el.querySelector('.field [role=combobox]')!.click(); + fixture.detectChanges(); + const done = () => { + fixture.destroy(); + el.remove(); + }; + return { fixture, el, done }; + } + + it('checks and unchecks several values without closing the list, chips under the button', () => { + const { fixture, el, done } = setup(); + options()[1].click(); + fixture.detectChanges(); + options()[0].click(); + fixture.detectChanges(); + expect(panel()).not.toBeNull(); + expect(fixture.componentInstance.values()).toEqual([EXPEDITION, MAIN]); + expect(options().map((o) => o.getAttribute('aria-selected'))).toEqual(['true', 'true']); + expect(sequence(el)).toEqual(['field', `chip:${EXPEDITION.text}`, 'ou', `chip:${MAIN.text}`]); + + options()[1].click(); + fixture.detectChanges(); + expect(fixture.componentInstance.values()).toEqual([MAIN]); + expect(options().map((o) => o.getAttribute('aria-selected'))).toEqual(['true', 'false']); + done(); + }); + + it('Enter in the search checks the first unchecked match, empties the field and stays open', () => { + const { fixture, done } = setup([MAIN]); + const search = panel()!.querySelector('input')!; + search.value = 'e'; // both match, MAIN is already checked + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + search.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + fixture.detectChanges(); + expect(fixture.componentInstance.values()).toEqual([MAIN, EXPEDITION]); + expect(search.value).toBe(''); + expect(panel()).not.toBeNull(); + done(); + }); + + it('a checked value the search hides stays checked', () => { + const { fixture, done } = setup([MAIN]); + const search = panel()!.querySelector('input')!; + search.value = 'expé'; + search.dispatchEvent(new Event('input')); + fixture.detectChanges(); + expect(options().map((o) => o.textContent?.trim())).toEqual([EXPEDITION.text]); + expect(fixture.componentInstance.values()).toEqual([MAIN]); + done(); + }); +}); + /** The component and its CDK overlay container in a shadow root, as in the plugin (`ShadowOverlayContainer`). */ const shadowHost = document.createElement('div'); const shadowRoot = shadowHost.attachShadow({ mode: 'open' }); diff --git a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts index 43a1d8b9..cceab656 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/combobox/combobox.ts @@ -1,8 +1,10 @@ import { CdkListbox, CdkOption, type ListboxValueChangeEvent } from '@angular/cdk/listbox'; import { CdkConnectedOverlay, CdkOverlayOrigin, createRepositionScrollStrategy, type ConnectedPosition } from '@angular/cdk/overlay'; import { _getEventTarget } from '@angular/cdk/platform'; -import { Component, ElementRef, Injector, afterNextRender, computed, inject, input, model, output, signal, viewChild } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; +import { Component, ElementRef, booleanAttribute, Injector, afterNextRender, computed, inject, input, model, output, signal, viewChild } from '@angular/core'; import { AcIcon } from '../../icon'; +import { groupStarts, normalizeSearch } from '../option-list'; import { nextId } from '../value-accessor'; export interface ComboOption { @@ -28,10 +30,14 @@ const POSITIONS: ConnectedPosition[] = [ * field and a CDK listbox (arrows, Home / End, Enter, typeahead, ARIA). Opening does not focus the * search field, so touch keyboards stay closed until the user taps it; typing on the button moves * the key into it. + * + * `keepOpen`: several values in a row (calculators, subtypes). Each option has a check box, a press checks or + * unchecks it and the list stays open; Enter in the search field checks the first unchecked match and + * empties the field. The chips go under the button, so checking never moves the button or the list. */ @Component({ selector: 'ac-combobox', - imports: [AcIcon, CdkOverlayOrigin, CdkConnectedOverlay, CdkListbox, CdkOption], + imports: [AcIcon, NgTemplateOutlet, CdkOverlayOrigin, CdkConnectedOverlay, CdkListbox, CdkOption], templateUrl: './combobox.html', styleUrl: './combobox.scss', }) @@ -40,8 +46,9 @@ export class AcCombobox { readonly options = input([]); readonly values = model([]); readonly placeholder = input($localize`:@@ui.combobox.add:Ajouter…`); - readonly emptyPlaceholder = input(''); readonly searchPlaceholder = input($localize`:@@ui.combobox.search:Rechercher…`); + /** Check several values without the list closing; checked values stay in the list. */ + readonly keepOpen = input(false, { transform: booleanAttribute }); readonly searchChange = output(); private readonly toggle = viewChild.required>('toggle'); @@ -61,26 +68,26 @@ export class AcCombobox { protected readonly query = signal(''); protected readonly open = signal(false); - protected readonly label = computed(() => (this.values().length ? this.placeholder() : this.emptyPlaceholder() || this.placeholder())); + /** + * Selection of the listbox: with `keepOpen`, the checked options among those listed (the listbox + * rejects a value it has no option for, such as one the search hides); none otherwise. + */ + protected readonly listValue = computed(() => { + if (!this.keepOpen()) return this.noSelection; + const checked = new Set(this.values().map((v) => v.id)); + return this.filtered().filter((o) => checked.has(o.id)); + }); /** Where a group heading goes: the first listed option of each group. */ - protected readonly groupStarts = computed(() => { - const starts = new Set(); - let last: string | undefined; - for (const o of this.filtered()) { - if (o.group && o.group !== last) starts.add(o.id); - last = o.group; - } - return starts; - }); + protected readonly groupStarts = computed(() => groupStarts(this.filtered())); protected readonly filtered = computed(() => { - const q = normalize(this.query()); - const chosen = new Set(this.values().map((v) => v.id)); + const q = normalizeSearch(this.query()); + const chosen = this.keepOpen() ? new Set() : new Set(this.values().map((v) => v.id)); const out: ComboOption[] = []; for (const o of this.options()) { if (chosen.has(o.id)) continue; - if (q && !normalize(o.text).includes(q)) continue; + if (q && !normalizeSearch(o.text).includes(q)) continue; out.push(o); if (out.length >= 50) break; } @@ -93,11 +100,23 @@ export class AcCombobox { this.close(); } + /** `keepOpen`: a checked value goes to the end of `values`, the order the user picked them in. */ + setChecked(o: ComboOption, on: boolean): void { + this.values.update((list) => { + const without = list.filter((v) => v.id !== o.id); + return on ? [...without, o] : without; + }); + } + remove(o: ComboOption): void { this.values.update((v) => v.filter((x) => x.id !== o.id)); } protected onSelect(e: ListboxValueChangeEvent): void { + if (this.keepOpen()) { + if (e.option) this.setChecked(e.option.value, e.option.isSelected()); + return; + } const o = e.value[0]; if (o) this.pick(o); } @@ -121,15 +140,23 @@ export class AcCombobox { } } - /** In the search field: arrows go to the list, Enter picks the first match. */ + /** In the search field: arrows go to the list, Enter picks (checks, with `keepOpen`) the first match. */ protected onSearchKey(e: KeyboardEvent): void { if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); this.focusList(); } else if (e.key === 'Enter') { e.preventDefault(); + if (!this.query()) return; + if (this.keepOpen()) { + const checked = new Set(this.values().map((v) => v.id)); + const first = this.filtered().find((o) => !checked.has(o.id)); + if (first) this.setChecked(first, true); + this.query.set(''); + return; + } const first = this.filtered()[0]; - if (first && this.query()) this.pick(first); + if (first) this.pick(first); } } @@ -179,7 +206,3 @@ export class AcCombobox { this.query.set(''); } } - -function normalize(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); -} diff --git a/plugins/rebuilder/app/src/app/ui/fields/index.ts b/plugins/rebuilder/app/src/app/ui/fields/index.ts index db6c5e8d..7faa9e8d 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/index.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/index.ts @@ -1,8 +1,10 @@ +export * from './check-list/check-list'; export * from './checkbox/checkbox'; export * from './combobox/combobox'; export * from './editable-title/editable-title'; export * from './file-input/file-input'; export * from './input/input'; +export * from './or-values/or-values'; export * from './radio-card/radio-card'; export * from './segmented/segmented'; export * from './select/select'; diff --git a/plugins/rebuilder/app/src/app/ui/fields/option-list.ts b/plugins/rebuilder/app/src/app/ui/fields/option-list.ts new file mode 100644 index 00000000..c8344db4 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/option-list.ts @@ -0,0 +1,15 @@ +/** Text compared by the list searches: lower case, accents removed (« épuisé » finds « Épuisé »). */ +export function normalizeSearch(s: string): string { + return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); +} + +/** Ids of the options that start a group (`group` differs from the previous option's): where a heading goes. */ +export function groupStarts(options: readonly { id: number; group?: string }[]): Set { + const starts = new Set(); + let last: string | undefined; + for (const o of options) { + if (o.group && o.group !== last) starts.add(o.id); + last = o.group; + } + return starts; +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html new file mode 100644 index 00000000..cacc06e3 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.html @@ -0,0 +1,13 @@ +@for (v of values(); track v.id) { + + @if (v.glyph) {}{{ v.text }} + + + @if (!$last) { + + } +} @empty { + @if (empty()) { + {{ empty() }} + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss new file mode 100644 index 00000000..8f3ef33e --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.scss @@ -0,0 +1,52 @@ +@use '../glyph'; + +/* One value per line, each « ou » on its own line between them. */ +:host { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--ac-space-1); + min-width: 0; +} +.value { + display: inline-flex; + align-items: flex-start; + gap: var(--ac-space-1); + max-width: 100%; + padding: var(--ac-space-1) var(--ac-space-1) var(--ac-space-1) var(--ac-space-2-5); + border: 1px solid var(--ac-color-primary-border); + background: var(--ac-color-primary-soft); + color: var(--ac-color-primary-strong); + border-radius: var(--ac-radius-md); + font-size: var(--ac-select-font-size); + font-weight: 700; + line-height: 1.35; +} +.value button { + position: relative; + border: none; + background: none; + color: var(--ac-color-primary-strong); + display: flex; + padding: 2px; + border-radius: var(--ac-radius-sm); + cursor: pointer; + flex-shrink: 0; +} +/* Tap area of at least --ac-hit-min around the small cross. */ +.value button::after { + content: ''; + position: absolute; + inset: min(0px, calc((18px - var(--ac-hit-min)) / 2)); +} +.or { + font-size: var(--ac-font-size-micro); + font-weight: 800; + color: var(--ac-color-text-muted); + text-transform: uppercase; + padding-left: var(--ac-space-2-5); +} +.empty { + font-size: var(--ac-font-size-small); + color: var(--ac-color-text-muted); +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts new file mode 100644 index 00000000..faa7bdd1 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/fields/or-values/or-values.ts @@ -0,0 +1,27 @@ +import { Component, input, model } from '@angular/core'; +import { AcIcon } from '../../icon'; +import type { ComboOption } from '../combobox/combobox'; + +/** + * Values one per line, « ou » on its own line between two, each value with a button that removes it + * (effect editor). `empty` is shown when + * there is none (« Tous les effets »). + */ +@Component({ + selector: 'ac-or-values', + imports: [AcIcon], + templateUrl: './or-values.html', + styleUrl: './or-values.scss', +}) +export class AcOrValues { + readonly values = model([]); + readonly empty = input(''); + + protected removeLabel(v: ComboOption): string { + return $localize`:@@ui.combobox.remove:Retirer ${v.text}:value:`; + } + + protected remove(v: ComboOption): void { + this.values.update((list) => list.filter((x) => x.id !== v.id)); + } +} diff --git a/plugins/rebuilder/app/src/app/ui/fields/select/select.ts b/plugins/rebuilder/app/src/app/ui/fields/select/select.ts index 25765ddf..bb918024 100644 --- a/plugins/rebuilder/app/src/app/ui/fields/select/select.ts +++ b/plugins/rebuilder/app/src/app/ui/fields/select/select.ts @@ -5,6 +5,7 @@ import { Component, ElementRef, Injector, afterNextRender, computed, forwardRef, import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { AcIcon } from '../../icon'; import { nextId, ValueAccessor } from '../value-accessor'; +import { normalizeSearch } from '../option-list'; export interface AcOption { value: T; @@ -90,8 +91,8 @@ export class AcSelect extends ValueAccessor { /** Options without a group first, then the groups, filtered by the search (names, then group names). */ protected readonly view = computed(() => { - const q = fold(this.query().trim()); - const match = (o: AcOption) => !q || fold(o.label).includes(q) || (!!o.group && fold(o.group).startsWith(q)); + const q = normalizeSearch(this.query().trim()); + const match = (o: AcOption) => !q || normalizeSearch(o.label).includes(q) || (!!o.group && normalizeSearch(o.group).startsWith(q)); const loose: Row[] = []; const groups: Group[] = []; let index = 0; @@ -121,8 +122,8 @@ export class AcSelect extends ValueAccessor { return `${this.id}-g${i}`; } protected highlight(label: string): { before: string; match: string; after: string } { - const q = fold(this.query().trim()); - const i = q ? fold(label).indexOf(q) : -1; + const q = normalizeSearch(this.query().trim()); + const i = q ? normalizeSearch(label).indexOf(q) : -1; return i < 0 ? { before: label, match: '', after: '' } : { before: label.slice(0, i), match: label.slice(i, i + q.length), after: label.slice(i + q.length) }; } protected readonly noResults = computed(() => $localize`:@@ui.select.noResults:Aucun résultat pour « ${this.query().trim()}:query: »`); @@ -287,13 +288,13 @@ export class AcSelect extends ValueAccessor { private typeahead(ch: string): void { const now = Date.now(); - this.typed = (now - this.typedAt > 700 ? '' : this.typed) + fold(ch); + this.typed = (now - this.typedAt > 700 ? '' : this.typed) + normalizeSearch(ch); this.typedAt = now; const rows = this.view().rows; const start = this.typed.length === 1 ? this.active() + 1 : Math.max(this.active(), 0); for (let k = 0; k < rows.length; k++) { const i = (start + k) % rows.length; - if (!rows[i].option.disabled && fold(rows[i].option.label).startsWith(this.typed)) { + if (!rows[i].option.disabled && normalizeSearch(rows[i].option.label).startsWith(this.typed)) { this.active.set(i); afterNextRender(() => this.scrollToActive(), { injector: this.injector }); return; @@ -319,9 +320,6 @@ export class AcSelect extends ValueAccessor { } } -function fold(s: string): string { - return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); -} function printable(e: KeyboardEvent): boolean { return e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey; diff --git a/plugins/rebuilder/app/src/app/ui/icon/icon.ts b/plugins/rebuilder/app/src/app/ui/icon/icon.ts index 92c27d6d..d2b5d64b 100644 --- a/plugins/rebuilder/app/src/app/ui/icon/icon.ts +++ b/plugins/rebuilder/app/src/app/ui/icon/icon.ts @@ -13,6 +13,7 @@ export const AC_ICONS = { plus: '', minus: '', 'chevron-down': '', + 'arrow-left-right': '', 'chevron-up': '', 'chevron-left': '', 'chevron-right': '', @@ -25,6 +26,9 @@ export const AC_ICONS = { pencil: '', 'sliders-horizontal': '', moon: '', + brush: '', + 'wand-sparkles': '', + images: '', layers: '', 'layout-grid': '', menu: '', diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html index 085bcbb4..8f30bafa 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-art/card-art.html @@ -3,6 +3,7 @@ } @if (visible() && src(); as s) { ); private readonly viewport = inject(AcViewportLoader); /** Art starts downloading once the tile is ~1.5 screens from the viewport (or immediately when eager). */ - protected readonly visible = signal(false); + private readonly inView = signal(false); readonly reference = input.required(); readonly faction = input(null); readonly placeholder = input($localize`:@@ui.cardArt.placeholder:Visuel de la carte`); @@ -35,17 +36,38 @@ export class AcCardArt implements OnDestroy { /** Replaces the fallback chain derived from `reference`. */ readonly sources = input(null); - private readonly chain = computed(() => this.sources() ?? cardImageSources(this.reference(), contentLocale())); - /** Reset with the chain: a new reference shows the placeholder until its own image loads. */ - protected readonly loaded = linkedSignal({ source: this.chain, computation: () => false }); + /** Compared by value: a parent that rebuilds the same list must not retry sources that already failed. */ + private readonly chain = computed(() => this.sources() ?? cardImageSources(this.reference(), contentLocale()), { + equal: (a, b) => a.length === b.length && a.every((src, i) => src === b[i]), + }); private readonly attempt = linkedSignal({ source: this.chain, computation: () => 0 }); protected readonly src = computed(() => this.chain()[this.attempt()] ?? null); + /** + * Reset with the displayed src only: a new image shows the placeholder until it loads, while an that keeps + * its src never fires `load` again (a unique whose card object is replaced once its effect arrives). + */ + protected readonly loaded = linkedSignal({ source: this.src, computation: () => false }); protected readonly tint = computed(() => factionColor(this.faction())); + /** Eager art is in the first render: no frame of placeholder before it. */ + protected readonly visible = computed(() => this.eager() || this.inView()); + private readonly img = viewChild>('img'); + /** + * Eager art already in the browser's cache (the same hero after the editor's view changed, which renders the page + * again): shown at once, without the placeholder and the fade of an image that downloads. + */ + protected readonly instant = linkedSignal({ source: this.src, computation: () => false }); constructor() { afterNextRender(() => { - if (this.eager()) this.visible.set(true); - else this.viewport.observe(this.host.nativeElement, () => this.visible.set(true)); + if (!this.eager()) this.viewport.observe(this.host.nativeElement, () => this.inView.set(true)); + }); + // Eager art only (portraits): the tiles of a grid load lazily and keep their fade, without this check each. + afterRenderEffect(() => { + if (!this.eager()) return; + const img = this.img()?.nativeElement; + if (!img || this.loaded() || !img.complete || !img.naturalWidth) return; + this.instant.set(true); + this.loaded.set(true); }); } @@ -63,6 +85,18 @@ export class AcCardArt implements OnDestroy { * the lookahead zone for DWELL_MS, so tiles skipped by a fast flick never download their image. */ const DWELL_MS = 150; +/** Art loads up to half a screen above and a screen and a half below the viewport… */ +const LOOKAHEAD = '50% 0px 150% 0px'; +/** + * …or half a screen below on a slow connection (3G or slower, or data saver): there, the art of the next screens + * takes the bandwidth of the art on screen, seconds later on a 3G network. + */ +const SLOW_LOOKAHEAD = '0px 0px 50% 0px'; + +function slowNetwork(): boolean { + const connection = (globalThis.navigator as Navigator & { connection?: { effectiveType?: string; saveData?: boolean } } | undefined)?.connection; + return !!connection && (connection.saveData === true || ['slow-2g', '2g', '3g'].includes(connection.effectiveType ?? '')); +} @Service() export class AcViewportLoader { @@ -91,7 +125,7 @@ export class AcViewportLoader { ); } }, - { rootMargin: '50% 0px 150% 0px' }, + { rootMargin: slowNetwork() ? SLOW_LOOKAHEAD : LOOKAHEAD }, ); this.callbacks.set(el, onVisible); this.observer.observe(el); diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html new file mode 100644 index 00000000..6ffbc41e --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.html @@ -0,0 +1,35 @@ + + @if (unique()) { + + } @else { + + } + + +@if (issues().length && quantity() > 0) { + +} +@if (illustrations()) { + +} +@if (quantity() > 1) { + +} +@if (!readonly()) { + +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss new file mode 100644 index 00000000..4e18b0e1 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.scss @@ -0,0 +1,149 @@ +:host { + /* Offset of each copy behind the front one, a share of the card's width: the rarity gem (top centre, down to 8 % + of the width) of each copy behind shows whole above the next one. */ + --pile-offset: 9cqi; + container-type: inline-size; + position: relative; + display: block; + min-width: 0; +} +.stack { + position: relative; + display: block; + width: 100%; + padding: calc(var(--copies, 0) * var(--pile-offset)) 0 0; + border: 0; + background: none; + border-radius: var(--ac-radius-lg); + cursor: zoom-in; +} +.stack:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} +.face { + display: block; + aspect-ratio: 5 / 7; + border-radius: var(--ac-radius-lg); + overflow: hidden; + box-shadow: var(--ac-shadow-card); + background: var(--ac-color-text-2); +} +.layer { + position: absolute; + left: 0; + right: 0; + top: calc(var(--i) * var(--pile-offset)); +} +.front { + position: relative; +} +:host(.zero) .stack { + opacity: 0.4; +} +:host(.issue) .front { + box-shadow: 0 0 0 3px var(--ac-color-warning); +} +.marker { + position: absolute; + left: var(--ac-space-1-5); + top: var(--ac-space-1-5); + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-warning); + color: var(--ac-color-on-strong); + box-shadow: var(--ac-shadow-e3); + pointer-events: auto; +} +.qty { + position: absolute; + right: var(--ac-space-1-5); + bottom: var(--ac-space-1-5); + min-width: 28px; + height: 24px; + padding: 0 var(--ac-space-1-5); + box-sizing: border-box; + display: flex; + align-items: center; + justify-content: center; + border-radius: var(--ac-radius-pill); + background: var(--ac-color-overlay-strong); + color: var(--ac-color-on-strong); + font: var(--ac-font-caption); + pointer-events: none; +} +/* Brush: top right of the front card, always shown (the card has several illustrations). */ +.illustrate { + position: absolute; + top: calc(var(--pile-behind, 0) * var(--pile-offset) + var(--ac-space-1-5)); + right: var(--ac-space-1-5); + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: 0; + border-radius: var(--ac-radius-md); + background: var(--ac-color-surface); + color: var(--ac-color-primary); + box-shadow: var(--ac-shadow-fab); + cursor: pointer; +} +.illustrate::after { + content: ''; + position: absolute; + left: 50%; + top: 50%; + width: max(100%, var(--ac-hit-min)); + height: max(100%, var(--ac-hit-min)); + transform: translate(-50%, -50%); +} +.illustrate:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} +/* The stepper: over the card while it is hovered or holds the focus (always shown on touch screens). */ +.stepper { + position: absolute; + left: 50%; + bottom: var(--ac-space-2); + transform: translateX(-50%); + opacity: 0; + pointer-events: none; + transition: opacity 0.12s; +} +:host(:focus-within) .stepper { + opacity: 1; + pointer-events: auto; +} +:host(.editable:focus-within) .qty { + opacity: 0; +} +@media (hover: hover) { + :host(:hover) .stepper { + opacity: 1; + pointer-events: auto; + } + :host(.editable:hover) .qty { + opacity: 0; + } +} +@media (hover: none) { + .stepper { + opacity: 1; + pointer-events: auto; + } + :host(.editable) .qty { + opacity: 0; + } +} +@media (prefers-reduced-motion: reduce) { + .stepper { + transition: none; + } +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts new file mode 100644 index 00000000..4237c72b --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/card-pile/card-pile.ts @@ -0,0 +1,61 @@ +import { NgTemplateOutlet } from '@angular/common'; +import { Component, computed, input, output } from '@angular/core'; +import { isUniqueReference } from '../../../core/card-art'; +import { contentLocale } from '../../../core/locale'; +import type { Card } from '../../../core/models'; +import { localizedText } from '../../../core/models'; +import { AcStepper } from '../../buttons'; +import { AcIcon } from '../../icon'; +import { AcCardArt } from '../card-art/card-art'; +import { AcUniqueCard } from '../unique-card/unique-card'; + +/** + * A card of the deck board (« Aperçu »): its copies stacked, each one a few pixels lower, « ×n » from 2 copies. Editable: + * the « − n + » stepper over the card on hover or focus; faded at 0. A line + * with legality issues gets an orange ring and marker. + */ +@Component({ + selector: 'ac-card-pile', + imports: [NgTemplateOutlet, AcCardArt, AcUniqueCard, AcStepper, AcIcon], + host: { + role: 'listitem', + '[class.zero]': 'quantity() === 0', + '[class.issue]': 'issues().length > 0 && quantity() > 0', + '[class.editable]': '!readonly()', + '[style.--pile-behind]': 'behind().length', + }, + templateUrl: './card-pile.html', + styleUrl: './card-pile.scss', +}) +export class AcCardPile { + readonly card = input.required(); + readonly quantity = input(1); + /** The copies' prints, front first (a pile of a card's illustrations); `null`: every copy shows `card`. */ + readonly prints = input(null); + readonly max = input(3); + readonly readonly = input(true); + /** Why the line is not legal (the deck panel's tooltips). */ + readonly issues = input([]); + readonly quantityChange = output(); + /** The card was clicked: the page opens it large. */ + readonly zoom = output(); + /** Brush over the front card (a card with several illustrations, in an editable deck): emits `illustrate`. */ + readonly illustrations = input(false); + readonly illustrate = output(); + + protected readonly name = computed(() => localizedText(this.card().name, contentLocale()) || this.card().reference); + protected readonly unique = computed(() => isUniqueReference(this.card().reference)); + /** Copies behind the front one (a pile at 0 shows one card). */ + protected readonly behind = computed(() => Array.from({ length: Math.max(0, Math.min(this.quantity(), 3) - 1) }, (_, i) => i)); + /** The print of each copy behind the front one. */ + protected readonly layers = computed(() => this.behind().map((i) => ({ i, reference: this.prints()?.[i + 1] ?? this.card().reference }))); + protected readonly zoomLabel = computed(() => { + const n = this.quantity(); + return n === 1 + ? $localize`:@@ui.cardPile.zoomOne:Agrandir ${this.name()}:name:, ${n}:count: exemplaire` + : $localize`:@@ui.cardPile.zoom:Agrandir ${this.name()}:name:, ${n}:count: exemplaires`; + }); + protected readonly copiesLabel = computed(() => $localize`:@@ui.cardTile.copiesInDeck:Exemplaires de ${this.name()}:name: dans le deck`); + protected readonly issueLabel = computed(() => this.issues().join(' · ')); + protected readonly illustrateLabel = computed(() => $localize`:@@ui.cardTile.illustrate:Choisir les illustrations de ${this.name()}:name:`); +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html index f6bd00af..9557a2d3 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html +++ b/plugins/rebuilder/app/src/app/ui/metier/card-tile/card-tile.html @@ -14,6 +14,11 @@
    } +@if (illustrations()) { + +} @if (favorite() !== null) { + } @else { + + @if (hero(); as h) { + + } + + }
    @if (appearance() === 'embedded') { Héros diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss index 1080e182..e29b5240 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.scss @@ -23,6 +23,15 @@ flex-shrink: 0; background: var(--ac-color-track); } +button.thumb { + padding: 0; + border: 0; + cursor: pointer; +} +button.thumb:focus-visible { + outline: 2px solid var(--ac-color-primary); + outline-offset: 2px; +} .meta { flex-grow: 1; min-width: 0; diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts new file mode 100644 index 00000000..3e3d78a5 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.spec.ts @@ -0,0 +1,35 @@ +import { TestBed } from '@angular/core/testing'; +import { AcDeckSummary } from './deck-summary'; + +const hero = { reference: 'ALT_CORE_B_AX_01_C', name: 'Sierra & Oddball', faction: 'AX' }; + +describe('AcDeckSummary', () => { + function render(editable: boolean) { + const fixture = TestBed.createComponent(AcDeckSummary); + fixture.componentRef.setInput('hero', hero); + fixture.componentRef.setInput('rarity', { C: 0, R: 0, U: 0, E: 0 }); + fixture.componentRef.setInput('editable', editable); + fixture.detectChanges(); + return fixture; + } + + it('opens « Choisir un héros » from the hero thumbnail when the deck is editable', () => { + const fixture = render(true); + let changes = 0; + let settings = 0; + fixture.componentInstance.changeHero.subscribe(() => changes++); + fixture.componentInstance.openSettings.subscribe(() => settings++); + const thumb = (fixture.nativeElement as HTMLElement).querySelector('button.thumb'); + expect(thumb?.getAttribute('aria-label')).toBe('Changer de héros'); + expect(thumb?.querySelector('ac-card-art')).not.toBeNull(); + thumb!.click(); + expect(changes).toBe(1); + expect(settings).toBe(0); + }); + + it('keeps a plain thumbnail on a read-only deck', () => { + const el = render(false).nativeElement as HTMLElement; + expect(el.querySelector('button.thumb')).toBeNull(); + expect(el.querySelector('.thumb ac-card-art')).not.toBeNull(); + }); +}); diff --git a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts index 9608825d..e32d82cd 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/deck-summary/deck-summary.ts @@ -7,7 +7,7 @@ import { AcBadge, AcRaritySummary } from '../../chips'; import { AcIcon } from '../../icon'; import { AcCardArt } from '../card-art/card-art'; -/** Hero thumbnail + deck meta + counters; the settings button opens « Réglages du deck ». */ +/** Hero thumbnail + deck meta + counters; the thumbnail opens « Choisir un héros », the settings button « Réglages du deck ». */ @Component({ selector: 'ac-deck-summary', imports: [AcCardArt, AcIcon, AcIconButton, AcRaritySummary, AcBadge], @@ -27,6 +27,8 @@ export class AcDeckSummary { readonly appearance = input<'card' | 'embedded'>('card'); readonly editable = input(true); readonly openSettings = output(); + /** Click on the hero thumbnail (editable only). */ + readonly changeHero = output(); /** Click on the validity badge: the format's rules, passed or failed. */ readonly showLegality = output(); @@ -35,6 +37,7 @@ export class AcDeckSummary { const ref = this.hero()?.reference; return ref ? heroArtSources(ref) : null; }); + protected readonly changeHeroLabel = $localize`:@@ui.deckSummary.changeHero:Changer de héros`; protected readonly noHero = $localize`:@@ui.deckSummary.noHero:Aucun héros`; protected readonly publicLabel = $localize`:@@ui.deckSummary.public:Public`; protected readonly privateLabel = $localize`:@@ui.deckSummary.private:Privé`; diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html index 509b4fc1..c1c4414f 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.html @@ -1,6 +1,7 @@
    {{ title() }} +
    @for (row of rows(); track row.label) { diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts new file mode 100644 index 00000000..809a99e0 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.spec.ts @@ -0,0 +1,18 @@ +import { TestBed } from '@angular/core/testing'; +import { AcEffectSummary } from './effect-summary'; + +describe('AcEffectSummary', () => { + it('offers edit, duplicate and delete, in that order, each named after the effect', () => { + TestBed.configureTestingModule({ imports: [AcEffectSummary] }); + const fixture = TestBed.createComponent(AcEffectSummary); + fixture.componentRef.setInput('effect', { id: 'e1', triggers: [], conditions: [], effects: [{ id: 3, text: 'Ravitaillez' }] }); + fixture.componentRef.setInput('title', 'Effet 2'); + fixture.detectChanges(); + let duplicated = 0; + fixture.componentInstance.duplicate.subscribe(() => duplicated++); + const buttons = [...(fixture.nativeElement as HTMLElement).querySelectorAll('.head button')]; + expect(buttons.map((b) => b.getAttribute('aria-label'))).toEqual(['Modifier Effet 2', 'Dupliquer Effet 2', 'Supprimer Effet 2']); + buttons[1].click(); + expect(duplicated).toBe(1); + }); +}); diff --git a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts index 67e2c4f6..5a8880ab 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/effect-summary/effect-summary.ts @@ -13,8 +13,10 @@ export class AcEffectSummary { readonly effect = input.required(); readonly title = input($localize`:@@ui.effectSummary.title:Effet 1`); readonly edit = output(); + readonly duplicate = output(); readonly remove = output(); protected readonly editLabel = computed(() => $localize`:@@ui.effectSummary.edit:Modifier ${this.title()}:title:`); + protected readonly duplicateLabel = computed(() => $localize`:@@ui.effectSummary.duplicate:Dupliquer ${this.title()}:title:`); protected readonly removeLabel = computed(() => $localize`:@@ui.effectSummary.remove:Supprimer ${this.title()}:title:`); protected readonly rows = computed(() => { const e = this.effect(); diff --git a/plugins/rebuilder/app/src/app/ui/metier/hero-selector/hero-selector.scss b/plugins/rebuilder/app/src/app/ui/metier/hero-selector/hero-selector.scss index 7fc5417e..80ac16af 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/hero-selector/hero-selector.scss +++ b/plugins/rebuilder/app/src/app/ui/metier/hero-selector/hero-selector.scss @@ -20,6 +20,11 @@ margin-inline: calc(-1 * var(--bleed)); padding: var(--ac-space-1-5) var(--bleed); } +/* The row's height for every faction, and while the heroes load: a tile with a name on two lines and the « Indispo. BGA » + badge (20 px, hero-tile), so that the form under it does not move. */ +:host(.layout-carousel) .track { + min-height: calc(112px * 7 / 5 + 2 * var(--ac-space-2) + 2 * 1.25 * var(--ac-font-size-small) + 20px + 2 * var(--ac-space-1-5)); +} :host(.layout-carousel) .track::-webkit-scrollbar { display: none; } diff --git a/plugins/rebuilder/app/src/app/ui/metier/index.ts b/plugins/rebuilder/app/src/app/ui/metier/index.ts index bae3f456..4f7b7d96 100644 --- a/plugins/rebuilder/app/src/app/ui/metier/index.ts +++ b/plugins/rebuilder/app/src/app/ui/metier/index.ts @@ -17,3 +17,6 @@ export * from './probability-bars/probability-bars'; export * from './save-status/save-status'; export * from './scroll'; export * from './unique-card/unique-card'; +export * from './card-pile/card-pile'; +export * from './cost-curve/cost-curve'; +export * from './rarity-limits/rarity-limits'; diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html new file mode 100644 index 00000000..1e06e43e --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.html @@ -0,0 +1,3 @@ +@for (r of items(); track r.key) { + {{ r.count }}/{{ r.limit }} +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss new file mode 100644 index 00000000..9e5338f1 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.scss @@ -0,0 +1,17 @@ +:host { + display: inline-flex; + align-items: center; + gap: var(--ac-space-1-5); + flex-shrink: 0; +} +:host(.dense) { + gap: var(--ac-space-1); +} +img { + height: 13px; + width: auto; + display: block; +} +:host(.dense) img { + height: 10px; +} diff --git a/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts new file mode 100644 index 00000000..aaa80fe3 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/metier/rarity-limits/rarity-limits.ts @@ -0,0 +1,49 @@ +import { Component, computed, input } from '@angular/core'; +import type { RarityLimit } from '../../../core/deck-rules'; +import { AcBadge, RARITY_ICONS } from '../../chips'; + +/** A capped rarity as shown: its icon, its name, `under` / `at` / `over` the cap, and « Rare : 12 sur 15 maximum ». */ +export interface RarityLimitItem extends RarityLimit { + icon: string; + /** The icon file's size (`width` / `height` attributes). */ + iconWidth: number; + iconHeight: number; + name: string; + state: 'under' | 'at' | 'over'; + label: string; +} + +export function rarityLimitItems(limits: readonly RarityLimit[]): RarityLimitItem[] { + return limits.map((r) => { + const { src: icon, label: name, width: iconWidth, height: iconHeight } = RARITY_ICONS.find((i) => i.key === r.key)!; + return { + ...r, + icon, + iconWidth, + iconHeight, + name, + state: r.count > r.limit ? 'over' : r.count === r.limit ? 'at' : 'under', + label: $localize`:@@ui.rarityLimits.item:${name}:rarity: : ${r.count}:count: sur ${r.limit}:limit: maximum`, + }; + }); +} + +const TONES = { under: 'neutral', at: 'green', over: 'red' } as const; + +/** + * Rares, Exalteds and Uniques against the format's caps, one badge each: grey below the cap, green at it, red above. + */ +@Component({ + selector: 'ac-rarity-limits', + imports: [AcBadge], + host: { role: 'group', '[attr.aria-label]': 'groupLabel', '[class.dense]': 'dense()' }, + templateUrl: './rarity-limits.html', + styleUrl: './rarity-limits.scss', +}) +export class AcRarityLimits { + readonly limits = input.required(); + /** Dense badges and smaller icons: a phone's app bar. */ + readonly dense = input(false); + protected readonly groupLabel = $localize`:@@ui.rarityLimits.label:Raretés limitées par le format`; + protected readonly items = computed(() => rarityLimitItems(this.limits()).map((r) => ({ ...r, tone: TONES[r.state] }))); +} diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html index b78bb2f3..f2bd27bf 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.html @@ -1,10 +1,14 @@
    -
    - {{ title() }} - @if (subtitle()) { - {{ subtitle() }} - } -
    + @if (title() || subtitle()) { +
    + {{ title() }} + @if (subtitle()) { + {{ subtitle() }} + } +
    + } + +
    diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss index d472368d..56b0f6d3 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.scss @@ -18,6 +18,12 @@ header { align-items: center; padding-right: 4px; } +/* An action in skeleton (``, owner not known yet): the size of a ghost icon button's + round, in its slot. Projected content keeps its page's scope, hence ::ng-deep. */ +.actions ::ng-deep > .ac-skeleton--circle { + --ac-skeleton-width: 36px; + margin: 0 var(--ac-space-1); +} .titles { flex-grow: 1; min-width: 0; diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.spec.ts b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.spec.ts new file mode 100644 index 00000000..05f6c334 --- /dev/null +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.spec.ts @@ -0,0 +1,46 @@ +import { TestBed } from '@angular/core/testing'; +import { AcAppBar } from './app-bar'; + +describe('AcAppBar', () => { + const offset = () => document.documentElement.style.getPropertyValue('--ac-scroll-top-offset'); + + it('keeps what the browser scrolls to the top below it while it sticks', () => { + const fixture = TestBed.createComponent(AcAppBar); + fixture.detectChanges(); + expect(offset()).toBe('var(--ac-app-bar-height)'); + fixture.destroy(); + expect(offset()).toBe(''); + }); + + it('scrolls with the page and leaves the offset alone when it does not stick', () => { + const fixture = TestBed.createComponent(AcAppBar); + fixture.componentRef.setInput('sticky', false); + fixture.detectChanges(); + expect((fixture.nativeElement as HTMLElement).style.position).toBe('static'); + expect(offset()).toBe(''); + fixture.componentRef.setInput('sticky', true); + fixture.detectChanges(); + expect(offset()).toBe('var(--ac-app-bar-height)'); + fixture.destroy(); + }); + + it('drops the offset while its page is detached (Decks list kept during a deck) and restores it on return', () => { + const fixture = TestBed.createComponent(AcAppBar); + fixture.detectChanges(); + const el = fixture.nativeElement as HTMLElement; + const parent = el.parentElement as HTMLElement; + // A render happens on each navigation: the next page's, then the list's when it is attached again. + const render = () => { + fixture.changeDetectorRef.markForCheck(); + fixture.detectChanges(); + }; + el.remove(); + render(); + expect(offset()).toBe(''); + parent.append(el); + render(); + expect(offset()).toBe('var(--ac-app-bar-height)'); + fixture.destroy(); + expect(offset()).toBe(''); + }); +}); diff --git a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts index da74d3cc..81a6394a 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts +++ b/plugins/rebuilder/app/src/app/ui/nav/app-bar/app-bar.ts @@ -1,15 +1,46 @@ -import { Component, input } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { Component, DestroyRef, ElementRef, afterEveryRender, effect, inject, input } from '@angular/core'; + +/** + * Sticky bars alive. A bar can be alive out of the document: the Decks list stays mounted, detached, while a deck is + * open (DecksListReuseStrategy). The offset follows the bars in the document, checked after every render. + */ +const stickyBars = new Set(); +let offsetSet = false; + +function syncScrollOffset(root: HTMLElement): void { + const stuck = [...stickyBars].some((bar) => bar.isConnected); + if (stuck === offsetSet) return; + offsetSet = stuck; + if (stuck) root.style.setProperty('--ac-scroll-top-offset', 'var(--ac-app-bar-height)'); + else root.style.removeProperty('--ac-scroll-top-offset'); +} /** * Application bar of compact screens (the site draws its own header above): projected `[leading]`, - * title / subtitle, projected `[actions]`. + * title / subtitle (or a projected `[titles]` when both are empty), projected `[actions]`. + * Sticky under the site header unless `sticky` is false; while stuck, what the browser scrolls to the top (keyboard + * focus, scrollIntoView, anchors) stops below it (`--ac-scroll-top-offset`, design-system/css/base.css). */ @Component({ selector: 'ac-app-bar', templateUrl: './app-bar.html', styleUrl: './app-bar.scss', + host: { '[style.position]': "sticky() ? null : 'static'" }, }) export class AcAppBar { readonly title = input(''); readonly subtitle = input(''); + readonly sticky = input(true); + + constructor() { + const host = inject>(ElementRef).nativeElement; + const root = inject(DOCUMENT).documentElement; + effect(() => (this.sticky() ? stickyBars.add(host) : stickyBars.delete(host))); + afterEveryRender(() => syncScrollOffset(root)); + inject(DestroyRef).onDestroy(() => { + stickyBars.delete(host); + syncScrollOffset(root); + }); + } } diff --git a/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.html b/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.html index 71a22cfc..377701ee 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.html +++ b/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.html @@ -9,7 +9,7 @@ (click)="active.set(t.id)" (keydown.arrowRight)="step($index, 1)" (keydown.arrowLeft)="step($index, -1)" - >{{ short() ? (t.short ?? t.label) : t.label }}@if (t.count !== undefined) { · {{ t.count }}} + >{{ short() ? (t.short ?? t.label) : t.label }}@if (t.count !== undefined) { · {{ t.count ?? '…' }}} } @if (overflow().length) { @let p = picked(); @@ -36,7 +36,7 @@ [cdkMenuItemChecked]="t.id === active()" [class.is-active]="t.id === active()" (cdkMenuItemTriggered)="active.set(t.id)" - >{{ t.label }}@if (t.count !== undefined) { · {{ t.count }}} + >{{ t.label }}@if (t.count !== undefined) { · {{ t.count ?? '…' }}} }
    diff --git a/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.ts b/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.ts index 4c3cdaa6..476cae07 100644 --- a/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.ts +++ b/plugins/rebuilder/app/src/app/ui/nav/tabs/tabs.ts @@ -8,7 +8,8 @@ export interface AcTab { label: string; /** Label in the tab row when `maxTabs` is set (the « Plus » menu shows `label`). */ short?: string; - count?: number; + /** A number after the label; `null` while it loads (« … », at the width of a three-digit number). */ + count?: number | null; } /** diff --git a/plugins/rebuilder/app/src/app/ui/overlay/overlay-container/overlay-container.scss b/plugins/rebuilder/app/src/app/ui/overlay/overlay-container/overlay-container.scss index af17b46c..17876a01 100644 --- a/plugins/rebuilder/app/src/app/ui/overlay/overlay-container/overlay-container.scss +++ b/plugins/rebuilder/app/src/app/ui/overlay/overlay-container/overlay-container.scss @@ -10,7 +10,7 @@ :host(.ac-overlay--dialog) { border-radius: var(--ac-radius-dialog); box-shadow: var(--ac-shadow-dialog); - max-height: min(90vh, 900px); + max-height: min(90dvh, 900px); } :host(.ac-overlay--bare) { background: none; diff --git a/plugins/rebuilder/app/src/app/ui/overlay/overlay.ts b/plugins/rebuilder/app/src/app/ui/overlay/overlay.ts index f19ba510..78ca36df 100644 --- a/plugins/rebuilder/app/src/app/ui/overlay/overlay.ts +++ b/plugins/rebuilder/app/src/app/ui/overlay/overlay.ts @@ -12,7 +12,7 @@ export interface AcOverlayConfig { data?: D; /** Centered window width at ≥ 768 px. */ width?: number; - /** Window height at ≥ 768 px: fit content (default, max 90vh / 900 px) or fill the viewport minus 40 px margins. */ + /** Window height at ≥ 768 px: fit content (default, max 90dvh / 900 px) or fill the viewport minus 40 px margins. */ height?: 'auto' | 'fill'; /** Compact presentation: bottom sheet (default) or full screen page. */ compact?: 'sheet' | 'fullscreen'; diff --git a/plugins/rebuilder/app/src/embed/embed.scss b/plugins/rebuilder/app/src/embed/embed.scss index e304d067..f8290212 100644 --- a/plugins/rebuilder/app/src/embed/embed.scss +++ b/plugins/rebuilder/app/src/embed/embed.scss @@ -83,7 +83,7 @@ } .ac-overlay-pane--dialog { - max-height: min(90vh, 900px); + max-height: min(90dvh, 900px); } .ac-overlay-pane--dialog.ac-overlay-pane--fill { diff --git a/plugins/rebuilder/app/src/locale/messages.en.json b/plugins/rebuilder/app/src/locale/messages.en.json index 7c22c697..9769d37c 100644 --- a/plugins/rebuilder/app/src/locale/messages.en.json +++ b/plugins/rebuilder/app/src/locale/messages.en.json @@ -96,13 +96,10 @@ "deck.page.cardCount": "{$START_TAG_STRONG}{$INTERPOLATION}{$CLOSE_TAG_STRONG} cards", "deck.page.groupBy": "Group by", "deck.page.noCards": "This deck has no cards yet.", - "deck.page.heroArt": "Hero art", - "deck.page.createdOn": "created on {$INTERPOLATION}", "deck.page.copyList": "Copy decklist", "deck.page.duplicate": "Duplicate", "deck.page.delete": "Delete", "deck.page.bottomNav": "Deck views", - "deck.page.tabCards": "Cards", "deck.page.byType": "By type", "deck.page.byCost": "By cost", "deck.page.public": "Public", @@ -151,7 +148,7 @@ "decks.page.sessionExpired": "Session expired: the account decks could not be loaded (401).", "decks.page.accountDecksUnavailable": "Account decks unavailable.", "decks.page.communityError": "Could not load the public decks.", - "decks.page.tabMine": "My decks · {$count}", + "decks.page.tabMine": "My decks", "decks.page.tabCommunity": "Community", "decks.nav.mine": "My decks", "decks.nav.contest": "Contest", @@ -197,19 +194,16 @@ "editor.bottomNav": "Deck editor", "editor.public": "Public", "editor.private": "Private", - "editor.myDecks": "My decks", - "editor.edit": "Edit", "editor.share": "Share", - "editor.done": "Done", "editor.hideDeck": "Hide the deck", - "editor.deckTab": "Deck · {$INTERPOLATION} cards", + "editor.deckTab": "Deck · {$START_TAG_SPAN}{$INTERPOLATION}{$CLOSE_TAG_SPAN} cards", + "decks.page.countLoading": "Loading…", "editor.showDeckLegal": "Show the deck: {$count} cards, valid", "editor.showDeckIssues": "Show the deck: {$count} cards, to fix", "editor.savingFirst": "Saving…", "editor.share.saved": "Deck saved: the link shows the latest version.", "editor.share.savedToAccount": "Deck saved to your account: you can share it.", "editor.stopped.share": "The share window will open once the deck is saved.", - "editor.stopped.done": "The deck page will open once the deck is saved.", "editor.signInToShare.title": "Sign in to share", "editor.signInToShare.body": "To share this deck and save it on the server, sign in.", "editor.signInToShare.note": "Once you are signed in, the deck is saved to your account and the share link opens.", @@ -217,7 +211,6 @@ "editor.signInToShare.signIn": "Sign in", "core.deckStore.moveFailed": "Could not save the deck to your account (HTTP {$status}).", "editor.search": "Search", - "editor.viewDeck": "View deck", "editor.preview": "Preview", "editor.deck": "Deck", "search.store.timeout": "The search took too long. Narrow the filters (set, cost, effect) and try again.", @@ -253,13 +246,23 @@ "search.card.total": "{$count} cards", "search.card.totalOne": "1 card", "search.effect.intro": "A card matches if it has {$START_TAG_STRONG}at least one{$CLOSE_TAG_STRONG} of the values of each filled criterion.", - "search.effect.when": "When {$START_TAG_SPAN}· trigger{$CLOSE_TAG_SPAN}", - "search.effect.addTrigger": "Add a trigger…", - "search.effect.if": "If {$START_TAG_SPAN}· condition{$CLOSE_TAG_SPAN}", - "search.effect.addCondition": "Add a condition…", - "search.effect.anyCondition": "Any · add a condition…", - "search.effect.then": "Then {$START_TAG_SPAN}· effect{$CLOSE_TAG_SPAN}", - "search.effect.addEffect": "Add an effect…", + "search.effect.whenLabel": "When", + "search.effect.triggerSub": "trigger", + "search.effect.anyTrigger": "Any trigger", + "search.effect.searchTrigger": "Search a trigger…", + "search.effect.ifLabel": "If", + "search.effect.conditionSub": "condition", + "search.effect.searchCondition": "Search a condition…", + "search.effect.thenLabel": "Then", + "search.effect.effectSub": "effect", + "search.effect.anyEffect": "Any effect", + "search.effect.searchEffect": "Search an effect…", + "search.effect.uncheckAll": "Uncheck all", + "search.effect.done": "Done", + "search.effect.addTrigger": "Add a trigger", + "search.effect.addCondition": "Add a condition", + "search.effect.anyCondition": "Any condition", + "search.effect.addEffect": "Add an effect", "search.effect.loadError": "Could not load abilities from the cards API.", "search.effect.cancel": "Cancel", "search.effect.apply": "Apply", @@ -373,6 +376,7 @@ "ui.deckSummary.hero": "Hero", "ui.deckSummary.heroName": "Hero: {$INTERPOLATION}", "ui.deckSummary.settings": "Deck settings: hero, format, visibility", + "ui.deckSummary.changeHero": "Change hero", "ui.deckSummary.cards": "{VAR_PLURAL, plural, =1 {card} other {cards}}", "ui.deckSummary.valid": "Valid", "ui.deckSummary.noHero": "No hero", @@ -389,6 +393,7 @@ "ui.effectSummary.empty": "No criteria", "ui.effectSummary.title": "Effect 1", "ui.effectSummary.edit": "Edit {$title}", + "ui.effectSummary.duplicate": "Duplicate {$title}", "ui.effectSummary.remove": "Delete {$title}", "ui.effectSummary.when": "When", "ui.effectSummary.if": "If", @@ -673,8 +678,8 @@ "ui.cardTile.unfavorite": "Remove {$name} from favorites", "ui.cardTile.favorite": "Add {$name} to favorites", "altArt.tile": "Illustration {$n}", - "altArt.marker": "Copy {$n}", "altArt.saveError": "Could not save your choice.", + "altArt.notEnoughCopies": "You don’t have enough copies of this alt art.", "core.violation.sets": "Set missing from BGA", "core.violation.frontierUniques": "Off the Frontier list", "search.filters.costRelation.any": "Cost management", @@ -707,21 +712,17 @@ "shared.heroPicker.altArts": "Alt arts", "shared.heroPicker.serialized": "Serialized", "editor.altArt.stockWarn": "Only {$owned} of the {$needed} copies used in this deck are owned for this illustration: on Board Game Arena, the others will show the base art.", - "cardZoom.chooseIllustration": "Choose illustration", - "cardZoom.confirmIllustration": "Use this illustration", "editor.tokenArts.title": "Token illustrations", - "editor.tokenArts.warning": "Token illustration preferences are shared across every deck: they aren't part of this deck's card list.", "editor.tokenArts.empty": "No token has more than one illustration.", "editor.tokenArts.loading": "Loading…", "editor.tokenArts.close": "Close", - "editor.tokenArts.open": "Choose token illustrations", + "editor.tokenArts.open": "Token illustrations", "editor.toast.undo": "Undo", "search.filters.power": "Power", "search.filters.rarity.unique": "Unique", "search.filters.legalOnly": "Legal in {$format}", "search.filters.format": "Format", "decks.page.mineLoading": "Loading your decks… This can take a while if you have many.", - "decks.page.tabMineLoading": "My decks · …", "editor.hand": "Starting hand", "editor.handShort": "Hand", "title.editorHand": "Starting hand", @@ -740,6 +741,77 @@ "deck.image.open": "Open in a new tab", "deck.actions.imageCopy": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Copy as image", "deck.actions.imageSave": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Save the image", - "deck.actions.imageOpen": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Open the image in a new tab" + "deck.actions.imageOpen": "{$START_TAG_AC_ICON}{$CLOSE_TAG_AC_ICON}Open the image in a new tab", + "deck.bar.total": "{VAR_PLURAL, plural, =1 {1 card} other {{INTERPOLATION} cards}}", + "deck.bar.power": "Total power", + "deck.bar.illegalOne": "Not legal · {$count} problem", + "deck.bar.illegalMany": "Not legal · {$count} problems", + "deck.bar.legalDetails": "Legal: see the format rules", + "deck.board.uniques": "Uniques", + "deck.bar.edit": "Edit", + "deck.page.tabPreview": "Preview", + "editor.imageOpen": "Open the image in a new tab", + "editor.boardHint": "Hover a pile to change its number of copies.", + "editor.stackPrints": "Group illustrations", + "editor.tokenArts.short": "Token illustrations", + "editor.moreActions": "More actions", + "deck.bar.rarities": "Rarities", + "ui.rarityLimits.label": "Rarities capped by the format", + "ui.rarityLimits.item": "{$rarity}: {$count} of {$limit} at most", + "ui.cardPile.zoomOne": "Enlarge {$name}, {$count} copy", + "ui.cardPile.zoom": "Enlarge {$name}, {$count} copies", + "ui.costCurve.title": "Cost curve", + "ui.costCurve.main": "Hand", + "ui.costCurve.reserve": "Reserve", + "ui.costCurve.bucket": "Cost {$cost}: {$main} in hand, {$reserve} in reserve", + "ui.costCurve.label": "Cost curve: hand and reserve", + "search.filters.favoritesLimited": "In favorites, only the faction, set and rarity filter the cards.", + "editor.overview.title": "Deck summary", + "editor.overview.rarities": "Rarities · {$format}", + "editor.overview.rules": "Format rules", + "editor.overview.composition": "Composition", + "editor.overview.cards": "{VAR_PLURAL, plural, =1 {card} other {cards}}", + "editor.overview.openDeck": "Open the Deck tab", + "editor.changeHeroOf": "Change hero ({$name})", + "editor.overviewOf": "Deck summary {$name}", + "editor.altArtPicker.hint": "Choose the illustration of each card", + "editor.altArtPicker.keysHint": "Click a box above an illustration to give it that card.", + "editor.altArtPicker.defaultsNote": "These choices only apply to this deck: your default alt arts stay as they are.", + "editor.altArtPicker.deckTitle": "What the deck uses", + "editor.altArtPicker.card1": "1st card", + "editor.altArtPicker.card2": "2nd card", + "editor.altArtPicker.card3": "3rd card", + "editor.altArtPicker.suffix1": "st", + "editor.altArtPicker.suffix2": "nd", + "editor.altArtPicker.suffix3": "rd", + "editor.altArtPicker.cardWord": "card", + "editor.altArtPicker.menu": "Cards of {$label}", + "editor.altArtPicker.ownedMany": "{$n} owned", + "editor.altArtPicker.ownedOne": "1 owned", + "editor.altArtPicker.summaryNone": "This deck has no copy of this card yet.", + "editor.altArtPicker.summaryOne": "With 1 copy, the deck takes the illustration of the 1st card.", + "editor.altArtPicker.summaryTwo": "With 2 copies, the deck takes the illustrations of the 1st and 2nd cards.", + "editor.altArtPicker.summaryAll": "With {$n} copies, the deck takes the illustrations of all three cards.", + "editor.altArtPicker.available": "Available illustrations", + "editor.altArtPicker.settings": "Edit my default alt arts", + "editor.altArtPicker.defaults": "Default alt arts", + "editor.altArtPicker.close": "Close", + "editor.altArtPicker.unlimited": "Unlimited", + "editor.altArtPicker.notOwned": "Not owned", + "editor.altArtPicker.copiesMany": "{$n} copies in this deck", + "editor.altArtPicker.copiesOne": "1 copy in this deck", + "editor.altArtDefaults.open": "Apply my default alt arts", + "editor.altArtDefaults.short": "Default alt arts", + "editor.altArtDefaults.title": "Apply your default alt arts?", + "editor.altArtDefaults.message": "Every card of the deck, and the hero, takes your default alt arts. The illustrations chosen for this deck are replaced.", + "editor.altArtDefaults.confirm": "Apply", + "editor.altArtDefaults.applied": "Default alt arts applied.", + "editor.altArtDefaults.unchanged": "The deck already has your default alt arts.", + "editor.tokenArts.intro": "Choose one illustration per token: it applies to all its copies, in all your decks.", + "editor.tokenArts.token": "Token {$n}", + "editor.tokenArts.notOwned": "Illustration {$n} (not owned)", + "editor.tokenArts.available": "{$n} illustrations available", + "cardZoom.chooseIllustrations": "Choose the illustrations", + "ui.cardTile.illustrate": "Choose the illustrations of {$name}" } } diff --git a/plugins/rebuilder/app/src/main.ts b/plugins/rebuilder/app/src/main.ts index 62ba0ab0..6c2eb093 100644 --- a/plugins/rebuilder/app/src/main.ts +++ b/plugins/rebuilder/app/src/main.ts @@ -7,6 +7,7 @@ import { setAssetBase } from './app/core/asset-url'; import { setUiLocale } from './app/core/i18n'; import { setContentLocale } from './app/core/locale'; import { readHost } from './app/embed/host'; +import { prefetchPage } from './app/embed/prefetch'; import { environment } from './environments/environment'; const host = readHost(); @@ -21,6 +22,8 @@ env.siteUrl = host.baseUrl ?? env.siteUrl; env.siteCsrf = host.csrf ?? env.siteCsrf; setAssetBase(host.page.assetsUrl); setContentLocale(host.lang); +// The page's data, requested while the app's modules download (embed/prefetch.ts). +prefetchPage(host); setUiLocale(host.lang) .then(() => import('./app/embed/bootstrap')) diff --git a/plugins/rebuilder/e2e/decks.ts b/plugins/rebuilder/e2e/decks.ts new file mode 100644 index 00000000..b15a4e5e --- /dev/null +++ b/plugins/rebuilder/e2e/decks.ts @@ -0,0 +1,30 @@ +// Decks of the signed-in account for the Re:Builder e2e tests and tests/e2e/loading-timeline.ts (Node runs this file +// with type stripping: type-only imports, explicit extensions). +import type { Page } from '@playwright/test'; + +/** Creates a deck of the signed-in account through the site's relay, from an SPA page (9 cards: not legal, the API says + * why), plus `extra` references, `copies` of each. */ +export async function createServerDeck(page: Page, name: string, extra: string[] = [], copies = 1): Promise { + return page.evaluate(async ([deckName, more, n]) => { + const host = (window as unknown as { AlteredCore: { csrf: string; services: { decks: string } } }).AlteredCore; + const res = await fetch(`${host.services.decks}/api/decks`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': host.csrf }, + body: JSON.stringify({ + name: deckName, + description: 'Première ligne\nDeuxième ligne', + format: 'standard', + isPublic: false, + deckCards: [ + { cardReference: 'ALT_CORE_B_AX_01_C', quantity: 1 }, + { cardReference: 'ALT_CORE_B_AX_08_C', quantity: 3 }, + { cardReference: 'ALT_CORE_B_AX_09_C', quantity: 3 }, + { cardReference: 'ALT_CORE_B_AX_10_C', quantity: 3 }, + ...more.map((cardReference) => ({ cardReference, quantity: n })), + ], + }), + }); + if (res.status !== 201) throw new Error(`deck creation: HTTP ${res.status}`); + return ((await res.json()) as { id: string }).id; + }, [name, extra, copies] as const); +} diff --git a/plugins/rebuilder/e2e/loading-budgets.json b/plugins/rebuilder/e2e/loading-budgets.json new file mode 100644 index 00000000..f093ea11 --- /dev/null +++ b/plugins/rebuilder/e2e/loading-budgets.json @@ -0,0 +1,5 @@ +{ + "_doc": "Largest CLS each screen may reach in loading.spec.ts (key: [@]//), measured with the « phone » profile. Default 0.01. Known shifts get a budget a little above today's value and a reason; lower a budget when a fix lands, never raise it to make a test pass.", + "default": 0.01, + "screens": {} +} diff --git a/plugins/rebuilder/e2e/loading.spec.ts b/plugins/rebuilder/e2e/loading.spec.ts new file mode 100644 index 00000000..b82701df --- /dev/null +++ b/plugins/rebuilder/e2e/loading.spec.ts @@ -0,0 +1,62 @@ +import { expect, login, setBeta, test, type Page } from '../../../tests/e2e/fixtures'; +import { clsWarning, emptyCache, loadingProblems, loadingReport, throttle, watchLoading } from '../../../tests/e2e/loading'; +import budgets from './loading-budgets.json'; +import { createServerDeck } from './decks'; +import { FORBIDDEN_TEXTS, SCREENS } from './screens'; + +/** + * Loading of every Re:Builder screen on a slow phone network (tests/e2e/loading.ts), first and second visit: the + * server's skeleton stays until the app draws its first screen, the app never shows unstyled, the skeleton keeps the + * site's font, no « Chargement… » title, and the layout shifts (CLS) stay within the screen's budget + * (loading-budgets.json: 0.01, or today's value for a known shift). A CLS over budget is a warning, not a failure (it + * varies from one CI run to the next): a screenshot with the boxes that moved, posted by the CI as a pull request + * comment (tests/e2e/loading.ts, clsWarning). A failure prints what showed; + * `node tests/e2e/loading-timeline.ts ` shows it frame by frame. On the desktop project, a second visit at + * 1280 and 1024 px too: laptop and tablet widths, where the deck bar and the editor change their layout + * (`npm run loading:matrix` for every size). + */ +type Budgets = { default: number; screens: Record }; + +/** Waits until the page is done: placeholder gone, network quiet (bounded: card art keeps coming on a slow network). */ +async function settle(page: Page): Promise { + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0, { timeout: 30_000 }); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => undefined); + await page.waitForTimeout(1000); +} + +test.beforeEach(async ({ page }) => setBeta(page, true)); + +test.describe('ReBuilder in the shell · loading on a slow phone network', () => { + const runs = [{ visit: 'first' }, { visit: 'second' }, { visit: 'second', width: 1280 }, { visit: 'second', width: 1024 }] as const; + for (const screen of SCREENS) { + for (const run of runs) { + const { visit } = run; + const width = 'width' in run ? run.width : null; + test(`${screen.name}, ${visit} visit${width ? ` at ${width} px` : ''}: skeleton until the first screen, styled, no layout shift`, { tag: width ? [] : '@mobile' }, async ({ page }, testInfo) => { + test.skip(width !== null && testInfo.project.name !== 'desktop', 'the desktop project only'); + if (width) await page.setViewportSize({ width, height: 800 }); + await login(page, 'alice', '/pages/decks?lang=fr'); + const id = screen.deck ? await createServerDeck(page, `E2E loading ${Date.now()}`) : ''; + const path = screen.path(id); + const url = `${path}${path.includes('?') ? '&' : '?'}lang=fr`; + if (visit === 'second') { + await page.goto(url); + await settle(page); + } else { + await emptyCache(page); + } + await watchLoading(page, FORBIDDEN_TEXTS); + await throttle(page, 'phone'); + await page.goto(url); + await settle(page); + + const report = await loadingReport(page); + const budget = (budgets as Budgets).screens[`${testInfo.project.name}${width ? `@${width}` : ''}/${screen.key}/${visit}`]; + await testInfo.attach('loading-report', { body: JSON.stringify(report, null, 2), contentType: 'application/json' }); + if (budget) testInfo.annotations.push({ type: 'known shift', description: `CLS ${report.cls} (budget ${budget.max}): ${budget.why}` }); + await clsWarning(page, testInfo, report, budget?.max ?? (budgets as Budgets).default); + expect(loadingProblems(report, Infinity)).toEqual([]); + }); + } + } +}); diff --git a/plugins/rebuilder/e2e/rebuilder.spec.ts b/plugins/rebuilder/e2e/rebuilder.spec.ts index 588f4034..71a53b96 100644 --- a/plugins/rebuilder/e2e/rebuilder.spec.ts +++ b/plugins/rebuilder/e2e/rebuilder.spec.ts @@ -1,5 +1,7 @@ import { deflateRawSync } from 'node:zlib'; import { evidence, expect, login, setBeta, test, type Page } from '../../../tests/e2e/fixtures'; +import { emptyCache } from '../../../tests/e2e/loading'; +import { createServerDeck } from './decks'; /** * Re:Builder's decks section (plugin `rebuilder`): with « Beta Deckbuilder » on (cookie ac_beta), the shell serves it @@ -11,18 +13,20 @@ import { evidence, expect, login, setBeta, test, type Page } from '../../../test /** Labels in both site languages (the interface follows AlteredCore.lang). */ const LABELS = { fr: { - newDeck: 'Nouveau deck', deckName: 'Nom du deck', create: 'Créer le deck', search: 'Recherche', viewDeck: 'Voir le deck', cancel: 'Annuler', + newDeck: 'Nouveau deck', deckName: 'Nom du deck', create: 'Créer le deck', search: 'Recherche', viewDeck: 'Aperçu', cancel: 'Annuler', deckNav: 'Éditeur de deck', decksNav: 'Decks', myDecks: 'Mes decks', community: 'Communauté', communityDecks: 'Decks de la communauté', sortBy: 'Trier par', guest: 'Mode invité', rarity: 'Rareté', advanced: 'Recherche avancée', clearAll: 'Tout effacer', }, en: { - newDeck: 'New deck', deckName: 'Deck name', create: 'Create deck', search: 'Search', viewDeck: 'View deck', cancel: 'Cancel', + newDeck: 'New deck', deckName: 'Deck name', create: 'Create deck', search: 'Search', viewDeck: 'Preview', cancel: 'Cancel', deckNav: 'Deck editor', decksNav: 'Decks', myDecks: 'My decks', community: 'Community', communityDecks: 'Community decks', sortBy: 'Sort by', guest: 'Guest mode', rarity: 'Rarity', advanced: 'Advanced search', clearAll: 'Clear all', }, }; type Lang = keyof typeof LABELS; const FR = LABELS.fr; +/** The deck page's « Modifier » (deck bar, from 768 px) or « Modifier le deck » (app bar, phones). */ +const EDIT_DECK = /^Modifier( le deck)?$/; /** Opens the new-deck window, picks the first hero, names the deck and creates it. */ async function createDeck(page: Page, name: string, lang: Lang = 'fr'): Promise { @@ -38,7 +42,7 @@ async function createDeck(page: Page, name: string, lang: Lang = 'fr'): Promise< await expect(dialog).toBeHidden(); } -/** « Recherche / Voir le deck » switch (desktop) or bottom navigation (mobile), in `lang`. */ +/** « Recherche / Aperçu » switch (desktop) or bottom navigation (mobile), in `lang`. */ async function expectEditorLabels(page: Page, compact: boolean, lang: Lang): Promise { const l = LABELS[lang]; if (compact) await expect(page.getByRole('navigation', { name: l.deckNav }).getByRole('link', { name: l.search })).toBeVisible(); @@ -69,7 +73,7 @@ async function expectDeckCount(page: Page, compact: boolean, count: number): Pro if (compact) { await expect(page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ })).toContainText(String(count)); } else { - await expect(page.locator('app-deck-panel ac-deck-summary')).toContainText(String(count)); + await expect(page.locator('app-deck-bar .meta strong')).toHaveText(new RegExp(`^${count} cartes?$`)); } } @@ -78,13 +82,23 @@ const NEW_DECK = '/pages/deckbuilder'; const DECK = (id: string) => `/pages/deck?id=${id}`; const EDITOR = (id: string) => `/pages/deckbuilder?id=${id}`; /** The page URL is `path`, optionally followed by more query parameters (`&lang=…`) when `more`. */ +/** + * The id of the account deck the editor opened. Read from the URL rather than from the POST's body: Chrome may have + * dropped that body (the editor's images fill its buffer) by the time the test asks for it. + */ +async function editorDeckId(page: Page): Promise { + await expect(page).toHaveURL(/\/pages\/deckbuilder\?id=(?!guest-)[^&]+$/); + return new URL(page.url()).searchParams.get('id')!; +} + const escape = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const at = (path: string, more = false) => new RegExp(`${escape(path)}${more ? '(&|$)' : '$'}`); test.beforeEach(async ({ page }) => setBeta(page, true)); test.describe('ReBuilder in the shell · signed in', () => { - test('creates a deck from the decks list, edits it, finds it in the list and opens its page', async ({ page, compact }, testInfo) => { + test('creates a deck from the decks list, edits it, finds it in the list and opens its page', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E ${testInfo.project.name} ${Date.now()}`; // The browser talks to the site only (relay) for decks, and never sends a bearer token. const leaks: string[] = []; @@ -100,10 +114,8 @@ test.describe('ReBuilder in the shell · signed in', () => { await page.getByRole('button', { name: FR.newDeck }).first().click(); const created = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/v1/services/decks/api/decks'); await createDeck(page, name); - const res = await created; - expect(res.status()).toBe(201); - const deck = (await res.json()) as { id: string }; - await expect(page).toHaveURL(at(EDITOR(deck.id))); + expect((await created).status()).toBe(201); + const deck = { id: await editorDeckId(page) }; // The save of the final state (hero + 2 cards): with the 400 ms autosave delay, a slow runner may save each card apart. const saved = page.waitForResponse((r) => { @@ -121,22 +133,34 @@ test.describe('ReBuilder in the shell · signed in', () => { await expect(page).toHaveURL(at(EDITOR(deck.id))); await expectDeckCount(page, compact, 2); if (!compact) { - await expect(page.locator('ac-editable-title input')).toHaveValue(name); + await expect(page.locator('app-deck-bar h1')).toHaveText(name); for (const card of cards) await expect(page.locator('app-deck-panel')).toContainText(card.replace(/ ×.*$/, '')); } - // Re:Builder's list shows it (account decks through the relay); its card opens the deck page. + // Re:Builder's list shows it (account decks through the relay); its card opens the editor on « Aperçu »: the deck + // board from 768 px, the deck preview on phones. await page.goto(DECKS); const item = page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name }); await expect(item).toBeVisible(); await evidence(page, testInfo, '03-listed'); await item.getByRole('link').first().click(); + await expect(page).toHaveURL(at(`${EDITOR(deck.id)}&view=apercu`)); + // Phones: the deck page's actions (image, copy, duplicate, delete) are in the app bar's « ⋯ ». + if (compact) { + await expect(page.locator('app-deck-preview')).toBeVisible(); + await page.getByRole('button', { name: 'Plus d’actions' }).click(); + await expect(page.getByRole('menuitem', { name: 'Supprimer' })).toBeVisible(); + await page.keyboard.press('Escape'); + } + else await expect(page.locator('app-deck-board ac-card-pile')).toHaveCount(2); + await evidence(page, testInfo, '04-editor-board'); + await page.goto(DECK(deck.id)); await expect(page).toHaveURL(at(DECK(deck.id))); await expect(page.locator('app-deck-page')).toContainText(name); await evidence(page, testInfo, '04-deck-page'); // Alice's own deck: she can edit it. const deckPage = page.locator('app-deck-page'); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' })).toBeVisible(); + await expect(deckPage.getByRole('button', { name: EDIT_DECK })).toBeVisible(); // Same decks API: with the beta off, the site's own list sees the deck too, and links to the same editor URL. await setBeta(page, false); @@ -151,7 +175,104 @@ test.describe('ReBuilder in the shell · signed in', () => { expect(leaks).toEqual([]); }); - test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', async ({ page, compact }, testInfo) => { + test('shows the page skeleton while the scripts load, then the deck skeleton while the deck loads, never an empty deck', { tag: '@mobile' }, async ({ page, compact }) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + const name = `E2E skeleton ${Date.now()}`; + await createDeck(page, name); + const id = await editorDeckId(page); + + // The app's scripts and the deck are held: the server's skeleton stands for the page meanwhile. + let releaseScripts!: () => void; + const scripts = new Promise((resolve) => (releaseScripts = resolve)); + await page.route(/\/plugins\/rebuilder\/dist\/browser\/main-[^/]+\.js/, async (route) => { + await scripts; + await route.continue(); + }); + let releaseDeck!: () => void; + const deckAnswer = new Promise((resolve) => (releaseDeck = resolve)); + await page.route(new RegExp(`/api/v1/services/decks/api/decks/${id}(\\?|$)`), async (route) => { + if (route.request().method() === 'GET') await deckAnswer; + await route.continue(); + }); + // The editor's scripts are in this tab's memory cache since the deck was created: without the cache, they go + // through the routes above. A module script holds DOMContentLoaded: wait for the response only. + await emptyCache(page); + await page.goto(`${EDITOR(id)}&view=apercu&lang=fr`, { waitUntil: 'commit' }); + const placeholder = page.locator('.ac-spa-placeholder'); + await expect(placeholder.locator('.ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + await expect(placeholder.getByRole('status')).toHaveText('Chargement…'); + + // The app draws the editor: the deck skeleton replaces the server's, the store's empty deck never shows. + releaseScripts(); + await expect(placeholder).toHaveCount(0); + const deckView = page.locator('app-deck-board, app-deck-preview'); + await expect(deckView.locator('.ac-skeleton').first()).toBeVisible(); + await expect(page.getByText('Deck vide')).toHaveCount(0); + await expect(page.getByText(/^0 cartes?/)).toHaveCount(0); + // Phones: the app bar in skeleton in its final layout (hero, name over the rarities), not « Chargement du deck… ». + const bar = page.locator('ac-app-bar'); + if (compact) { + await expect(bar.locator('.bar-deck--pending .ac-skeleton')).toHaveCount(4); + await expect(bar).not.toContainText('Chargement'); + } + + releaseDeck(); + await expect(deckView.locator('.ac-skeleton')).toHaveCount(0); + await expect(page.getByText('Deck vide')).toBeVisible(); + if (compact) { + await expect(bar.locator('.bar-deck-name')).toHaveText(name); + await expect(bar.locator('.ac-skeleton')).toHaveCount(0); + } + }); + + for (const { tab, short, endpoint, shot } of [ + { tab: 'Collection physique', short: 'Collection', endpoint: 'collection-search', shot: '15-collection-search' }, + { tab: 'Propriété numérique', short: null, endpoint: 'ownership-search', shot: '15-owned-search' }, + ]) { + test(`lists « ${tab} » with every rarity (Uniques included), type and set of the default filters`, async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E ${endpoint} ${Date.now()}`); + const request = page.waitForRequest((r) => r.url().includes(`/papi/core-altered-cards/${endpoint}`)); + if (compact && !short) { + // Compact screens: the last sources are in the « Plus » menu of the tabs. + await page.getByRole('tab', { name: 'Plus' }).click(); + await page.getByRole('menuitemradio', { name: tab }).click(); + } else { + await page.getByRole('tab', { name: compact && short ? short : tab, exact: true }).click(); + } + // The Axiom cards of the stack's mock (docker/stack/ownership-mock): commons, rares, a Unique, two sets. + const tiles = page.locator('app-search-results ac-card-tile'); + await expect(tiles).toHaveCount(5); + // The rare and the Unique (named « La machine dans la glace » by the cards API). + await expect(tiles.and(page.locator('[aria-label*="La Machine dans la Glace" i]'))).toHaveCount(2); + // Every rarity chosen, Unique included: no rarity filter, so the Uniques come too. Lists in their array form (`cardType[]=…`). + const query = new URL((await request).url()).searchParams; + expect(query.getAll('rarity[]')).toEqual([]); + expect(query.getAll('cardType[]').length).toBeGreaterThan(1); + if (!compact) await expect(page.getByRole('group', { name: 'Rareté' }).getByRole('button', { name: 'U', exact: true })).toHaveAttribute('aria-pressed', 'true'); + // The APIs give the Unique's costs only: its stats and effects come from the cards API (ALT_ALIZE_B_AX_32_U_2). + const unique = tiles.locator('ac-unique-card'); + await expect(unique.locator('.main-text')).toContainText('Au Crépuscule'); + await expect(unique.getByRole('list', { name: 'Puissances' }).getByRole('listitem')).toHaveText(['3', '2', '2']); + await unique.evaluate((el) => el.scrollIntoView({ block: 'center', behavior: 'instant' })); + await evidence(page, testInfo, shot); + }); + } + + test('shows each token large with its illustrations, one of them chosen for every deck', async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E token arts ${Date.now()}`); + if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); + await page.getByRole('button', { name: compact ? 'Illustrations des jetons' : 'Arts des jetons' }).click(); + const dialog = page.getByRole('dialog', { name: 'Illustrations des jetons' }); + const tokens = dialog.getByRole('radiogroup'); + await expect(tokens.first()).toBeVisible(); + // One illustration chosen a token. + for (const token of await tokens.all()) await expect(token.getByRole('radio', { checked: true })).toHaveCount(1); + await evidence(page, testInfo, '16-token-arts'); + }); + + test('deletes a deck after a confirmation in a design-system dialog, not the browser’s', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E delete ${testInfo.project.name} ${Date.now()}`; const browserDialogs: string[] = []; page.on('dialog', (d) => { @@ -166,7 +287,7 @@ test.describe('ReBuilder in the shell · signed in', () => { const deckPage = page.locator('app-deck-page'); await expect(deckPage).toContainText(name); - // Desktop: « Supprimer » in the side panel; mobile: in the « Plus d’actions » sheet. + // Desktop: « Supprimer » in the deck bar; mobile: in the « Plus d’actions » sheet. const askDelete = async () => { if (compact) { await deckPage.getByRole('button', { name: /^Plus d’actions/ }).click(); @@ -194,7 +315,7 @@ test.describe('ReBuilder in the shell · signed in', () => { expect(browserDialogs).toEqual([]); }); - test('touch: every control of the windows is at least --ac-hit-min (44 px) on each side', async ({ page, compact }, testInfo) => { + test('touch: every control of the windows is at least --ac-hit-min (44 px) on each side', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { test.skip(!compact, 'touch density: mobile project'); await login(page, 'alice', `${NEW_DECK}?lang=fr`); const name = `E2E touch ${testInfo.project.name} ${Date.now()}`; @@ -289,7 +410,7 @@ test.describe('ReBuilder in the shell · signed in', () => { const deckPage = page.locator('app-deck-page'); await expect(deckPage.locator('ac-card-art').first()).toBeVisible(); await expect(deckPage.getByRole('button', { name: /Plus d’actions|Dupliquer/ }).first()).toBeVisible(); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' })).toHaveCount(0); + await expect(deckPage.getByRole('button', { name: EDIT_DECK })).toHaveCount(0); await expect(deckPage.getByRole('button', { name: 'Supprimer' })).toHaveCount(0); await evidence(page, testInfo, '05b-community-deck'); }); @@ -432,7 +553,7 @@ test.describe('ReBuilder in the shell · languages', () => { // Decks list, community tab, editor search and deck summary, in each site language; the switch // goes through the site's ?lang= (a change reloads the page, translations load before the app). for (const lang of ['en', 'fr'] as const) { - test(`shows its interface in the site language (${lang})`, async ({ page, compact }, testInfo) => { + test(`shows its interface in the site language (${lang})`, { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const l = LABELS[lang]; const other = LABELS[lang === 'en' ? 'fr' : 'en']; await page.goto(`${DECKS}?tab=mine&lang=${lang}`); @@ -463,6 +584,20 @@ test.describe('ReBuilder in the shell · languages', () => { }); test.describe('ReBuilder in the shell · Uniques search', () => { + test('shows the Uniques at most 7 per row, larger, on a very wide window', async ({ page, compact }) => { + test.skip(compact, 'a desktop width'); + await page.setViewportSize({ width: 2560, height: 1440 }); + await page.goto(`${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E uniques grid ${Date.now()}`); + const columns = () => page.locator('ac-virtual-grid.grid').evaluate((g) => getComputedStyle(g).gridTemplateColumns.split(' ').length); + await expect(page.locator('ac-card-tile').first()).toBeVisible(); + const all = await columns(); + await page.getByRole('tab', { name: 'Uniques' }).click(); + await expect(page.locator('ac-unique-card').first()).toBeVisible(); + expect(all).toBeGreaterThan(7); + await expect.poll(columns).toBe(7); + }); + test('ORs the values of a criterion and ANDs the effects, on the Uniques search API', async ({ page, compact }, testInfo) => { test.skip(compact, 'the effect filter is checked on the desktop panel'); await page.goto(`${NEW_DECK}?lang=fr`); @@ -475,13 +610,17 @@ test.describe('ReBuilder in the shell · Uniques search', () => { await page.getByRole('tab', { name: 'Uniques' }).click(); await expect(page.locator('ac-unique-card').first()).toBeVisible(); - /** Picks `text` (exact) in the criterion `index` of the open effect window. */ + /** Checks `text` (exact) in the criterion `index` of the open effect window: its list stays open. */ const pick = async (index: number, text: string) => { const dialog = page.getByRole('dialog'); - await dialog.getByRole('combobox').nth(index).click(); - await page.locator('.cdk-overlay-container .panel input').fill(text); - await page.getByRole('option', { name: text, exact: true }).click(); - await expect(dialog.locator('ac-combobox').nth(index).locator('.picked').filter({ hasText: text })).toBeVisible(); + // Anywhere on the criterion's card opens it, its values too (« Tous les déclencheurs »…), not only its title. + const card = dialog.locator('.card').nth(index); + const box = (await card.boundingBox())!; + await card.click({ position: { x: 24, y: box.height - 12 } }); + await expect(card.locator('.card-head')).toHaveAttribute('aria-pressed', 'true'); + await dialog.locator('ac-check-list input[type=search]').fill(text); + await dialog.getByRole('checkbox', { name: text, exact: true }).check(); + await expect(dialog.locator('.card').nth(index).locator('ac-or-values .value').filter({ hasText: text })).toBeVisible(); }; const editEffect = async (open: () => Promise, picks: [number, string][]) => { await open(); @@ -514,9 +653,16 @@ test.describe('ReBuilder in the shell · Uniques search', () => { // The count shown is the API's for that request. await expect(page.locator('.results .total, .results').getByText(new RegExp(`^${both.toLocaleString('fr-FR').replace(/\s/g, '\\s')} cartes?$`))).toBeVisible(); await evidence(page, testInfo, '11-uniques-effects'); + + // « Dupliquer » puts a copy of the effect right after it. + const summaries = page.locator('ac-effect-summary'); + const firstText = await summaries.nth(0).locator('p').allTextContents(); + await summaries.nth(0).getByRole('button', { name: 'Dupliquer Effet 1' }).click(); + await expect(summaries).toHaveCount(3); + expect(await summaries.nth(1).locator('p').allTextContents()).toEqual(firstText); }); - test('narrows each effect list to the values that still give a card, for the effect being edited', async ({ page, compact }, testInfo) => { + test('narrows each effect list to the values that still give a card, for the effect being edited', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { await page.goto(`${NEW_DECK}?lang=fr`); await createDeck(page, `E2E uniques narrowing ${Date.now()}`); await expect(page.locator('ac-card-tile').first()).toBeVisible(); @@ -527,15 +673,23 @@ test.describe('ReBuilder in the shell · Uniques search', () => { const narrowing = (editing: string) => page.waitForResponse((r) => r.url().includes('/api/v2/effects/filtered') && new URL(r.url()).searchParams.get('editing') === editing); const idGds = async (res: Promise<{ json(): Promise }>) => ((await (await res).json()) as { idGds: number[] }).idGds; - /** Options of the criterion `index` of the open effect window, list closed again. */ + const dialog = () => page.getByRole('dialog'); + /** Shows the checkbox list of the criterion `index`: the right column, or a step in compact. */ + const openCriterion = async (index: number) => { + await dialog().locator(compact ? '.crit .add' : '.card-head').nth(index).click(); + await expect(dialog().locator('ac-check-list')).toBeVisible(); + }; + /** Compact: back from the step to the criteria. */ + const closeCriterion = async () => { + if (compact) await dialog().getByRole('button', { name: 'Valider' }).click(); + }; + /** Options of the criterion `index` of the open effect window. */ const listed = async (index: number) => { - const combo = page.getByRole('dialog').getByRole('combobox').nth(index); - await combo.click(); - const options = page.locator('.cdk-overlay-container .panel [role=option]'); + await openCriterion(index); + const options = dialog().locator('ac-check-list .option'); await options.first().waitFor(); const texts = await options.allTextContents(); - await combo.click(); - await expect(page.locator('.cdk-overlay-container .panel')).toHaveCount(0); + await closeCriterion(); return texts; }; const apply = async () => { @@ -552,9 +706,10 @@ test.describe('ReBuilder in the shell · Uniques search', () => { // The whole list shows until the answer comes, then only the ids it holds. await expect.poll(async () => (await listed(0)).length).toBeLessThanOrEqual(factionIds.length); const allTriggers = await listed(0); - await page.getByRole('dialog').getByRole('combobox').nth(2).click(); - await page.locator('.cdk-overlay-container .panel input').fill('Piochez deux cartes.'); - await page.getByRole('option', { name: 'Piochez deux cartes.', exact: true }).click(); + await openCriterion(2); + await dialog().locator('ac-check-list input[type=search]').fill('Piochez deux cartes.'); + await dialog().getByRole('checkbox', { name: 'Piochez deux cartes.', exact: true }).check(); + await closeCriterion(); await apply(); // Effect 2: slot 1, effect 1 sent as a constraint, the new block empty. @@ -568,12 +723,14 @@ test.describe('ReBuilder in the shell · Uniques search', () => { const narrowed = await listed(0); expect(narrowed.length).toBeGreaterThan(0); expect(narrowed.length).toBeLessThan(allTriggers.length); - await page.getByRole('dialog').getByRole('combobox').nth(0).click(); + await openCriterion(0); await evidence(page, testInfo, '14-uniques-effect-narrowed'); - await page.locator('.cdk-overlay-container .panel [role=option]').first().click(); + await dialog().locator('ac-check-list input[type=checkbox]').first().check(); + await closeCriterion(); await apply(); - // Effect 1 removed: the one left is slot 0 again, and its own trigger does not narrow its trigger list. + // Effect 1 removed: the one left is slot 0 again, and its own trigger does not narrow its trigger list + // (the checked trigger stays in the list, checked). await page.locator('ac-effect-summary').first().getByRole('button', { name: /^Supprimer/ }).click(); await expect(page.locator('ac-effect-summary')).toHaveCount(1); const again = narrowing('trigger:0'); @@ -582,7 +739,31 @@ test.describe('ReBuilder in the shell · Uniques search', () => { expect(reqAgain.searchParams.get('effect[0][t]')).toBeTruthy(); expect([...reqAgain.searchParams.keys()].filter((k) => k.startsWith('effect[1]'))).toEqual([]); await again; - await expect.poll(async () => (await listed(0)).length).toBe(allTriggers.length - 1); + await expect.poll(async () => (await listed(0)).length).toBe(allTriggers.length); + }); +}); + +test.describe('ReBuilder in the shell · card filters', () => { + test('checks several subtypes in a row: the list stays open', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + await page.goto(`${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E sous-types ${Date.now()}`); + await expect(page.locator('ac-card-tile').first()).toBeVisible(); + if (compact) await page.getByRole('button', { name: /^Filtres/ }).first().click(); + const filters = compact ? page.getByRole('dialog', { name: 'Filtres' }) : page.getByRole('complementary', { name: 'Filtres' }); + await filters.getByRole('button', { name: 'Recherche avancée' }).click(); + const subtypes = filters.locator('ac-combobox'); + await subtypes.getByRole('combobox', { name: 'Ajouter…' }).click(); + const options = page.getByRole('listbox', { name: 'Ajouter…' }).getByRole('option'); + await options.nth(0).click(); + await options.nth(1).click(); + await expect(page.getByRole('listbox', { name: 'Ajouter…' }).getByRole('option', { selected: true })).toHaveCount(2); + await expect(subtypes.locator('.picked')).toHaveCount(2); + await evidence(page, testInfo, '12-subtypes'); + // The button closes the list; the two subtypes make one filter. + await subtypes.getByRole('combobox', { name: 'Ajouter…' }).click(); + await expect(page.getByRole('listbox', { name: 'Ajouter…' })).toBeHidden(); + if (compact) await filters.getByRole('button', { name: 'Rechercher' }).click(); + await expect(page.getByText('2 sous-types').first()).toBeVisible(); }); }); @@ -669,30 +850,132 @@ test.describe('ReBuilder in the shell · altered.gg export', () => { }); }); +test.describe('ReBuilder in the shell · hero portrait', () => { + test('the hero portrait opens « Choisir un héros » alone; the chosen hero replaces the deck\'s', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + await login(page, 'alice', `${DECKS}?lang=fr`); + await page.getByRole('button', { name: FR.newDeck }).first().click(); + await createDeck(page, `E2E hero ${testInfo.project.name} ${Date.now()}`); + // The portrait: in the deck bar from 768 px, in the « Deck » view's summary on phones. + if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); + const portrait = page.getByRole('button', { name: 'Changer de héros' }); + await expect(portrait.locator('img').first()).toBeVisible(); + await portrait.click(); + + const picker = page.getByRole('dialog', { name: 'Choisir un héros' }); + await expect(picker).toBeVisible(); + // The other settings stay behind the settings button: the picker comes alone, without « Réglages du deck ». + await expect(page.getByRole('dialog', { name: 'Réglages du deck' })).toHaveCount(0); + const other = picker.locator('ac-hero-tile button[aria-pressed="false"]').first(); + const name = ((await other.locator('.name').textContent()) ?? '').trim(); + await other.click(); + await evidence(page, testInfo, '01-hero-picker-from-portrait'); + await picker.getByRole('button', { name: 'Choisir ce héros' }).click(); + await expect(picker).toBeHidden(); + if (compact) await expect(page.locator('ac-deck-summary')).toContainText(name); + else { + // The reduced bar of « Recherche » has no hero name: « Aperçu » shows it. + await page.locator('ac-segmented').getByText(FR.viewDeck, { exact: true }).click(); + await expect(page.locator('app-deck-bar .meta')).toContainText(name); + } + }); +}); + +test.describe('ReBuilder in the shell · app bar of the search and « Aperçu » views (phones)', () => { + test('the hero opens « Choisir un héros », the title « Résumé du deck » with the rarities against the caps', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + test.skip(!compact, 'from 768 px the deck bar shows the hero and the rarities'); + await login(page, 'alice', `${DECKS}?lang=fr`); + await page.getByRole('button', { name: FR.newDeck }).first().click(); + await createDeck(page, `E2E barre ${testInfo.project.name} ${Date.now()}`); + await addTwoCards(page); + + // The app bar: the hero, then the deck name over the rarities the format caps. + const bar = page.locator('ac-app-bar'); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); + await evidence(page, testInfo, '60-app-bar-search'); + + // Down the results the bar stays, and the condensed search head sticks below it. + await page.mouse.wheel(0, 1500); + await expect(page.locator('.search-head.condensed')).toBeVisible(); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toBeInViewport(); + const barBox = await bar.boundingBox(); + const headBox = await page.locator('.search-head').boundingBox(); + expect(headBox!.y).toBeGreaterThanOrEqual(barBox!.y + barBox!.height - 1); + await evidence(page, testInfo, '62-app-bar-scrolled'); + await page.evaluate(() => window.scrollTo(0, 0)); + + await bar.getByRole('button', { name: /^Changer de héros/ }).click(); + const picker = page.getByRole('dialog', { name: 'Choisir un héros' }); + await expect(picker).toBeVisible(); + await page.keyboard.press('Escape'); + await expect(picker).toBeHidden(); + + // « Résumé du deck »: hero, legality, a bar per capped rarity, counts per type. + await bar.getByRole('button', { name: /^Résumé du deck/ }).click(); + const overview = page.getByRole('dialog', { name: 'Résumé du deck' }); + await expect(overview).toContainText('Raretés · Standard All Uniques'); + await expect(overview.getByRole('progressbar', { name: /^Rare : \d+ sur 15 maximum$/ })).toBeVisible(); + await expect(overview).toContainText('2 cartes'); + await evidence(page, testInfo, '61-deck-overview'); + + // « Changer de héros » closes the summary for the picker (no window over another); the new hero shows in the bar. + await overview.getByRole('button', { name: 'Changer de héros' }).click(); + await expect(overview).toBeHidden(); + await expect(picker).toBeVisible(); + const other = picker.locator('ac-hero-tile button[aria-pressed="false"]').first(); + const name = ((await other.locator('.name').textContent()) ?? '').trim(); + await other.click(); + await picker.getByRole('button', { name: 'Choisir ce héros' }).click(); + await expect(picker).toBeHidden(); + await expect(bar.getByRole('button', { name: `Changer de héros (${name})` })).toBeVisible(); + + // « Ouvrir l’onglet Deck » leads to the deck view. + await bar.getByRole('button', { name: /^Résumé du deck/ }).click(); + await overview.getByRole('button', { name: 'Ouvrir l’onglet Deck' }).click(); + await expect(page.locator('ac-deck-summary')).toContainText(name); + + // « Aperçu » has the same bar, kept on screen down the deck. + await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Aperçu/ }).click(); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); + await expect(bar.getByRole('button', { name: `Changer de héros (${name})` })).toBeVisible(); + await page.mouse.wheel(0, 800); + await expect(bar.getByRole('group', { name: 'Raretés limitées par le format' })).toBeInViewport(); + await evidence(page, testInfo, '63-app-bar-apercu'); + + // Back to « Recherche »: the page renders again, the hero's art comes from the cache at once (no placeholder, no fade). + await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Recherche/ }).click(); + await expect(bar.locator('.bar-hero ac-card-art')).toHaveClass(/\binstant\b/); + }); +}); + test.describe('ReBuilder in the shell · deck page', () => { - /** Creates a deck of alice's account through the relay (9 cards: not legal, the API says why). */ - async function createServerDeck(page: Page, name: string): Promise { - return page.evaluate(async (deckName) => { - const host = (window as unknown as { AlteredCore: { csrf: string; services: { decks: string } } }).AlteredCore; - const res = await fetch(`${host.services.decks}/api/decks`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': host.csrf }, - body: JSON.stringify({ - name: deckName, - description: 'Première ligne\nDeuxième ligne', - format: 'standard', - isPublic: false, - deckCards: [ - { cardReference: 'ALT_CORE_B_AX_01_C', quantity: 1 }, - { cardReference: 'ALT_CORE_B_AX_08_C', quantity: 3 }, - { cardReference: 'ALT_CORE_B_AX_09_C', quantity: 3 }, - { cardReference: 'ALT_CORE_B_AX_10_C', quantity: 3 }, - ], - }), - }); - if (res.status !== 201) throw new Error(`deck creation: HTTP ${res.status}`); - return ((await res.json()) as { id: string }).id; - }, name); + /** + * The illustrations of every card family, as the site's `deck-alt-arts` gives them, made predictable: the plain print + * and one alt art owned `owned` times, the defaults `slots` (0: the plain print, 1: the alt art). Returns the two + * references of Élémentaire de Kélon (ALT_CORE_B_AX_04_C) once known. + */ + async function routeAltArts(page: Page, owned: number, slots: (0 | 1)[]): Promise<{ base: string; alt: () => string }> { + let alt = ''; + type Options = { options: { reference: string; ownedQuantity: number | null }[]; slots: { slotIndex: number; reference: string }[] }; + await page.route('**/papi/core-altered-cards/deck-alt-arts**', async (route) => { + const body = (await (await route.fetch()).json()) as { groups: Record; options: Record }; + for (const [key, family] of Object.entries(body.options)) { + family.options = family.options.slice(0, 2).map((o, i) => ({ ...o, ownedQuantity: i === 0 ? null : owned })); + family.slots = slots.map((n, i) => ({ slotIndex: i + 1, reference: family.options[Math.min(n, family.options.length - 1)].reference })); + const kelon = body.groups['ALT_CORE_B_AX_04_C']; + if (kelon && key === `${kelon.familyId}:${kelon.faction}:${kelon.rarity}`) alt = family.options[1]?.reference ?? ''; + } + await route.fulfill({ json: body }); + }); + return { base: 'ALT_CORE_B_AX_04_C', alt: () => alt }; + } + + /** The next PATCH of deck `id` whose cards satisfy `test` (reference → copies). */ + function savedCards(page: Page, id: string, test: (cards: Map) => boolean) { + return page.waitForRequest((r) => { + if (r.method() !== 'PATCH' || !r.url().includes(`/api/decks/${id}`)) return false; + const body = r.postDataJSON() as { deckCards?: { cardReference: string; quantity: number }[] }; + return !!body.deckCards && test(new Map(body.deckCards.map((c) => [c.cardReference, c.quantity]))); + }); } /** Deck page tab (desktop tabs) or bottom navigation entry (mobile). */ @@ -701,7 +984,31 @@ test.describe('ReBuilder in the shell · deck page', () => { else await page.getByRole('tab', { name: tab }).click(); } - test('shows the API legality, description and a test hand; shares and duplicates on the account', async ({ page, compact, baseURL }, testInfo) => { + test('opens a deck at the top of its page from far down the list, and back puts the list where it was', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + const name = `E2E scroll ${testInfo.project.name} ${Date.now()}`; + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, name); + await page.goto(DECKS); + const item = page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name }); + await expect(item).toBeVisible(); + // Room below the page, as with a long list: the window keeps its scroll from one page of the app to the next. + await page.evaluate(() => (document.body.style.paddingBottom = '200vh')); + await item.evaluate((el) => el.scrollIntoView({ block: 'start', behavior: 'instant' })); + const scrollY = () => page.evaluate(() => window.scrollY); + await expect.poll(scrollY).toBeGreaterThan(0); + await item.getByRole('link').first().click(); + await expect(page).toHaveURL(new RegExp(`id=${id}`)); + await expect.poll(scrollY).toBe(0); + await evidence(page, testInfo, '21-deck-opened-at-top'); + // Phones: the list comes back at its top after « back » (already the case before scrolling to the top of each page). + if (compact) return; + await page.goBack(); + await expect(page).toHaveURL(/\/pages\/decks(\?|$)/); + await expect.poll(scrollY).toBeGreaterThan(0); + await expect(item).toBeInViewport(); + }); + + test('shows the API legality, description and a test hand; shares and duplicates on the account', { tag: '@mobile' }, async ({ page, compact, baseURL }, testInfo) => { // navigator.share is recorded: sharing must never use the system share sheet. await page.addInitScript(() => { Object.defineProperty(navigator, 'share', { @@ -744,9 +1051,13 @@ test.describe('ReBuilder in the shell · deck page', () => { await expect(deckPage.locator('app-hand-stats')).toContainText('Démarrage optimal'); const calc = deckPage.locator('app-hand-calculators'); await calc.getByRole('combobox', { name: 'Choisir…', exact: true }).click(); + // The list stays open: several cards are checked in a row. await page.getByRole('option').first().click(); + await page.getByRole('option').nth(1).click(); + await expect(page.getByRole('option', { selected: true })).toHaveCount(2); await expect(calc.locator('ac-probability-bars').first()).toContainText('%'); await evidence(page, testInfo, '23-deck-test-hand'); + await page.keyboard.press('Escape'); // Game mode (the site's playground, desktop only): 3 cards to mana, then a card played from its menu. if (!compact) { await deckPage.getByRole('button', { name: 'Mode jeu' }).click(); @@ -794,13 +1105,339 @@ test.describe('ReBuilder in the shell · deck page', () => { expect(res.status()).toBe(201); const copy = (await res.json()) as { id: string; isPublic: boolean }; expect(copy.isPublic).toBe(false); - await expect(page).toHaveURL(at(DECK(copy.id))); - await expect(deckPage).toContainText(`${name} bis`); - await expect(deckPage.getByRole('button', { name: 'Modifier le deck' }).first()).toBeVisible(); + // The copy is the user's: it opens in the editor, on « Aperçu ». + await expect(page).toHaveURL(at(`${EDITOR(copy.id)}&view=apercu`)); + await expect(page.locator('app-editor-page')).toContainText(`${name} bis`); await page.goto(DECKS); await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: `${name} bis` })).toBeVisible(); }); + test('keeps a unique\'s illustration once its printed effect arrives after it', async ({ page }, testInfo) => { + // No support ability and a short effect: the same frame, so the same illustration URL, before and after the effect. + const unique = 'ALT_ALIZE_B_AX_32_U_2'; + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E unique art ${Date.now()}`, [unique]); + // A pile of the deck board from 768 px, a tile on phones. + const tile = page.locator('ac-card-tile, ac-card-pile').filter({ has: page.locator('ac-unique-card') }); + // The cards API answers once the illustration is shown: the deck then replaces the unique's card object. + await page.route('**/api/cards/batch**', async (route) => { + await expect(tile.locator('ac-card-art')).toHaveClass(/\bloaded\b/); + await route.continue(); + }); + await page.goto(DECK(id)); + await expect(tile.locator('.main-text')).not.toBeEmpty(); + await expect(tile.locator('ac-card-art')).toHaveClass(/\bloaded\b/); + await expect(tile.locator('ac-card-art img')).toHaveCSS('opacity', '1'); + await evidence(page, testInfo, '26-unique-art'); + }); + + /** The default alt arts saved by the brush (`PUT /api/alt-arts/preferences`), answered without the service. */ + async function routePreferences(page: Page): Promise { + const saved: string[][] = []; + await page.route('**/api/alt-arts/preferences', async (route) => { + saved.push((route.request().postDataJSON() as { slotReferences: string[] }).slotReferences); + await route.fulfill({ status: 204 }); + }); + return saved; + } + + /** The next brush choice saved for deck `id` (`POST /papi/rebuilder/deck-alt-arts`): its cards. */ + async function savedDeckCards(page: Page, id: string): Promise { + const request = await page.waitForRequest((r) => r.method() === 'POST' && r.url().includes('/papi/rebuilder/deck-alt-arts') && (r.postDataJSON() as { deck?: string }).deck === id); + return (request.postDataJSON() as { cards: string[] | null }).cards; + } + + test('chooses the illustration of each card of the deck with the brush, the default alt arts unchanged', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + const kelon = await routeAltArts(page, 2, [0, 0, 0]); + const preferences = await routePreferences(page); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E brush ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 2); + await page.goto(`${EDITOR(id)}&view=apercu`); + const brush = page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }); + await brush.click(); + const dialog = page.getByRole('dialog', { name: 'Élémentaire de Kélon' }); + /** Key `card` of illustration `n`: pressed when that card takes the illustration. */ + const key = (n: number, card: string) => dialog.getByRole('group', { name: `Cartes de Illustration ${n}` }).getByRole('button', { name: card }); + // The plain print is chosen for the three cards (the defaults); the deck uses two. + for (const card of ['1ère carte', '2ème carte', '3ème carte']) { + await expect(key(1, card)).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, card)).toHaveAttribute('aria-pressed', 'false'); + } + await expect(dialog.getByText('Avec 2 exemplaires, le deck prend les illustrations des 1ère et 2ème cartes.')).toBeVisible(); + // The alt art for the 1st card: the deck's copy 1 takes it, and the choice is kept for this deck. + let saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 1); + let cards = savedDeckCards(page, id); + await key(2, '1ère carte').click(); + await saved; + expect(await cards).toEqual([kelon.alt(), kelon.base, kelon.base]); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(1, '1ère carte')).toHaveAttribute('aria-pressed', 'false'); + // The illustrations keep their order: the plain print stays first. + await expect(dialog.getByRole('list', { name: 'Illustrations disponibles' }).getByRole('listitem').first()).toContainText('Illustration 1'); + await expect(dialog.locator('.fan img').first()).toHaveAttribute('src', new RegExp(kelon.alt())); + await evidence(page, testInfo, '30-alt-art-brush'); + // The 3rd card, past the deck's copies: kept for the deck, its two copies stay. + cards = savedDeckCards(page, id); + await key(2, '3ème carte').click(); + expect(await cards).toEqual([kelon.alt(), kelon.base, kelon.alt()]); + // Both copies owned are on cards: the 2nd card cannot take the alt art. + await expect(key(2, '3ème carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, '2ème carte')).toBeDisabled(); + await evidence(page, testInfo, '30-alt-art-brush-keys'); + // The default alt arts are not touched. + expect(preferences).toEqual([]); + // Kept after a reload; a copy added takes the 3rd card's illustration. + await page.reload(); + // On a phone, a tile a print: a brush on each. + await brush.first().click(); + await expect(key(2, '1ère carte')).toHaveAttribute('aria-pressed', 'true'); + await expect(key(2, '3ème carte')).toHaveAttribute('aria-pressed', 'true'); + await page.keyboard.press('Escape'); + if (compact) return; + saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 2 && c.get(kelon.base) === 1); + const pile = page.locator('app-deck-board ac-card-pile').filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); + await pile.hover(); + await pile.getByRole('button', { name: 'Ajouter un exemplaire de Élémentaire de Kélon' }).click(); + await saved; + // The deck deleted: its choices go with it. + const deckCards = () => page.evaluate((deck) => fetch(`/papi/rebuilder/deck-alt-arts?deck=${deck}`).then((r) => r.json()), id); + expect(Object.keys(((await deckCards()) as { families: object }).families)).toHaveLength(1); + await page.evaluate(async (deck) => { + const host = (window as unknown as { AlteredCore: { csrf: string; services: { decks: string } } }).AlteredCore; + const res = await fetch(`${host.services.decks}/api/decks/${deck}`, { method: 'DELETE', headers: { 'X-CSRF-Token': host.csrf } }); + if (!res.ok) throw new Error(`deck deletion: HTTP ${res.status}`); + }, id); + expect(await deckCards()).toEqual({ families: {} }); + }); + + test('takes a card brought to 0 off the deck board', async ({ page, compact }) => { + test.skip(compact, 'the deck board is from 768 px'); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E board ${Date.now()}`, ['ALT_CORE_B_AX_04_C']); + await page.goto(`${EDITOR(id)}&view=apercu`); + const piles = page.locator('app-deck-board ac-card-pile'); + const kelon = piles.filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); + await expect(piles).toHaveCount(4); + await kelon.hover(); + await kelon.getByRole('button', { name: 'Retirer un exemplaire de Élémentaire de Kélon' }).click(); + await expect(piles).toHaveCount(3); + await expect(kelon).toHaveCount(0); + }); + + test('puts a card’s illustrations on one pile of the deck board, copy 1 in front, or on a pile each', async ({ page, compact }, testInfo) => { + test.skip(compact, 'the deck board is from 768 px'); + const kelon = await routeAltArts(page, 1, [0, 0, 0]); + await routePreferences(page); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E stack ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + await page.goto(`${EDITOR(id)}&view=apercu`); + const piles = page.locator('app-deck-board ac-card-pile').filter({ has: page.getByRole('group', { name: 'Exemplaires de Élémentaire de Kélon dans le deck' }) }); + // The alt art for the 1st card (the brush): the deck has two prints of the card. + await page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' }).click(); + const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 2); + await page.getByRole('dialog').getByRole('group', { name: 'Cartes de Illustration 2' }).getByRole('button', { name: '1ère carte' }).click(); + await saved; + await page.keyboard.press('Escape'); + // One pile of 3, copy 1 in front. + const stack = page.getByRole('switch', { name: 'Regrouper les illustrations' }); + await expect(stack).toBeChecked(); + await expect(piles).toHaveCount(1); + await expect(piles.locator('.qty')).toHaveText('×3'); + await expect(piles.locator('.front img')).toHaveAttribute('src', new RegExp(kelon.alt())); + await evidence(page, testInfo, '32-alt-arts-one-pile'); + // Its stepper adds a copy of the card: back to the deck limit, the pile stays. + await piles.hover(); + await piles.getByRole('button', { name: 'Retirer un exemplaire de Élémentaire de Kélon' }).click(); + await expect(piles.locator('.qty')).toHaveText('×2'); + await expect(piles.locator('.front img')).toHaveAttribute('src', new RegExp(kelon.alt())); + // Off: a pile each print. + await stack.uncheck(); + await expect(piles).toHaveCount(2); + await stack.check(); + await expect(piles).toHaveCount(1); + }); + + test('adds a card from the search with the default alt art of its copy', { tag: '@mobile' }, async ({ page }, testInfo) => { + const kelon = await routeAltArts(page, 1, [1, 1, 0]); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E alt arts search ${Date.now()}`); + await page.goto(EDITOR(id)); + const tiles = page.locator('app-search-results ac-card-tile'); + // A reference looks up that card only (typed again if the search was not ready for it). + await expect(async () => { + await page.getByRole('textbox', { name: 'Rechercher par nom' }).first().fill(kelon.base); + await expect(tiles).toHaveCount(1, { timeout: 5000 }); + }).toPass(); + // The card's tile stands for all its prints: the 1st copy takes the 1st default, the alt art. + let saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && !c.has(kelon.base)); + await tiles.first().getByRole('button', { name: 'Ajouter Élémentaire de Kélon au deck' }).click(); + await saved; + await expect(tiles.first().locator('ac-stepper .value')).toHaveText('1'); + // The 2nd default is the alt art too, but its only copy owned is placed: the plain print. + saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 1 && c.get(kelon.base) === 1); + await tiles.first().locator('ac-stepper .inc').click(); + await saved; + await expect(tiles.first().locator('ac-stepper .value')).toHaveText('2'); + await expect(page.getByRole('switch', { name: 'Arts alternatifs' })).toHaveCount(0); + await evidence(page, testInfo, '31-alt-arts-search'); + }); + + test('applies the default alt arts to the whole deck after a confirmation', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + const kelon = await routeAltArts(page, 3, [1, 1, 1]); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E default alt arts ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + await page.goto(`${EDITOR(id)}&view=apercu`); + await expect(page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' })).toBeVisible(); + if (compact) await page.getByRole('navigation', { name: FR.deckNav }).getByRole('link', { name: /Deck/ }).click(); + await page.getByRole('button', { name: compact ? 'Appliquer les arts par défaut' : 'Arts par défaut' }).click(); + const dialog = page.getByRole('dialog', { name: 'Appliquer vos arts par défaut ?' }); + await evidence(page, testInfo, '32-default-alt-arts-confirm'); + const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 3 && !c.has(kelon.base)); + await dialog.getByRole('button', { name: 'Appliquer' }).click(); + await saved; + await expect(page.getByText('Arts par défaut appliqués.')).toBeVisible(); + }); + + /** A call to the ownership service through the site's relay (from a page where Re:Builder runs): status and body. */ + function ownership(page: Page, method: string, path: string, body?: unknown): Promise<{ status: number; json: unknown }> { + return page.evaluate( + async ([m, p, b]) => { + const host = (window as unknown as { AlteredCore: { csrf: string; services: { ownership: string } } }).AlteredCore; + const res = await fetch(`${host.services.ownership}${p}`, { + method: m, + headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-Token': host.csrf }, + body: b === undefined ? undefined : JSON.stringify(b), + }); + const text = await res.text(); + return { status: res.status, json: text ? (JSON.parse(text) as unknown) : null }; + }, + [method, path, body] as const, + ); + } + + /** The cards of deck `id` (reference → copies), from the decks API through the relay. */ + async function deckCards(page: Page, id: string): Promise> { + const cards = await page.evaluate(async (deck) => { + const host = (window as unknown as { AlteredCore: { services: { decks: string } } }).AlteredCore; + const res = await fetch(`${host.services.decks}/api/decks/${deck}`, { headers: { Accept: 'application/json' } }); + const body = (await res.json()) as Record<'deckCards' | 'cards', { cardReference: string; quantity: number }[] | undefined>; + return body.deckCards ?? body.cards ?? []; + }, id); + return new Map(cards.map((c) => [c.cardReference, c.quantity])); + } + + // The alt-art mode is bob's account's: these scenarios run one after the other, on one project. + test.describe('alt-art mode', () => { + test.describe.configure({ mode: 'serial' }); + + test('« Global » mode: the deck follows the default alt arts, without a brush', async ({ page, compact }, testInfo) => { + test.skip(compact, 'the mode is the account’s: one project at a time'); + const kelon = await routeAltArts(page, 3, [1, 1, 1]); + await login(page, 'bob', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E global ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + expect((await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'Global' })).status).toBe(204); + try { + const saved = savedCards(page, id, (c) => c.get(kelon.alt()) === 3 && !c.has(kelon.base)); + await page.goto(`${EDITOR(id)}&view=apercu`); + await saved; + await expect(page.getByRole('button', { name: 'Arts des jetons' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Arts par défaut' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Choisir les illustrations de Élémentaire de Kélon' })).toHaveCount(0); + await evidence(page, testInfo, '36-global-mode'); + } finally { + await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'PerDeck' }); + } + }); + + test('leaving « Global » writes the default alt arts in every deck, then switches to « par deck »', async ({ page, compact }, testInfo) => { + test.skip(compact, 'the mode is the account’s: one project at a time'); + // Every deck of the account is written: a local stack keeps the decks of earlier runs. + test.slow(); + await login(page, 'bob', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E leave global ${Date.now()}`, ['ALT_CORE_B_AX_04_C'], 3); + // bob's default alt art for Élémentaire de Kélon: an illustration he owns, on the three copies. + type Family = { familyId: number; faction: string; rarity: string; options: { reference: string; ownedQuantity: number | null }[] }; + const [key] = (await ownership(page, 'POST', '/api/alt-arts/resolve-references', ['ALT_CORE_B_AX_04_C'])).json as Family[]; + const [family] = (await ownership(page, 'POST', '/api/alt-arts/options', [key])).json as Family[]; + const alt = family.options.find((o) => o.reference !== 'ALT_CORE_B_AX_04_C' && o.ownedQuantity !== 0)?.reference ?? ''; + expect(alt).not.toBe(''); + const prefs = { familyId: key.familyId, faction: key.faction, rarity: key.rarity }; + await ownership(page, 'PUT', '/api/alt-arts/preferences', { ...prefs, slotReferences: [alt, alt, alt] }); + expect((await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'Global' })).status).toBe(204); + // What Board Game Arena shows of the deck in « Global » mode. + const shown = (await ownership(page, 'POST', '/api/alt-arts/apply-to-deck', [{ reference: 'ALT_CORE_B_AX_04_C', quantity: 3 }])).json as { + lines: { reference: string; quantity: number }[][]; + }; + const expected = new Map(shown.lines.flat().map((l) => [l.reference, l.quantity])); + expect(expected.get('ALT_CORE_B_AX_04_C') ?? 0).toBeLessThan(3); + try { + await page.goto('/pages/ownership-alt-arts?lang=fr'); + await page.getByRole('button', { name: 'Par deck', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Passer en mode par deck ?' }); + await expect(dialog).toBeVisible(); + await evidence(page, testInfo, '37-leave-global-confirm'); + await dialog.getByRole('button', { name: 'Passer en mode par deck' }).click(); + await expect(page.getByRole('button', { name: 'Par deck', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 120_000 }); + await evidence(page, testInfo, '38-per-deck-mode'); + await page.goto(`${DECKS}?lang=fr`); + // Élémentaire de Kélon's copies, as Board Game Arena showed them (the deck's other cards have one illustration). + const cards = await deckCards(page, id); + expect(new Map([...cards].filter(([ref]) => family.options.some((o) => o.reference === ref)))).toEqual(expected); + } finally { + await page.goto(`${DECKS}?lang=fr`); + await ownership(page, 'PUT', '/api/alt-arts/preferences', { ...prefs, slotReferences: [] }); + await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'PerDeck' }); + } + }); + + test('leaving « Global » switches to « par deck » even when decks cannot be written', async ({ page, compact }) => { + test.skip(compact, 'the mode is the account’s: one project at a time'); + await login(page, 'bob', `${DECKS}?lang=fr`); + await createServerDeck(page, `E2E leave global failing ${Date.now()}`); + expect((await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'Global' })).status).toBe(204); + // Every deck fails: best effort, the mode changes all the same. + let tried = 0; + await page.route('**/papi/ownership/alt-art-leave-global', (route) => { + if (route.request().method() !== 'POST') return route.continue(); + tried++; + return route.fulfill({ status: 502, json: { error: 'write' } }); + }); + try { + await page.goto('/pages/ownership-alt-arts?lang=fr'); + await page.getByRole('button', { name: 'Par deck', exact: true }).click(); + const dialog = page.getByRole('dialog', { name: 'Passer en mode par deck ?' }); + await dialog.getByRole('button', { name: 'Passer en mode par deck' }).click(); + await expect(page.getByRole('button', { name: 'Par deck', exact: true })).toHaveAttribute('aria-pressed', 'true', { timeout: 60_000 }); + expect(tried).toBeGreaterThan(0); + } finally { + await page.goto(`${DECKS}?lang=fr`); + await ownership(page, 'PUT', '/api/alt-arts/preference-mode', { mode: 'PerDeck' }); + } + }); + }); + + test('shows the whole card window on a short phone screen (browser bars): the card shrinks, the buttons stay visible', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { + // 390 × 640: a phone whose browser shows its address bar and its navigation bar. + await page.setViewportSize(compact ? { width: 390, height: 640 } : { width: 1440, height: 640 }); + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, `E2E zoom fits ${Date.now()}`, ['ALT_CORE_B_AX_04_C']); + const fits = async (shot: string) => { + await page.getByRole('button', { name: /^Agrandir Élémentaire de Kélon/ }).first().click(); + const dialog = page.getByRole('dialog', { name: 'Élémentaire de Kélon' }); + const detail = dialog.getByRole('link', { name: 'Accéder au détail' }); + await expect(detail).toBeVisible(); + await evidence(page, testInfo, shot); + await expect(detail).toBeInViewport({ ratio: 1 }); + // The card's 3D stage may round its edge by a fraction of a pixel. + await expect(dialog.locator('ac-card-tile')).toBeInViewport({ ratio: 0.99 }); + await page.keyboard.press('Escape'); + await expect(dialog).toBeHidden(); + }; + // Editor: copies and « Choisir les illustrations ». + await page.goto(`${EDITOR(id)}&view=apercu`); + await fits('29-card-zoom-short-screen'); + }); + test('names a public deck in the page title and link preview, also from a site-style link', async ({ page }) => { const res = await page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }); const deck = ((await res.json()) as { member: { id: string; name: string }[] }).member[0]; @@ -819,6 +1456,29 @@ test.describe('ReBuilder in the shell · deck page', () => { await expect(page.locator('app-deck-page').getByRole('alert')).toContainText('Deck introuvable.'); await evidence(page, testInfo, '25-deck-not-found'); }); + + test('back from a deck opened far down the list puts the list where it was', { tag: '@mobile' }, async ({ page }, testInfo) => { + const name = `E2E back ${testInfo.project.name} ${Date.now()}`; + await login(page, 'alice', `${DECKS}?lang=fr`); + const id = await createServerDeck(page, name); + await page.goto(DECKS); + const item = page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name }); + await expect(item).toBeVisible(); + // Room below the page, as with a long list. The scroll stops below the sticky site header and app bar (phones): + // 's scroll-padding-top. + const before = await item.evaluate((el) => { + document.body.style.paddingBottom = '200vh'; + el.scrollIntoView({ block: 'start', behavior: 'instant' }); + return window.scrollY; + }); + expect(before).toBeGreaterThan(0); + await item.getByRole('link').first().click(); + await expect(page).toHaveURL(at(`${EDITOR(id)}&view=apercu`)); + await page.goBack(); + await expect(page).toHaveURL(at(DECKS)); + await expect.poll(() => page.evaluate(() => window.scrollY)).toBe(before); + await expect(item).toBeInViewport(); + }); }); test.describe('ReBuilder in the shell · guest', () => { @@ -852,24 +1512,30 @@ test.describe('ReBuilder in the shell · guest', () => { }); }); -test.describe('ReBuilder in the shell · « Partager » and « Terminer » in the editor', () => { - /** « Partager »: a button on desktop, an icon in the app bar on mobile. */ - const shareButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Partager', exact: true }); - const doneButton = (page: Page) => page.locator('app-editor-page').getByRole('button', { name: 'Terminer', exact: true }); +test.describe('ReBuilder in the shell · « Partager » in the editor', () => { + /** « Partager »: a button on desktop; on phones, in the app bar's « ⋯ » menu (the search view's title takes the room). */ + const share = async (page: Page, compact: boolean) => { + const editor = page.locator('app-editor-page'); + if (!compact) return editor.getByRole('button', { name: 'Partager', exact: true }).click(); + await page.locator('ac-app-bar').getByRole('button', { name: 'Plus d’actions' }).click(); + await page.getByRole('menuitem', { name: 'Partager' }).click(); + }; - test('saves the deck first, then shares it, then goes back to the deck page', async ({ page, compact }, testInfo) => { + test('saves the deck first, then shares it', { tag: '@mobile' }, async ({ page, compact }, testInfo) => { const name = `E2E share ${testInfo.project.name} ${Date.now()}`; await login(page, 'bob', `${DECKS}?lang=fr`); await page.getByRole('button', { name: FR.newDeck }).first().click(); const created = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/v1/services/decks/api/decks'); await createDeck(page, name); - const deck = (await (await created).json()) as { id: string }; - await expect(page).toHaveURL(at(EDITOR(deck.id))); + expect((await created).status()).toBe(201); + const deck = { id: await editorDeckId(page) }; // « Partager » right after a change: no wait for the autosave delay, the change is sent first. const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${deck.id}`) && r.ok()); await addTwoCards(page); - await shareButton(page).click(); + // The deck bar counts the rarities the format caps (from 768 px). + if (!compact) await expect(page.locator('app-deck-bar').getByRole('group', { name: 'Raretés limitées par le format' })).toContainText('/15'); + await share(page, compact); const patch = await saved; expect(((patch.request().postDataJSON() as { deckCards?: unknown[] }).deckCards ?? []).length).toBeGreaterThanOrEqual(2); const dialog = page.getByRole('dialog', { name: 'Partager ce deck' }); @@ -882,20 +1548,22 @@ test.describe('ReBuilder in the shell · « Partager » and « Terminer » in th await page.keyboard.press('Escape'); await expect(dialog).toBeHidden(); - await doneButton(page).click(); - await expect(page).toHaveURL(at(DECK(deck.id))); - await expect(page.locator('app-deck-page')).toContainText(name); - if (!compact) await expect(page.locator('app-deck-page').getByRole('button', { name: 'Modifier le deck' })).toBeVisible(); + // « Mes decks » of the deck bar leaves the editor (from 768 px; the app bar's back button on phones). + if (!compact) { + await page.locator('app-deck-bar').getByRole('link', { name: 'Mes decks' }).click(); + await expect(page).toHaveURL(/\/pages\/decks(\?|$)/); + await expect(page.getByRole('list', { name: 'Mes decks' }).locator('ac-deck-card').filter({ hasText: name })).toBeVisible(); + } }); - test('a guest signs in to share: the deck moves to the account and the share window opens', async ({ page }, testInfo) => { + test('a guest signs in to share: the deck moves to the account and the share window opens', async ({ page, compact }, testInfo) => { const name = `E2E invité ${testInfo.project.name} ${Date.now()}`; await page.goto(`${NEW_DECK}?lang=fr`); await createDeck(page, name); await expect(page).toHaveURL(/\/pages\/deckbuilder\?id=guest-[^&]+$/); await addTwoCards(page); - await shareButton(page).click(); + await share(page, compact); const prompt = page.getByRole('dialog', { name: 'Connectez-vous pour partager' }); await expect(prompt).toContainText('Pour partager ce deck et l’enregistrer sur le serveur, connectez-vous.'); await evidence(page, testInfo, '41-editor-share-sign-in'); @@ -953,3 +1621,162 @@ test.describe('ReBuilder in the shell · side panels of the editor', () => { await expect(editor.getByRole('button', { name: 'Masquer le deck' })).toBeFocused(); }); }); + +test.describe('ReBuilder in the shell · loading', () => { + test('requests its modules from , the deck once while they load, and the ownership from the deck ids', async ({ page, compact }) => { + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E chargement ${Date.now()}`); + const id = await editorDeckId(page); + + const requests: { url: string; method: string }[] = []; + page.on('request', (r) => requests.push({ url: r.url(), method: r.method() })); + const failed: string[] = []; + page.on('response', (r) => { + if (r.url().includes('/plugins/rebuilder/') && r.status() >= 400) failed.push(`${r.status()} ${r.url()}`); + }); + const deckAnswer = page.waitForResponse((r) => new URL(r.url()).pathname === `/api/v1/services/decks/api/decks/${id}` && r.request().method() === 'GET'); + // A fresh load of the editor: the deck was created in this tab before, now only the account says it is alice's. + requests.length = 0; + await page.goto(`${EDITOR(id)}&lang=fr`); + expect(await (await deckAnswer).headerValue('content-encoding')).toBe('gzip'); + // Once the account's deck ids are in, the owner's actions replace their skeletons: « Partager » on desktop, on phones + // the hero of the app bar (« Partager » moves to its « ⋯ » menu). + const editor = page.locator('app-editor-page'); + await expect(compact ? editor.locator('ac-app-bar').getByRole('button', { name: /^Changer de héros/ }) : editor.getByRole('button', { name: 'Partager', exact: true })).toBeVisible(); + const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${id}`) && r.ok()); + await addTwoCards(page); + await expectDeckCount(page, compact, 2); + + // The page's modules, preloaded once its stylesheets are in: entry, bootstrap, shared chunks and the editor's. + expect(await page.locator('head link[rel="modulepreload"]').count()).toBeGreaterThanOrEqual(5); + expect(failed).toEqual([]); + const paths = requests.map((r) => ({ ...r, path: new URL(r.url).pathname, query: new URL(r.url).searchParams })); + expect(paths.filter((r) => r.method === 'GET' && r.path === `/api/v1/services/decks/api/decks/${id}`)).toHaveLength(1); + expect(paths.filter((r) => r.path === '/papi/rebuilder/my-deck-ids')).toHaveLength(1); + expect(paths.filter((r) => r.path === '/api/v1/services/decks/api/decks' && r.method === 'GET')).toHaveLength(0); + + // The test hand comes with its own chunk, when its view opens (once the two cards are saved). + await saved; + await page.goto(`${EDITOR(id)}&view=main&lang=fr`); + await expect(page.locator('app-editor-page').getByRole('list', { name: 'Main de départ' })).toBeVisible(); + }); + + test('draws its first screen in place of the server\'s skeleton, without moving it (layout shift)', { tag: '@mobile' }, async ({ page }) => { + await page.addInitScript(() => { + const w = window as unknown as { __cls: number }; + w.__cls = 0; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as (PerformanceEntry & { value: number; hadRecentInput: boolean })[]) if (!e.hadRecentInput) w.__cls += e.value; + }).observe({ type: 'layout-shift', buffered: true }); + }); + // The app's scripts wait until the server's skeleton is on screen. + let releaseScripts!: () => void; + const scripts = new Promise((resolve) => (releaseScripts = resolve)); + await page.route(/\/plugins\/rebuilder\/dist\/browser\/main-[^/]+\.js/, async (route) => { + await scripts; + await route.continue(); + }); + await emptyCache(page); + await page.goto(`${DECKS}?lang=fr`, { waitUntil: 'commit' }); + await expect(page.locator('.ac-spa-placeholder .ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + await page.waitForTimeout(300); + releaseScripts(); + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0); + await expect(page.locator('ac-deck-card').first()).toBeVisible(); + await page.waitForTimeout(500); + expect(await page.evaluate(() => (window as unknown as { __cls: number }).__cls)).toBeLessThan(0.1); + }); + + test('keeps the server\'s skeleton until the page draws, never an empty frame in between', { tag: '@mobile' }, async ({ page }) => { + // A public deck seeded by the stack (docker/stack/seed-decks.php). + const [decks, manifestRes] = await Promise.all([ + page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }), + page.request.get('/plugins/rebuilder/dist/embed-manifest.json'), + ]); + const deck = ((await decks.json()) as { member: { id: string; name: string }[] }).member[0]; + + // The deck page's own modules are held: the app starts (its root, its router) but cannot draw the page yet. + const manifest = (await manifestRes.json()) as { preload: Record }; + const pageChunks = manifest.preload['deck'].filter((chunk) => !manifest.preload['*'].includes(chunk)); + expect(pageChunks.length).toBeGreaterThan(0); + let releasePage!: () => void; + const pageModules = new Promise((resolve) => (releasePage = resolve)); + await page.route((url) => pageChunks.some((chunk) => url.pathname.endsWith(`/${chunk}`)), async (route) => { + await pageModules; + await route.continue(); + }); + await emptyCache(page); + await page.goto(`${DECK(deck.id)}&lang=fr`, { waitUntil: 'commit' }); + // The app's root is drawn: a placeholder taken for the first screen would go at the next frame. + await expect(page.locator('app-rebuilder-embed')).toBeAttached(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expect(page.locator('.ac-spa-placeholder .ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + // The first paint waits for the skeleton (not the site header over an empty page): names an element after it. + const expectLink = page.locator('head link[rel="expect"][blocking="render"]'); + await expect(expectLink).toHaveAttribute('href', '#ac-spa-rebuilder-parsed'); + await expect(page.locator('#ac-spa-rebuilder:has(> .ac-spa-placeholder) + #ac-spa-rebuilder-parsed')).toHaveCount(1); + // Once the plugin's stylesheets reset its shadow host (`:host { all: initial }`), the skeleton keeps the site's font. + await expect.poll(() => page.evaluate(() => [...document.querySelector('#ac-spa-rebuilder')!.shadowRoot!.querySelectorAll('link[rel="stylesheet"]')].every((l) => (l as HTMLLinkElement).sheet))).toBe(true); + expect(await page.locator('.ac-spa-placeholder').evaluate((el) => getComputedStyle(el).fontFamily)).toContain('Figtree'); + + releasePage(); + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0); + await expect(page.locator('app-deck-page').getByText(deck.name).first()).toBeVisible(); + }); + + test('« Mon deck » on a phone: nothing moves or changes look while the owner, the alt-art mode and the rarity icons arrive', { tag: '@mobile' }, async ({ page, compact }) => { + test.skip(!compact, 'the « Deck » view is a phone view'); + await login(page, 'alice', `${NEW_DECK}?lang=fr`); + await createDeck(page, `E2E mon deck ${Date.now()}`); + const id = await editorDeckId(page); + const saved = page.waitForResponse((r) => r.request().method() === 'PATCH' && r.url().includes(`/api/decks/${id}`) && r.ok()); + await addTwoCards(page); + await saved; + + // Held: the account's deck ids (the owner, which shows « Illustrations des jetons »), the alt-art mode and the + // rarity icons of the summary (sized by their height only). + let release!: () => void; + const held = new Promise((resolve) => (release = resolve)); + await page.route(/\/papi\/rebuilder\/my-deck-ids|\/alt-arts\/preference-mode|\/assets\/icons\/rarete-/, async (route) => { + await held; + await route.continue(); + }); + await emptyCache(page); + await page.goto(`${EDITOR(id)}&view=deck&lang=fr`); + const section = page.locator('app-deck-list-view ac-deck-section').first(); + await expect(section).toBeVisible(); + const before = await section.boundingBox(); + // The rows have their steppers already, not a read-only « ×1 » replaced once the owner is known. + await expect(section.locator('ac-deck-row ac-stepper')).toHaveCount(2); + await expect(section.locator('ac-deck-row .qty')).toHaveCount(0); + + release(); + await expect(page.getByRole('button', { name: 'Illustrations des jetons' })).toBeVisible(); + await expect(page.locator('ac-rarity-summary img').first()).toHaveJSProperty('complete', true); + expect((await section.boundingBox())?.y).toBe(before?.y); + }); + + test('draws its first screen with its styles: the server\'s skeleton stays while they load', { tag: '@mobile' }, async ({ page }) => { + const decks = await page.request.get('/api/v1/services/decks/api/decks/public', { params: { itemsPerPage: 1 }, headers: { Accept: 'application/json' } }); + const deck = ((await decks.json()) as { member: { id: string; name: string }[] }).member[0]; + // The plugin's own stylesheet (shadow root) is held: the app draws its page, unstyled, which must not show. + let releaseStyles!: () => void; + const styles = new Promise((resolve) => (releaseStyles = resolve)); + await page.route(/\/plugins\/rebuilder\/dist\/browser\/embed\.css/, async (route) => { + await styles; + await route.continue(); + }); + await emptyCache(page); + await page.goto(`${DECK(deck.id)}&lang=fr`, { waitUntil: 'commit' }); + const root = page.locator('.ac-plugin-root'); + await expect(page.locator('app-deck-page ac-app-bar, app-deck-page app-deck-bar').first()).toBeAttached(); + await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))); + await expect(page.locator('.ac-spa-placeholder .ac-skeleton').filter({ visible: true }).first()).toBeVisible(); + await expect(root).toHaveCSS('visibility', 'hidden'); + + releaseStyles(); + await expect(page.locator('.ac-spa-placeholder')).toHaveCount(0); + await expect(root).toHaveCSS('visibility', 'visible'); + await expect(page.locator('app-deck-page').getByText(deck.name).first()).toBeVisible(); + }); +}); diff --git a/plugins/rebuilder/e2e/screens.ts b/plugins/rebuilder/e2e/screens.ts new file mode 100644 index 00000000..e425aa20 --- /dev/null +++ b/plugins/rebuilder/e2e/screens.ts @@ -0,0 +1,22 @@ +// Re:Builder screens for the loading checks (loading.spec.ts) and tests/e2e/loading-timeline.ts (Node runs this file +// with type stripping: type-only imports, explicit extensions). `path` takes the id of a deck of the signed-in account. +export interface Screen { + key: string; + name: string; + path: (deckId: string) => string; + /** Needs a deck of the signed-in account (`deckId`). */ + deck: boolean; +} + +export const SCREENS: Screen[] = [ + { key: 'decks', name: 'Mes decks', path: () => '/pages/decks', deck: false }, + { key: 'deck', name: 'deck page', path: (id) => `/pages/deck?id=${id}`, deck: true }, + { key: 'search', name: 'editor, Recherche', path: (id) => `/pages/deckbuilder?id=${id}`, deck: true }, + { key: 'apercu', name: 'editor, Aperçu', path: (id) => `/pages/deckbuilder?id=${id}&view=apercu`, deck: true }, + { key: 'mon-deck', name: 'editor, Mon deck', path: (id) => `/pages/deckbuilder?id=${id}&view=deck`, deck: true }, + { key: 'main', name: 'editor, Main', path: (id) => `/pages/deckbuilder?id=${id}&view=main`, deck: true }, + { key: 'new', name: 'new deck', path: () => '/pages/deckbuilder', deck: false }, +]; + +/** The skeleton of a title replaces these texts: they must never show. */ +export const FORBIDDEN_TEXTS = [{ selector: 'ac-app-bar', text: 'Chargement' }]; diff --git a/plugins/rebuilder/includes/community-builders.php b/plugins/rebuilder/includes/community-builders.php new file mode 100644 index 00000000..f998bdfb --- /dev/null +++ b/plugins/rebuilder/includes/community-builders.php @@ -0,0 +1,19 @@ + */ +function rebuilderCommunityBuilders(string $lang): array { + $rows = getDB()->query(q( + "SELECT title, desc_en, desc_fr, image, url + FROM {community_builders} + WHERE is_visible = 1 ORDER BY sort_order ASC, created_at ASC" + ))->fetchAll(); + return array_map(fn($r) => [ + 'title' => (string)$r['title'], + 'description' => (string)(($lang === 'fr' ? $r['desc_fr'] : $r['desc_en']) ?: ($r['desc_en'] ?? '')), + 'image' => !empty($r['image']) ? assetUrl($r['image']) : null, + 'url' => (string)$r['url'], + ], $rows); +} diff --git a/plugins/rebuilder/papi/community-builders.php b/plugins/rebuilder/papi/community-builders.php index d098a620..6c0dd5fa 100644 --- a/plugins/rebuilder/papi/community-builders.php +++ b/plugins/rebuilder/papi/community-builders.php @@ -1,19 +1,8 @@ query(q( - "SELECT title, desc_en, desc_fr, image, url - FROM {community_builders} - WHERE is_visible = 1 ORDER BY sort_order ASC, created_at ASC" -))->fetchAll(); - -echo json_encode(array_map(fn($r) => [ - 'title' => (string)$r['title'], - 'description' => (string)(($lang === 'fr' ? $r['desc_fr'] : $r['desc_en']) ?: ($r['desc_en'] ?? '')), - 'image' => !empty($r['image']) ? assetUrl($r['image']) : null, - 'url' => (string)$r['url'], -], $rows), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); +echo json_encode(rebuilderCommunityBuilders($lang), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); diff --git a/plugins/rebuilder/papi/deck-alt-arts.php b/plugins/rebuilder/papi/deck-alt-arts.php new file mode 100644 index 00000000..d1173649 --- /dev/null +++ b/plugins/rebuilder/papi/deck-alt-arts.php @@ -0,0 +1,76 @@ + → {"families": {"::": ["", "", ""]}} +// POST /papi/rebuilder/deck-alt-arts {"deck": "", "family": "", "cards": ["<1st>", "<2nd>", "<3rd>"]} → 204 +// POST … {"deck": "", "family": "", "cards": null} → 204: the card follows the default alt arts again. +// POST … {"deck": ""} → 204: every card of the deck follows them again (« Appliquer les arts par défaut »). +// The brush's choices for a deck of the signed-in player: the illustration of the 1st, 2nd and 3rd card (copy) of each +// multi-art card, in order (table {rebuilder_deck_alt_arts}, sql/migration_009). The decks API keeps only the deck's +// references; the default alt arts stay in the ownership service. Manifest: "auth": "user"; CSRF checked by the router. +header('Content-Type: application/json; charset=UTF-8'); +header('Cache-Control: private, no-store'); + +function rebuilderDeckAltArtsReply(int $status, ?array $body = null): void { + http_response_code($status); + if ($body !== null) echo json_encode($body, JSON_UNESCAPED_SLASHES); +} + +$userId = (int)($_SESSION['user_id'] ?? 0); +$method = $_SERVER['REQUEST_METHOD'] ?? 'GET'; +$body = $method === 'GET' ? [] : pluginApiBody(); +$deckId = $method === 'GET' ? ($_GET['deck'] ?? '') : ($body['deck'] ?? ''); +if ($userId <= 0) { + rebuilderDeckAltArtsReply(401, ['error' => 'unauthenticated']); + return; +} +if (!is_string($deckId) || !preg_match('/^[A-Za-z0-9-]{1,64}$/', $deckId)) { + rebuilderDeckAltArtsReply(400, ['error' => 'deck']); + return; +} + +$db = getDB(); + +if ($method === 'GET') { + $stmt = $db->prepare(q("SELECT family, card1, card2, card3 FROM {rebuilder_deck_alt_arts} WHERE user_id = :u AND deck_id = :d")); + $stmt->execute([':u' => $userId, ':d' => $deckId]); + $families = []; + foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + $families[$row['family']] = [$row['card1'], $row['card2'], $row['card3']]; + } + rebuilderDeckAltArtsReply(200, ['families' => (object)$families]); + return; +} + +// No family: the whole deck goes back to the default alt arts. +if (!array_key_exists('family', $body)) { + $db->prepare(q("DELETE FROM {rebuilder_deck_alt_arts} WHERE user_id = :u AND deck_id = :d")) + ->execute([':u' => $userId, ':d' => $deckId]); + rebuilderDeckAltArtsReply(204); + return; +} + +$family = $body['family']; +if (!is_string($family) || !preg_match('/^\d{1,10}:[A-Z_]{1,20}:[A-Z0-9_]{1,20}$/', $family)) { + rebuilderDeckAltArtsReply(400, ['error' => 'family']); + return; +} +$cards = $body['cards'] ?? null; +if ($cards === null) { + $db->prepare(q("DELETE FROM {rebuilder_deck_alt_arts} WHERE user_id = :u AND deck_id = :d AND family = :f")) + ->execute([':u' => $userId, ':d' => $deckId, ':f' => $family]); + rebuilderDeckAltArtsReply(204); + return; +} +$valid = is_array($cards) && array_values($cards) === $cards && count($cards) === 3; +foreach ($valid ? $cards : [] as $ref) { + if (!is_string($ref) || !preg_match('/^[A-Za-z0-9_]{1,64}$/', $ref)) $valid = false; +} +if (!$valid) { + rebuilderDeckAltArtsReply(400, ['error' => 'cards']); + return; +} +$db->prepare(q( + "INSERT INTO {rebuilder_deck_alt_arts} (user_id, deck_id, family, card1, card2, card3)" + . " VALUES (:u, :d, :f, :c1, :c2, :c3)" + . " ON DUPLICATE KEY UPDATE card1 = VALUES(card1), card2 = VALUES(card2), card3 = VALUES(card3)" +))->execute([':u' => $userId, ':d' => $deckId, ':f' => $family, ':c1' => $cards[0], ':c2' => $cards[1], ':c3' => $cards[2]]); +rebuilderDeckAltArtsReply(204); diff --git a/plugins/rebuilder/papi/my-deck-ids.php b/plugins/rebuilder/papi/my-deck-ids.php new file mode 100644 index 00000000..a2bc9b3b --- /dev/null +++ b/plugins/rebuilder/papi/my-deck-ids.php @@ -0,0 +1,69 @@ + 1, 'itemsPerPage' => 1000])); + $headers = ['Accept: application/json']; + if ($token !== null) $headers[] = 'Authorization: Bearer ' . $token; + curl_setopt_array($ch, [ + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_FOLLOWLOCATION => false, + CURLOPT_ENCODING => '', + CURLOPT_CONNECTTIMEOUT => 5, + CURLOPT_TIMEOUT => 20, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + ]); + $out = curl_exec($ch); + $status = (int)curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + if ($out === false || $status === 0) return null; + return [$status, json_decode((string)$out, true)]; +} + +$userId = (int)($_SESSION['user_id'] ?? 0); +if (!defined('KC_URL') || KC_URL === '' || !defined('DECKS_API_URL') || DECKS_API_URL === '' || $userId <= 0) { + rebuilderMyDeckIdsReply(401, ['error' => 'unauthenticated']); + return; +} + +// Release the session lock during the upstream call (the page's other requests of the same user). +$token = kc_get_access_token($userId) ?: null; +session_write_close(); +$res = rebuilderMyDeckIdsFetch($token); +// A token can be rejected before its expiry (revoked, clock skew): renew once and retry. +if ($res !== null && $res[0] === 401 && $token !== null) { + session_start(); + unset($_SESSION['kc_access_token'], $_SESSION['kc_access_token_exp']); + $token = kc_get_access_token($userId) ?: null; + session_write_close(); + if ($token !== null) $res = rebuilderMyDeckIdsFetch($token); +} + +if ($res === null) { + rebuilderMyDeckIdsReply(502, ['error' => 'service_unreachable']); + return; +} +[$status, $body] = $res; +if ($status !== 200 || !is_array($body)) { + rebuilderMyDeckIdsReply($status >= 400 ? $status : 502, ['error' => 'decks_api', 'status' => $status]); + return; +} +$decks = isset($body['member']) && is_array($body['member']) ? $body['member'] : $body; +$ids = []; +foreach ($decks as $deck) { + if (is_array($deck) && isset($deck['id']) && is_string($deck['id'])) $ids[] = $deck['id']; +} +rebuilderMyDeckIdsReply(200, ['ids' => $ids]); diff --git a/plugins/rebuilder/placeholder.css b/plugins/rebuilder/placeholder.css new file mode 100644 index 00000000..20eba469 --- /dev/null +++ b/plugins/rebuilder/placeholder.css @@ -0,0 +1,155 @@ +/* Skeleton of the Re:Builder screens while the scripts load (placeholder.php). Sizes follow the app's layout + (deck-bar.scss, decks.page.scss, deck-list-view.scss, hand-skeleton.scss…): keep them in step. */ +.rbph { --rbph-panel-width: 300px; } +@media (min-width: 1440px) { .rbph { --rbph-panel-width: var(--ac-deck-panel-width); } } +.rbph-desktop, .rbph-compact { display: flex; flex-direction: column; } +.rbph-row { display: flex; align-items: center; gap: var(--ac-space-2); } +.rbph-row > .ac-skeleton, .rbph-wrap > .ac-skeleton { flex: 0 0 auto; } +.rbph-wrap { flex-wrap: wrap; } +.rbph-grow { flex: 1 1 auto !important; } +.rbph-col12 { display: flex; flex-direction: column; gap: var(--ac-space-3); } +.rbph-h44 { height: 44px; border-radius: var(--ac-radius-lg); } +.rbph-h159 { height: 159px; } +.rbph-h190 { height: 190px; } +.rbph-h200 { height: 200px; } +.rbph-h320 { height: 320px; } +.rbph-mt12 { margin-top: var(--ac-space-3); } +.rbph-icon { --ac-skeleton-width: 36px; } +.rbph-icon24 { width: 24px; height: 24px; } +.rbph-panel { background: var(--ac-color-surface); border: 1px solid var(--ac-color-border); border-radius: var(--ac-radius-card); } +.rbph-tabs { display: flex; align-items: center; gap: var(--ac-space-6); height: 44px; padding: 0 var(--ac-space-3); overflow: hidden; border-bottom: 1px solid var(--ac-color-divider); } +.rbph-tabs > .ac-skeleton { flex: 0 0 auto; } +/* Deck bar (features/deck/deck-bar) and its loading state. */ +/* Deck bar: the heights and the widths where the curve, the terrain totals and the rarities go of features/deck/deck-bar + (its container widths + 70 px, + 66 px reduced: the page's padding, the bar's border and padding). */ +.rbph-bar { display: flex; align-items: center; gap: var(--ac-space-5); min-height: max(calc(3 * var(--ac-control-sm) + 2 * var(--ac-space-1-5) + 2 * var(--ac-space-3) + 2px), calc(1.3 * var(--ac-font-size-caption) + var(--ac-control-sm) + 1.4 * var(--ac-font-size-small) + var(--ac-badge-height) + 1.35 * var(--ac-font-size-body-strong) + 4 * 2px + 2 * var(--ac-space-2) + 2px)); padding-right: var(--ac-space-5); background: var(--ac-color-surface); border: 1px solid var(--ac-color-border); border-radius: var(--ac-radius-card); box-shadow: var(--ac-shadow-card); } +.rbph-bar--reduced { gap: var(--ac-space-4); min-height: calc(1.3 * var(--ac-font-size-caption) + 1.3 * var(--ac-font-size-heading) + 1.4 * var(--ac-font-size-small) + 2 * 2px + 2 * var(--ac-space-2) + 2px); padding-right: var(--ac-space-4); } +.rbph-bar__art { align-self: stretch; flex: 0 0 150px; border-radius: var(--ac-radius-card) 0 0 var(--ac-radius-card); } +.rbph-bar--reduced .rbph-bar__art { flex-basis: 96px; } +.rbph-bar__text { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; justify-content: center; gap: 2px; align-self: stretch; padding: var(--ac-space-2) var(--ac-space-4); border-left: 4px solid var(--ac-color-track); } +.rbph-bar--reduced .rbph-bar__text { padding-inline: var(--ac-space-3); } +.rbph-bar__name { display: flex; align-items: center; min-height: var(--ac-control-sm); } +.rbph-bar--reduced .rbph-bar__name { min-height: 0; } +.rbph-bar__badges { display: flex; gap: var(--ac-space-1-5); } +.rbph-bar__sep { align-self: stretch; width: 1px; flex-shrink: 0; margin: var(--ac-space-3) 0; background: var(--ac-color-divider); } +.rbph-bar__curve { flex: 0 0 181px; height: 125px; } +.rbph-bar__terrain { flex: 0 0 216px; height: 103px; } +.rbph-bar--reduced .rbph-bar__curve { flex-basis: 145px; height: 51px; } +.rbph-bar__limits { flex: 0 1 230px; height: 30px; } +.rbph-bar--reduced .rbph-bar__terrain { flex-basis: 162px; height: 30px; } +.rbph-bar__actions { flex-shrink: 0; margin-left: auto; padding: var(--ac-space-3) 0; display: grid; grid-template-columns: repeat(2, 170px); gap: var(--ac-space-1-5); } +.rbph-bar--reduced .rbph-bar__actions { display: flex; padding: var(--ac-space-2) 0; } +@media (max-width: 1274px) { .rbph-bar:not(.rbph-bar--reduced) :is(.rbph-bar__curve, .rbph-bar__sep--curve) { display: none; } } +@media (max-width: 1063px) { .rbph-bar:not(.rbph-bar--reduced) :is(.rbph-bar__terrain, .rbph-bar__sep) { display: none; } } +@media (max-width: 1475px) { .rbph-bar--reduced :is(.rbph-bar__curve, .rbph-bar__sep--curve) { display: none; } } +@media (max-width: 1297px) { .rbph-bar--reduced :is(.rbph-bar__terrain, .rbph-bar__sep--terrain) { display: none; } } +@media (max-width: 1086px) { .rbph-bar--reduced :is(.rbph-bar__limits, .rbph-bar__sep) { display: none; } } +/* Editor (features/editor/editor-page) from 768 px. */ +.rbph-editor { display: flex; flex-direction: row; flex-wrap: wrap; align-items: flex-start; gap: var(--ac-space-4) var(--ac-space-6); padding: var(--ac-space-6); } +.rbph-editor__top { flex: 0 0 100%; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-modes { display: flex; align-items: center; justify-content: space-between; height: 48px; } +.rbph-modes__segmented { width: 377px; height: 48px; border-radius: var(--ac-radius-lg); } +.rbph-editor__main { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-deckpanel { width: var(--rbph-panel-width); flex-shrink: 0; padding: var(--ac-space-2) var(--ac-space-3) var(--ac-space-4); } +.rbph-row34 { display: flex; align-items: center; height: 34px; padding: 0 var(--ac-space-1); } +.rbph-row36 { display: flex; align-items: center; justify-content: space-between; gap: var(--ac-space-3); height: 36px; padding: 0 var(--ac-space-1); } +.rbph-search { display: flex; gap: var(--ac-space-6); align-items: flex-start; } +.rbph-filters { width: 320px; flex-shrink: 0; height: calc(100vh - 384px); min-height: 360px; padding: var(--ac-space-4); display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-results { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: var(--ac-space-4); } +.rbph-toolbar { height: 40px; } +.rbph-chip { height: 32px; border-radius: var(--ac-radius-pill); } +.rbph-results__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: var(--ac-space-2); } +.rbph-board { flex: 1 1 0; min-width: 0; padding: var(--ac-space-5) 0; } +.rbph-board__group { display: flex; flex-direction: column; gap: var(--ac-space-3); padding: 0 var(--ac-space-5); max-width: calc(4 * 260px + 3 * var(--ac-space-3) + 2 * var(--ac-space-5)); } +.rbph-board__cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--ac-space-4) var(--ac-space-3); } +.rbph-decktab { flex-shrink: 0; margin-right: calc(-1 * var(--ac-space-6)); cursor: default; } +.rbph-decktab .ac-drawer-tab__label { width: auto; color: transparent; border-radius: var(--ac-radius-pill); } +.rbph-icon16 { width: 16px; height: 16px; } +.rbph-decktab__count { display: inline-block; min-inline-size: 2ch; text-align: center; font-variant-numeric: tabular-nums; } +.rbph-hand { display: flex; flex-direction: column; gap: var(--ac-space-6); } +.rbph-hand__cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 28vw, 150px), 1fr)); gap: var(--ac-space-3); } +.rbph-hand__panels { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--ac-space-3); } +/* Deck page from 768 px. */ +.rbph-deckpage { display: flex; flex-direction: column; gap: var(--ac-space-5); padding: var(--ac-space-6); } +.rbph-deckpage .rbph-board { flex: none; } +/* Decks list (features/decks/decks-page) from 768 px. */ +.rbph-decks { display: flex; flex-direction: column; padding: 28px 32px var(--ac-space-8); max-width: 1600px; margin: 0 auto; } +.rbph-decks__head { display: flex; align-items: center; gap: var(--ac-space-2); height: 40px; margin-bottom: 20px; } +/* features/decks/decks-page .builders-banner */ +.rbph-builders { display: flex; align-items: center; gap: var(--ac-space-4); margin-bottom: 20px; padding: var(--ac-space-3) var(--ac-space-4); border: 1px solid var(--ac-color-border); border-left: 3px solid var(--ac-color-primary); border-radius: var(--ac-radius-md); background: var(--ac-color-bg-subtle); font-size: var(--ac-font-size-small); color: var(--ac-color-text-2); } +.rbph-builders p { flex: 1; margin: 0; display: flex; align-items: center; gap: var(--ac-space-2); } +.rbph-builders p > svg { --ac-icon-size: 16px; flex-shrink: 0; color: var(--ac-color-primary); } +.rbph-decks__filters { display: flex; align-items: center; gap: var(--ac-space-2); height: 40px; margin: 16px 0 12px; } +.rbph-decks__factions { display: flex; align-items: center; gap: var(--ac-space-2); height: 32px; margin-bottom: 20px; } +.rbph-decks__grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(232px, 1fr)); gap: var(--ac-space-4); } +.rbph-decks__grid > .ac-skeleton { height: 227px; } +/* New deck: the window over the page. */ +/* « Nouveau deck »: the page's block (new-deck.page.scss) and the overlay's window (ui/overlay, new-deck.overlay.scss). */ +.rbph-new { height: calc(100dvh - var(--ac-sticky-top)); } +.rbph-new__backdrop { height: 100%; gap: var(--ac-space-4); padding: var(--ac-space-6); } +.rbph-new__bar { width: 30%; height: 34px; border-radius: var(--ac-radius-control); background: var(--ac-color-track); } +.rbph-new__bar--wide { width: 70%; } +.rbph-new__cols { flex-grow: 1; display: grid; grid-template-columns: 190px 1fr 240px; gap: var(--ac-space-4); } +.rbph-new__cols > span { border-radius: var(--ac-radius-card); background: var(--ac-color-track); } +.rbph-new__scrim { position: fixed; inset: 0; z-index: var(--ac-z-overlay); background: var(--ac-color-scrim); } +.rbph-new__window { position: fixed; z-index: var(--ac-z-overlay); top: var(--ac-space-10); left: 50%; transform: translateX(-50%); width: min(1080px, calc(100vw - 32px)); height: calc(100dvh - 2 * var(--ac-space-10)); display: flex; flex-direction: column; overflow: hidden; border-radius: var(--ac-radius-dialog); background: var(--ac-color-surface); box-shadow: var(--ac-shadow-dialog); } +.rbph-new__head { display: flex; align-items: center; gap: var(--ac-space-3); flex-shrink: 0; min-height: 57px; padding: 14px 12px 14px 20px; border-bottom: 1px solid var(--ac-color-border); } +.rbph-new__body { flex: 1 1 auto; min-height: 0; display: flex; overflow: hidden; } +.rbph-new__heroes, .rbph-new__side { display: flex; flex-direction: column; gap: 14px; padding: var(--ac-space-5) var(--ac-space-6); } +.rbph-new__heroes { flex: 1 1 auto; min-width: 0; border-right: 1px solid var(--ac-color-border); } +.rbph-new__side { flex: 0 0 calc(360px + 2 * var(--ac-space-6)); gap: var(--ac-space-3); } +.rbph-new__chips { display: flex; flex-wrap: wrap; gap: var(--ac-space-2); } +.rbph-new__cards { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 18px; } +.rbph-new__foot { display: flex; align-items: center; gap: var(--ac-space-2); flex-shrink: 0; padding: 14px var(--ac-space-6); border-top: 1px solid var(--ac-color-border); background: var(--ac-color-bg-subtle); } +.rbph-h56 { height: 56px; } +@media (max-width: 767.98px) { + .rbph-new__scrim { display: none; } + .rbph-new__window { inset: 0; transform: none; width: 100vw; height: 100dvh; border-radius: 0; box-shadow: none; } + .rbph-new__body { flex-direction: column; gap: 18px; padding: var(--ac-space-4); } + .rbph-new__heroes, .rbph-new__side { padding: 0; border: 0; flex: none; } + .rbph-new__chips { flex-wrap: nowrap; overflow: hidden; } + .rbph-new__chips > * { flex-shrink: 0; } + .rbph-new__cards { display: flex; gap: var(--ac-space-3); overflow: hidden; } + .rbph-new__cards > * { flex: 0 0 112px; } + .rbph-new__foot { flex-direction: column-reverse; align-items: stretch; background: none; padding: var(--ac-space-3) var(--ac-space-4); } + .rbph-new__foot > .ac-skeleton { width: 100%; } + .rbph-new__foot > .rbph-grow { display: none; } +} +@media (min-width: 768px) { .rbph-compact-only { display: none; } } +/* Phones. */ +.rbph-appbar { display: flex; align-items: center; gap: var(--ac-space-1); height: var(--ac-app-bar-height); padding-inline: var(--ac-space-1-5); background: var(--ac-color-surface); border-bottom: 1px solid var(--ac-color-border); } +.rbph-back { --ac-icon-size: 22px; flex-shrink: 0; } +.rbph-hero { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: var(--ac-hit-min); height: var(--ac-hit-min); } +.rbph-hero__art { width: 36px; height: 36px; border-radius: var(--ac-radius-pill); box-shadow: 0 0 0 2px var(--ac-color-surface), 0 0 0 3px var(--ac-color-border-control); } +.rbph-appbar__titles { flex: 1 1 auto; min-width: 0; min-height: var(--ac-hit-min); display: flex; flex-direction: column; justify-content: center; gap: 3px; padding: 0 var(--ac-space-1); } +.rbph-appbar__title { font-size: var(--ac-font-size-title-sm); font-weight: 800; line-height: 1.2; white-space: nowrap; } +.rbph-limits { display: flex; gap: var(--ac-space-1); } +.rbph-appbar__actions { display: flex; align-items: center; padding-right: 4px; } +.rbph-appbar__actions > .ac-skeleton { margin: 0 var(--ac-space-1); } +.rbph-save { min-width: var(--ac-hit-min); } +.rbph-mpage { display: flex; flex-direction: column; padding: var(--ac-space-3) var(--ac-page-padding) calc(var(--ac-bottom-nav-height) + var(--ac-space-4)); } +.rbph-bleed { margin-inline: calc(-1 * var(--ac-page-padding)); } +.rbph-mchips { display: flex; gap: var(--ac-space-2); height: 36px; padding: 0 var(--ac-page-padding); overflow: hidden; } +.rbph-mchips > .ac-skeleton { flex: 0 0 auto; height: 36px; border-radius: var(--ac-radius-pill); } +.rbph-mcards { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--ac-space-3); } +.rbph-msection { display: flex; flex-direction: column; gap: var(--ac-space-3); padding: var(--ac-space-3); } +.rbph-msection .rbph-mcards { gap: var(--ac-space-2-5); } +.rbph-msection .rbph-dense { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--ac-space-2); } +.rbph-mhead { display: flex; align-items: center; height: 44px; } +.rbph-mlist { display: flex; flex-direction: column; gap: var(--ac-space-3); } +.rbph-mlist > .ac-skeleton { height: 149px; } +.rbph-row51 { display: flex; align-items: center; justify-content: space-between; height: 51px; } +.rbph-nav { position: fixed; z-index: 1; left: 0; right: 0; bottom: 0; display: flex; justify-content: space-around; align-items: center; height: calc(var(--ac-bottom-nav-height) + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); background: var(--ac-color-surface); border-top: 1px solid var(--ac-color-border); } +.rbph-nav__item { display: flex; flex-direction: column; align-items: center; gap: var(--ac-space-1); width: 56px; } +.rbph-nav__item .ac-skeleton--circle { --ac-skeleton-width: 24px; } +@media (max-width: 767.98px) { .rbph-desktop { display: none; } } +@media (min-width: 768px) { .rbph-compact { display: none; } } +/* Rows and stacks of the phone screens (sizes of the app's search toolbar, preview bar, deck summary). */ +.rbph-row44 { height: 44px; margin: var(--ac-space-3) 0; } +.rbph-decks .rbph-row44, .rbph-mpage--decks .rbph-row44 { margin: var(--ac-space-4) 0; } +.rbph-row36 { height: 36px; } +.rbph-mb16 { margin-bottom: var(--ac-space-4); } +.rbph-gap3 { gap: var(--ac-space-3); } +.rbph-gap4 { gap: var(--ac-space-4); } +.rbph-summary { height: 132px; } +.rbph-summary__meta { flex: 1; gap: var(--ac-space-1-5); } diff --git a/plugins/rebuilder/placeholder.php b/plugins/rebuilder/placeholder.php new file mode 100644 index 00000000..d7e63191 --- /dev/null +++ b/plugins/rebuilder/placeholder.php @@ -0,0 +1,231 @@ + 'decklist', 'description' => 'description', 'main' => 'main'][$tab] ?? 'cartes'; + +/** `$n` skeletons with `$class` (variants and layout classes) and an optional `--ac-skeleton-width`. */ +$sk = function (string $class = '', int $n = 1, string $width = ''): string { + $style = $width !== '' ? ' style="--ac-skeleton-width: ' . $width . '"' : ''; + return str_repeat('', $n); +}; +/** Grid of card illustrations (5:7). */ +$cards = function (string $class, int $n) use ($sk): string { + return '
    ' . $sk('ac-skeleton--card', $n) . '
    '; +}; +/** Deck bar (from 768 px): hero art, name, curve, rarity limits, terrain totals, actions (six in two columns of 170 px, + * or the reduced bar's « Partager », « Copier en image », « ⋯ » at their width); `reduced` in the editor. */ +$deckBar = function (bool $reduced) use ($sk): string { + return '
    ' + . '' + . '
    ' . $sk('ac-skeleton--text', 1, '70px') . '
    ' . $sk('ac-skeleton--title', 1, '70%') . '
    ' . $sk('ac-skeleton--text', 1, '60%') + . ($reduced ? '' : '
    ' . $sk('ac-skeleton--badge', 1, '90px') . $sk('ac-skeleton--badge', 1, '64px') . '
    ' . $sk('ac-skeleton--text', 1, '50%')) + . '
    ' + . '' + . ($reduced ? '' : '') + . '' + . '
    ' . ($reduced ? $sk('ac-skeleton--control-sm', 1, '95px') . $sk('ac-skeleton--control-sm', 1, '141px') . $sk('ac-skeleton--control-sm', 1, 'var(--ac-control-sm)') : $sk('ac-skeleton--control-sm', 6)) . '
    ' + . '
    '; +}; +/** + * Phones: app bar as the app draws it while the page opens (ui/nav/app-bar, editor and deck pages): the back button, + * the hero in skeleton (editor, search and « Aperçu »), `$titles`, the editor's save status (an empty slot while idle), + * `$icons` actions in skeleton. + */ +$appBar = function (string $titles, int $icons, bool $back = true, bool $hero = false, bool $save = false) use ($sk): string { + return '
    ' + . ($back ? '' . ac_icon('chevron-left') . '' : '') + . ($hero ? '' : '') + . '
    ' . $titles . '
    ' + . '
    ' . ($save ? '' : '') . $sk('ac-skeleton--circle rbph-icon', $icons) . '
    '; +}; +/** App bar titles: a known title, or the deck's name over its rarities (three badges) or over a line of text. */ +$barTitle = fn(string $text): string => '' . h($text) . ''; +$barDeck = fn(bool $rarities): string => $sk('ac-skeleton--title', 1, '70%') + . ($rarities ? '' . $sk('ac-skeleton--badge', 3, '52px') . '' : $sk('ac-skeleton--text', 1, '50%')); +/** Phones: bottom navigation with `$n` entries. */ +$bottomNav = function (int $n) use ($sk): string { + return '
    ' . str_repeat('' . $sk('ac-skeleton--circle') . $sk('ac-skeleton--text') . '', $n) . '
    '; +}; +/** A row of tabs on a divider. */ +$tabs = function (array $widths, string $class = '') use ($sk): string { + $out = '
    '; + foreach ($widths as $w) $out .= $sk('ac-skeleton--text', 1, $w); + return $out . '
    '; +}; +/** Deck panel (editor, from 768 px): a type and its rows. */ +$deckPanel = function () use ($sk): string { + $rows = str_repeat('
    ' . $sk('ac-skeleton--text', 1, '60%') . $sk('ac-skeleton--text', 1, '56px') . '
    ', 5); + return '
    ' . $sk('ac-skeleton--text', 1, '35%') . '
    ' . $rows . '
    '; +}; +/** « Main de départ »: toolbar, summary, hand, stats panels (features/shared/hand-skeleton). */ +$hand = function (bool $compact) use ($sk): string { + return '
    ' . $sk('ac-skeleton--control', 1, '150px') . $sk('ac-skeleton--control', 1, '170px') . ($compact ? '' : $sk('ac-skeleton--control', 1, '120px')) . '
    ' + . $sk('ac-skeleton--text', 1, 'min(320px, 80%)') . '
    ' . $sk('ac-skeleton--card', 6) . '
    ' + . '
    ' . $sk('ac-skeleton--text', 1, '200px') . '
    ' . $sk('ac-skeleton--panel rbph-h200', 3) . '
    '; +}; +/** Phones: a type and its card rows (« Deck » / « Decklist »). */ +$listSection = function () use ($sk): string { + return '
    ' . $sk('ac-skeleton--text', 1, '35%') . '
    ' + . str_repeat('
    ' . $sk('ac-skeleton--text', 1, '55%') . $sk('ac-skeleton--text', 1, '80px') . '
    ', 4) . '
    '; +}; +/** Board (« Aperçu » from 768 px): one group of four piles. */ +$board = function () use ($sk): string { + return '
    ' . $sk('ac-skeleton--text', 1, '140px') . '
    ' . $sk('ac-skeleton--card', 4) . '
    '; +}; +?> + here holds the paint of everything after it until it downloads, the site header + // alone on screen meanwhile on a first visit. ?> + + + +
    + + + + + + + +
    + +
    +
    + +
    + + + +
    + +
    +
    + + + + +
    + +
    + +
    +
    + +
    +
    + + + + Deck · 00 + +
    + + + + + +
    +
    +
    +
    +
    + +
    + + +
    +
    + + + + + +
    + +
    + +
    +
    + + +
    +
    + +
    + + + + +
    + + + +
    + +
    + +
    diff --git a/plugins/rebuilder/plugin.json b/plugins/rebuilder/plugin.json index 1a88dc6b..32d75976 100644 --- a/plugins/rebuilder/plugin.json +++ b/plugins/rebuilder/plugin.json @@ -16,6 +16,7 @@ "mount": "shadow", "fullwidth": false, "meta": "meta.php", + "placeholder": "placeholder.php", "beta_slugs": ["decks", "deck", "deckbuilder"], "title_en": "Re:Builder", "title_fr": "Re:Builder" @@ -23,7 +24,9 @@ ], "api": [ - { "endpoint": "community-builders", "file": "papi/community-builders.php", "methods": ["GET"] } + { "endpoint": "community-builders", "file": "papi/community-builders.php", "methods": ["GET"] }, + { "endpoint": "my-deck-ids", "file": "papi/my-deck-ids.php", "methods": ["GET"], "auth": "user" }, + { "endpoint": "deck-alt-arts", "file": "papi/deck-alt-arts.php", "methods": ["GET", "POST"], "auth": "user" } ], "build": { diff --git a/plugins/tournament-reports/assets/app.js b/plugins/tournament-reports/assets/app.js index fc45e439..d95aef02 100644 --- a/plugins/tournament-reports/assets/app.js +++ b/plugins/tournament-reports/assets/app.js @@ -1053,7 +1053,11 @@ }); var btn = el.querySelector('.tr-chart-detail-btn'); var modalTitle = TR_TXT.chart_hero_modal_title || title; - if (btn) btn.addEventListener('click', function () { openChartModal(allSegments, modalTitle); }); + if (btn) btn.addEventListener('click', function () { + openChartModal(allSegments, modalTitle); + // For the site's hidden creatures (plugins/hidden-creatures): a spot can wait for this event. + window.dispatchEvent(new CustomEvent('ac-hidden-spot', { detail: 'tournament-heroes' })); + }); } /* ── Filters (GameApi tournaments only) ───────────────────────────────── */ diff --git a/sql/migration_009_rebuilder_deck_alt_arts.sql b/sql/migration_009_rebuilder_deck_alt_arts.sql new file mode 100644 index 00000000..2c34ed96 --- /dev/null +++ b/sql/migration_009_rebuilder_deck_alt_arts.sql @@ -0,0 +1,14 @@ +-- Re:Builder's brush: the illustration a deck gives the 1st, 2nd and 3rd card (copy) of a multi-art card, in order. +-- The decks API keeps only the deck's references, with no order and nothing past its copies; the player's default alt +-- arts stay in the ownership service. By player, deck and card family (ownership key « familyId:faction:rarity »); +-- endpoint papi/rebuilder/deck-alt-arts. +CREATE TABLE IF NOT EXISTS `{prefix}rebuilder_deck_alt_arts` ( + `user_id` INT NOT NULL, + `deck_id` VARCHAR(64) NOT NULL, + `family` VARCHAR(64) NOT NULL, + `card1` VARCHAR(64) NOT NULL, + `card2` VARCHAR(64) NOT NULL, + `card3` VARCHAR(64) NOT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `deck_id`, `family`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; diff --git a/sql/schema.sql b/sql/schema.sql index bae5b2b9..81f8bc81 100644 --- a/sql/schema.sql +++ b/sql/schema.sql @@ -346,6 +346,21 @@ CREATE TABLE IF NOT EXISTS `{prefix}newsletter_sub` ( UNIQUE KEY `uq_newsletter_email` (`email`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; +-- ─── Default alt arts ───────────────────────────────────────────────────────── +-- Re:Builder's brush: the illustration a deck gives the 1st, 2nd and 3rd card of a multi-art card +-- (papi/rebuilder/deck-alt-arts). + +CREATE TABLE IF NOT EXISTS `{prefix}rebuilder_deck_alt_arts` ( + `user_id` INT NOT NULL, + `deck_id` VARCHAR(64) NOT NULL, + `family` VARCHAR(64) NOT NULL, + `card1` VARCHAR(64) NOT NULL, + `card2` VARCHAR(64) NOT NULL, + `card3` VARCHAR(64) NOT NULL, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `deck_id`, `family`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; + -- ─── Plugins ────────────────────────────────────────────────────────────────── CREATE TABLE IF NOT EXISTS `{prefix}plugins` ( diff --git a/tests/CollectionQueryTest.php b/tests/CollectionQueryTest.php new file mode 100644 index 00000000..c5c7beee --- /dev/null +++ b/tests/CollectionQueryTest.php @@ -0,0 +1,18 @@ + ['AX'], 'rarity' => ['COMMON', 'RARE', 'EXALTED'], 'cardType' => ['CHARACTER', 'SPELL'], + 'cardSet' => ['CORE', 'ALIZE'], 'locale' => 'fr', 'page' => '2', 'itemsPerPage' => '36', + ]), + 'list filters keep every value' +); +assertSame('faction[]=AX&name=Red%20Axiom', cacCollectionQuery(['faction' => 'AX', 'name' => 'Red Axiom', 'rarity' => ['']]), 'a single value, empty values dropped'); +assertSame('mainCost[gte]=1&mainCost[lte]=3', cacCollectionQuery(['mainCost' => ['gte' => '1', 'lte' => '3', 'eq' => '2']]), 'cost range'); +assertSame('', cacCollectionQuery([]), 'no filter'); +assertSame('cardType[]=SPELL', cacCollectionQuery(['cardType' => ['SPELL', '']]), 'one card type goes to the API'); +assertSame(null, cacCollectionLocalTypes(['cardType' => ['SPELL']]), 'one card type: the API filters'); +assertSame(['CHARACTER', 'SPELL'], cacCollectionLocalTypes(['cardType' => ['CHARACTER', '', 'SPELL']]), 'several card types: the proxy filters'); diff --git a/tests/DesignSystemTest.php b/tests/DesignSystemTest.php index 6a2fb473..8334110f 100644 --- a/tests/DesignSystemTest.php +++ b/tests/DesignSystemTest.php @@ -225,3 +225,17 @@ function dsStrayHovers(string $path, string $root): array { assertSame(true, dsToken('--ac-color-primary', 'dark') !== null && dsToken('--ac-color-primary', 'dark') !== dsToken('--ac-color-primary'), "dsToken() reads the dark theme block"); assertSame('#1050aa', dsToken('--ac-header-border'), "dsToken() resolves a var() value (light header border = primary-strong)"); assertSame(dsToken('--ac-color-primary-strong', 'dark'), dsToken('--ac-color-link', 'dark'), "dsToken() resolves a var() value in the dark theme"); + +// ---- Masked e-mail address (tooltip.css) ---- +assertSame('c•••@gmail.com', dsMaskEmail('camille.durand@gmail.com'), 'dsMaskEmail keeps the first character and the domain'); +assertSame('é•••@exemple.fr', dsMaskEmail('élodie@exemple.fr'), 'dsMaskEmail keeps a multibyte first character whole'); +foreach (['', 'camille', '@gmail.com', 'camille@'] as $notAnAddress) { + assertSame('•••', dsMaskEmail($notAnAddress), 'dsMaskEmail hides ' . var_export($notAnAddress, true) . ' entirely'); +} +$masked = ac_masked_email('a@x.fr', 'ac-tooltip--below'); +assertSame(true, strpos($masked, 'a•••@x.fr') === false, 'ac_masked_email escapes the address'); + +// ---- The newsletter field is never filled with the account's address ---- +$nl = (string)file_get_contents($root . '/includes/newsletter_sub.php'); +assertSame(0, preg_match('/name="nl_email"[^>]*value=/s', $nl), 'the homepage newsletter field has no value'); diff --git a/tests/SpaPreloadTest.php b/tests/SpaPreloadTest.php new file mode 100644 index 00000000..60594ee6 --- /dev/null +++ b/tests/SpaPreloadTest.php @@ -0,0 +1,70 @@ + 'demo', '_dir' => $dir]; +$page = ['slug' => 'demo', 'type' => 'spa', 'entry' => 'dist/embed-manifest.json']; +$assets = '/plugins/demo/dist/browser/'; + +$writeManifest([ + 'version' => 1, 'base' => 'browser/', 'js' => ['polyfills-A.js', 'main-B.js'], 'css' => ['embed.css?v=1'], + 'documentCss' => ['document.css?v=2'], + 'preload' => [ + '*' => ['chunk-boot.js', 'chunk-shared.js'], + 'deck' => ['chunk-shared.js', 'chunk-deck.js'], + 'decks' => ['chunk-decks.js'], + 'lang:en' => ['chunk-en.js'], + 'bad' => ['../escape.js', '/abs.js', 'style.css', 42], + ], +]); + +// A beta page keeps the slug it is served at (pluginFindBetaPage()). +$deck = ['slug' => 'deck'] + spaResolvePage($plugin, $page); +$GLOBALS['__test_ui_lang'] = 'fr'; +assertSame([ + ['href' => $assets . 'polyfills-A.js', 'as' => 'module'], + ['href' => $assets . 'main-B.js', 'as' => 'module'], + ['href' => $assets . 'chunk-boot.js', 'as' => 'module'], + ['href' => $assets . 'chunk-shared.js', 'as' => 'module'], + ['href' => $assets . 'chunk-deck.js', 'as' => 'module'], + ['href' => $assets . 'embed.css?v=1', 'as' => 'style'], +], spaPreloads($deck), 'deck page, French: entry modules, "*" and its slug\'s modules once each, then the shadow root CSS'); + +$GLOBALS['__test_ui_lang'] = 'en'; +$hrefs = array_column(spaPreloads($deck), 'href'); +assertSame(true, in_array($assets . 'chunk-en.js', $hrefs, true), 'English: the translations module'); +assertSame(true, !in_array($assets . 'chunk-decks.js', $hrefs, true), 'another slug\'s modules are left out'); + +$bad = spaResolvePage($plugin, $page)['spa']['preload']['bad'] ?? []; +assertSame([], $bad, 'paths outside the build, absolute paths and non-modules are dropped'); + +assertSame('ac-spa-demo', spaMountId($deck), 'mount point id'); +assertSame('ac-spa-demo-parsed', spaFirstPaintId($deck), 'element after the mount point, named by the header\'s render-blocking '); + +// Light DOM: the plugin's CSS is not loaded by the runtime, no style preload. +$light = ['slug' => 'demo'] + spaResolvePage($plugin, ['mount' => 'light'] + $page); +assertSame([], array_values(array_filter(spaPreloads($light), fn($p) => $p['as'] === 'style')), 'light mount: no style preload'); + +// A manifest without `preload` (older builds) still renders: entry modules only. +$writeManifest(['version' => 1, 'js' => ['main.js'], 'css' => [], 'documentCss' => []]); +$plain = ['slug' => 'demo'] + spaResolvePage($plugin, $page); +assertSame([['href' => '/plugins/demo/dist/main.js', 'as' => 'module']], spaPreloads($plain), 'no preload key: entry modules only'); + +// A page that cannot render announces nothing. +@unlink($dir . '/dist/embed-manifest.json'); +$broken = ['slug' => 'demo'] + spaResolvePage($plugin, $page); +assertSame([], spaPreloads($broken), 'missing build: no preload'); +assertSame(null, spaMountId($broken), 'missing build: no mount point'); +assertSame(null, spaFirstPaintId($broken), 'missing build: nothing to wait for before the first paint'); + +@rmdir($dir . '/dist'); +@rmdir($dir); diff --git a/tests/e2e/.gitignore b/tests/e2e/.gitignore index dcd2b3e9..ebee5e50 100644 --- a/tests/e2e/.gitignore +++ b/tests/e2e/.gitignore @@ -2,3 +2,4 @@ /test-results/ /playwright-report/ /evidence/ +/cls-warnings/ diff --git a/tests/e2e/loading-matrix.ts b/tests/e2e/loading-matrix.ts new file mode 100644 index 00000000..0e6212bd --- /dev/null +++ b/tests/e2e/loading-matrix.ts @@ -0,0 +1,118 @@ +#!/usr/bin/env node +// The CLS of every Re:Builder screen at several screen sizes, first and second visit, in one table: to find the shifts a +// change brings or removes across the breakpoints (loading.spec.ts checks two sizes only). Each cell is the largest +// CLS of `--runs` loads; the JSON has the shifts (boxes that moved) and the recorder's issues of every load. +// +// node tests/e2e/loading-matrix.ts [options] +// --base site to load (default E2E_BASE_URL or http://localhost:8080) +// --screens a,b screens of plugins/rebuilder/e2e/screens.ts (default: all) +// --viewports a,b sizes of VIEWPORTS in loading.ts (default: all) +// --visits a,b first, second (default: both) +// --runs loads per cell (default 1) +// --profile

    phone (default), slowPhone, fast +// --out default test-results/loading/matrix.json +// +// Node ≥ 22.18 (TypeScript type stripping). Run from tests/e2e (its node_modules) against the full stack. +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { chromium, type Browser, type BrowserContextOptions } from '@playwright/test'; +import { VIEWPORTS, emptyCache, loadingReport, throttle, watchLoading, type LoadingReport, type Profile } from './loading.ts'; +import { createServerDeck } from '../../plugins/rebuilder/e2e/decks.ts'; +import { FORBIDDEN_TEXTS, SCREENS } from '../../plugins/rebuilder/e2e/screens.ts'; + +const args = process.argv.slice(2); +const option = (name: string, fallback?: string) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : fallback; +}; +const list = (name: string, all: string[]) => { + const picked = option(name)?.split(',') ?? all; + const unknown = picked.filter((p) => !all.includes(p)); + if (unknown.length) throw new Error(`--${name}: unknown ${unknown.join(', ')} (${all.join(', ')})`); + return picked; +}; +const base = option('base', process.env['E2E_BASE_URL'] ?? 'http://localhost:8080')!; +const screens = list('screens', SCREENS.map((s) => s.key)).map((k) => SCREENS.find((s) => s.key === k)!); +const viewports = list('viewports', Object.keys(VIEWPORTS)); +const visits = list('visits', ['first', 'second']); +const runs = Number(option('runs', '1')); +const profile = option('profile', 'phone') as Profile; +const out = resolve(option('out', 'test-results/loading/matrix.json')!); +const PASSWORD = 'TestPassword1234'; // docker/stack/players-realm.json + +type State = BrowserContextOptions['storageState']; + +/** Signs alice in once (the contexts of the loads reuse her cookies) and creates the deck of the deck screens. */ +async function signIn(browser: Browser): Promise<{ state: State; deckId: string }> { + const context = await browser.newContext(); + await context.addCookies([{ name: 'alteredcore_consent', value: '1', url: base }, { name: 'ac_beta', value: '1', url: base }]); + const page = await context.newPage(); + await page.goto(`${base}/auth/keycloak-login?return=${encodeURIComponent('/pages/decks')}`); + await page.locator('#username').fill('alice'); + await page.locator('#password').fill(PASSWORD); + await page.locator('#kc-login').click(); + await page.waitForURL((u) => u.pathname.startsWith('/pages/decks')); + await page.locator('[data-ac-plugin]').waitFor(); + const deckId = screens.some((s) => s.deck) ? await createServerDeck(page, `E2E matrix ${Date.now()}`) : ''; + const state = await context.storageState(); + await context.close(); + return { state, deckId }; +} + +/** One load of `url` at `size`, recorded: a first visit (empty cache) or a second one (the page loaded once before). */ +async function load(browser: Browser, state: State, size: string, url: string, visit: string): Promise { + const context = await browser.newContext({ ...VIEWPORTS[size], storageState: state, serviceWorkers: 'block' }); + const page = await context.newPage(); + const settle = async () => { + await page.locator('.ac-spa-placeholder').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => undefined); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => undefined); + await page.waitForTimeout(1000); + }; + if (visit === 'second') { + await page.goto(url); + await settle(); + } else { + await page.goto(`${base}/`); + await emptyCache(page); + } + await watchLoading(page, FORBIDDEN_TEXTS); + await throttle(page, profile); + await page.goto(url); + await settle(); + const report = await loadingReport(page); + await context.close(); + return report; +} + +mkdirSync(dirname(out), { recursive: true }); +const browser = await chromium.launch(); +const { state, deckId } = await signIn(browser); +const results: { screen: string; viewport: string; visit: string; reports: LoadingReport[] }[] = []; +for (const screen of screens) { + const path = screen.path(deckId); + const url = new URL(`${path}${path.includes('?') ? '&' : '?'}lang=fr`, base).href; + for (const viewport of viewports) { + for (const visit of visits) { + const reports: LoadingReport[] = []; + for (let i = 0; i < runs; i++) reports.push(await load(browser, state, viewport, url, visit)); + results.push({ screen: screen.key, viewport, visit, reports }); + const worst = Math.max(...reports.map((r) => r.cls)); + const issues = [...new Set(reports.flatMap((r) => r.issues.map((x) => x.kind)))]; + console.log(`${screen.key.padEnd(9)} ${viewport.padEnd(9)} ${visit.padEnd(6)} CLS ${worst.toFixed(4)}${issues.length ? ` ${issues.join(', ')}` : ''}`); + for (const s of reports.flatMap((r) => r.shifts).filter((x) => x.value >= 0.001)) console.log(` ${s.t} ms ${s.value}: ${s.boxes.slice(0, 3).join('; ')}`); + } + } +} +await browser.close(); +writeFileSync(out, JSON.stringify({ base, profile, runs, results }, null, 2)); + +// Table: one row per screen and visit, one column per size; the cells over 0.01 are marked. +const head = ['screen', 'visit', ...viewports]; +const rows = screens.flatMap((s) => visits.map((v) => [s.key, v, ...viewports.map((vp) => { + const cell = results.find((r) => r.screen === s.key && r.viewport === vp && r.visit === v)!; + const cls = Math.max(...cell.reports.map((r) => r.cls)); + return `${cls > 0.01 ? '*' : ''}${cls.toFixed(3)}${cell.reports.some((r) => r.issues.length) ? '!' : ''}`; +})])); +const widths = head.map((h, i) => Math.max(h.length, ...rows.map((r) => r[i].length))); +console.log(`\n${[head, ...rows].map((r) => r.map((c, i) => c.padEnd(widths[i])).join(' ')).join('\n')}`); +console.log(`\n* over 0.01 ! issue (empty frame, unstyled, font, text): see ${out}`); diff --git a/tests/e2e/loading-timeline.ts b/tests/e2e/loading-timeline.ts new file mode 100644 index 00000000..d0298e50 --- /dev/null +++ b/tests/e2e/loading-timeline.ts @@ -0,0 +1,177 @@ +#!/usr/bin/env node +// Frame-by-frame loading of a page, for a pull request's evidence or to find what moves: every frame the screen +// changes, the content area shown empty (the site header alone) framed in red, layout shifts marked, and the recorder's report +// (tests/e2e/loading.ts: CLS, empty frame, unstyled frame, skeleton font, forbidden texts). +// +// node tests/e2e/loading-timeline.ts [options] +// a Re:Builder screen (plugins/rebuilder/e2e/screens.ts): decks, deck, search, apercu, mon-deck, +// main, new; the screens of a deck use a new deck of the account +// --base site to load (default E2E_BASE_URL or http://localhost:8080) +// --compare a second site (another checkout's stack: before / after), drawn as a second row +// --viewport a size of VIEWPORTS in loading.ts: phone-s, phone (default, 390×844), tablet, laptop-s, laptop, +// desktop, wide +// --desktop --viewport desktop (1440×900) +// --first-visit empty cache (default: second visit, the page loaded once before) +// --profile

    phone (default: 150 ms, 1.6 Mbit/s, CPU 4×), slowPhone (300 ms, 750 kbit/s, CPU 6×), fast +// --user alice (default), bob, or none (signed out) +// --out

    default test-results/loading; writes .png and .json +// --frames also writes the frames of the image at full size: /.png +// +// Node ≥ 22.18 (TypeScript type stripping). Run from tests/e2e (its node_modules) against the full stack. +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { chromium, type Browser } from '@playwright/test'; +import { VIEWPORTS, emptyCache, loadingReport, throttle, watchLoading, type LoadingReport, type Profile } from './loading.ts'; +import { createServerDeck } from '../../plugins/rebuilder/e2e/decks.ts'; +import { FORBIDDEN_TEXTS, SCREENS } from '../../plugins/rebuilder/e2e/screens.ts'; + +const args = process.argv.slice(2); +const flag = (name: string) => args.includes(`--${name}`); +const option = (name: string, fallback?: string) => { + const i = args.indexOf(`--${name}`); + return i >= 0 ? args[i + 1] : fallback; +}; +const target = args[0]; +if (!target || target.startsWith('--')) { + console.error('usage: node tests/e2e/loading-timeline.ts [--base url] [--compare url] [--viewport v | --desktop] [--first-visit] [--profile phone|slowPhone|fast] [--user alice|bob|none] [--out dir]'); + console.error(`screens: ${SCREENS.map((s) => s.key).join(', ')}`); + process.exit(1); +} +const screen = SCREENS.find((s) => s.key === target); +if (!screen && !target.startsWith('/')) throw new Error(`unknown screen ${target} (${SCREENS.map((s) => s.key).join(', ')}), or a path starting with /`); +const bases = [option('base', process.env['E2E_BASE_URL'] ?? 'http://localhost:8080')!, option('compare')].filter((b): b is string => !!b); +const size = flag('desktop') ? 'desktop' : option('viewport', 'phone')!; +if (!VIEWPORTS[size]) throw new Error(`unknown viewport ${size} (${Object.keys(VIEWPORTS).join(', ')})`); +const wide = (VIEWPORTS[size].viewport?.width ?? 0) >= 768; +const firstVisit = flag('first-visit'); +const profile = option('profile', 'phone') as Profile; +const user = option('user', 'alice')!; +const out = resolve(option('out', 'test-results/loading')!); +const name = `${screen?.key ?? 'page'}-${size}-${firstVisit ? 'first' : 'second'}-visit`; +const PASSWORD = 'TestPassword1234'; // docker/stack/players-realm.json + +interface Frame { ms: number; png: Buffer } +interface Run { base: string; url: string; frames: Frame[]; report: LoadingReport } + +async function capture(browser: Browser, base: string): Promise { + const context = await browser.newContext({ ...VIEWPORTS[size], deviceScaleFactor: Math.min(VIEWPORTS[size].deviceScaleFactor ?? 1, 2), serviceWorkers: 'block' }); + await context.addCookies([{ name: 'alteredcore_consent', value: '1', url: base }, { name: 'ac_beta', value: '1', url: base }]); + const page = await context.newPage(); + if (user !== 'none') { + await page.goto(`${base}/auth/keycloak-login?return=${encodeURIComponent('/pages/decks')}`); + await page.locator('#username').fill(user); + await page.locator('#password').fill(PASSWORD); + await page.locator('#kc-login').click(); + await page.waitForURL((u) => u.pathname.startsWith('/pages/decks')); + await page.locator('[data-ac-plugin]').waitFor(); + } + const id = screen?.deck ? await createServerDeck(page, `E2E timeline ${Date.now()}`) : ''; + const path = screen ? screen.path(id) : target; + const url = new URL(path, base).href; + const settle = async () => { + await page.locator('.ac-spa-placeholder').waitFor({ state: 'detached', timeout: 30_000 }).catch(() => undefined); + await page.waitForLoadState('networkidle', { timeout: 15_000 }).catch(() => undefined); + await page.waitForTimeout(1000); + }; + if (firstVisit) await emptyCache(page); + else { + await page.goto(url); + await settle(); + } + await watchLoading(page, FORBIDDEN_TEXTS); + await throttle(page, profile); + const cdp = await context.newCDPSession(page); + const shots: { ts: number; data: string }[] = []; + cdp.on('Page.screencastFrame', (f) => { + shots.push({ ts: f.metadata.timestamp ?? 0, data: f.data }); + void cdp.send('Page.screencastFrameAck', { sessionId: f.sessionId }).catch(() => undefined); + }); + await cdp.send('Page.startScreencast', { format: 'png', everyNthFrame: 1 }); + await page.goto(url); + await settle(); + await cdp.send('Page.stopScreencast'); + const timeOrigin = await page.evaluate(() => performance.timeOrigin / 1000); + const report = await loadingReport(page); + await context.close(); + const frames = shots.map((s) => ({ ms: Math.round((s.ts - timeOrigin) * 1000), png: Buffer.from(s.data, 'base64') })).filter((f) => f.ms >= 0); + return { base, url, frames, report }; +} + +/** One row per run: the frames where the screen changes, empty content framed in red, shifts marked under the frame. */ +async function compose(browser: Browser, runs: Run[], file: string): Promise { + const framesDir = flag('frames') ? file.replace(/\.png$/, '') : null; + const page = await browser.newPage({ deviceScaleFactor: 1 }); + await page.setContent(''); + const rows = await page.evaluate(async (input) => { + const load = (src: string) => new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.src = src; }); + const W = 120; + const out: { label: string; frames: { ms: number; src: string; empty: boolean; shift: number }[] }[] = []; + for (const run of input.runs) { + const kept: { ms: number; src: string; empty: boolean; shift: number }[] = []; + let prev: Uint8ClampedArray | null = null; + for (const f of run.frames) { + const img = await load(f.src); + const h = Math.round((img.height / img.width) * W); + const c = document.createElement('canvas'); + c.width = W; + c.height = h; + const g = c.getContext('2d', { willReadFrequently: true })!; + g.drawImage(img, 0, 0, W, h); + const px = g.getImageData(0, 0, W, h).data; + // Content area: under the site header (10 % of the height) and above the bottom navigation (5 %). + let n = 0, sum = 0, sq = 0, diff = 0; + for (let y = Math.round(h * 0.12); y < Math.round(h * 0.95); y++) { + for (let x = 0; x < W; x++) { + const i = (y * W + x) * 4, v = (px[i] + px[i + 1] + px[i + 2]) / 3; + n++; sum += v; sq += v * v; + } + } + for (let i = 0; prev && i < px.length; i += 4) diff += Math.abs(px[i] - prev[i]) + Math.abs(px[i + 1] - prev[i + 1]) + Math.abs(px[i + 2] - prev[i + 2]); + const empty = Math.sqrt(sq / n - (sum / n) ** 2) < 2; + const changed = !prev || diff / (px.length * 0.75) > 3; // skeleton pulses and image fades stay under it + const shift = run.shifts.filter((s) => s.t <= f.ms && !kept.some((k) => k.ms >= s.t)).reduce((a, s) => a + s.value, 0); + if (changed || (shift >= 0.001 && kept.length)) kept.push({ ms: f.ms, src: f.src, empty, shift: Math.round(shift * 1000) / 1000 }); + prev = px; + } + out.push({ label: run.label, frames: kept }); + } + return out; + }, { runs: runs.map((r, i) => ({ label: runs.length > 1 ? (i === 0 ? 'A' : 'B') + ` · ${new URL(r.base).host}` : new URL(r.base).host, shifts: r.report.shifts, frames: r.frames.map((f) => ({ ms: f.ms, src: `data:image/png;base64,${f.png.toString('base64')}` })) })) }); + if (framesDir) { + rmSync(framesDir, { recursive: true, force: true }); + rows.forEach((r, i) => { + const dir = rows.length > 1 ? join(framesDir, String.fromCharCode(65 + i)) : framesDir; + mkdirSync(dir, { recursive: true }); + for (const f of r.frames) writeFileSync(join(dir, `${f.ms}.png`), Buffer.from(f.src.split(',')[1], 'base64')); + }); + } + const frameWidth = wide ? 360 : 195; + await page.setContent(`
    ${rows.map((r, i) => `

    ${r.label} · CLS ${runs[i].report.cls}${runs[i].report.issues.length ? ` · ${runs[i].report.issues.map((x) => x.kind).join(', ')}` : ''}

    ${r.frames + .map((f) => `
    ${f.ms} ms${f.empty ? ' · vide' : ''}${f.shift ? ` · décalage ${f.shift}` : ''}
    `) + .join('')}
    `).join('')}
    `); + await page.locator('main').screenshot({ path: file }); +} + +mkdirSync(out, { recursive: true }); +const browser = await chromium.launch(); +const runs: (Run & { label?: string })[] = []; +for (const base of bases) runs.push(await capture(browser, base)); +await compose(browser, runs.map((r) => ({ ...r, label: '' })), join(out, `${name}.png`)); +writeFileSync(join(out, `${name}.json`), JSON.stringify(runs.map(({ base, url, report }) => ({ base, url, report })), null, 2)); +await browser.close(); +for (const r of runs) { + console.log(`${r.url}\n CLS ${r.report.cls}, placeholder gone at ${r.report.placeholderGoneAt} ms, ${r.frames.length} frames`); + for (const i of r.report.issues) console.log(` ${i.kind} at ${i.t} ms: ${i.detail}`); + for (const s of r.report.shifts.filter((x) => x.value >= 0.001)) console.log(` shift ${s.value} at ${s.t} ms: ${s.boxes.slice(0, 3).join('; ')}`); +} +console.log(`\n${join(out, `${name}.png`)}\n${join(out, `${name}.json`)}`); diff --git a/tests/e2e/loading.ts b/tests/e2e/loading.ts new file mode 100644 index 00000000..ed1d1339 --- /dev/null +++ b/tests/e2e/loading.ts @@ -0,0 +1,242 @@ +/** + * Loading checks of SPA pages: the server's skeleton, then the plugin's first screen, then its data. Used by the e2e + * tests (plugins//e2e/loading.spec.ts) and by tests/e2e/loading-timeline.ts (frame-by-frame evidence). + * + * watchLoading() installs a recorder in the page before it loads. It notes: + * - layout shifts (the browser's CLS entries), with the boxes that moved (a plugin's nodes sit in a shadow root, which + * the entries do not name); + * - `empty-frame`: the first frame painted without the server's placeholder (the site header alone), or the + * placeholder taken away while the plugin shows nothing yet; + * - `unstyled`: the plugin shown before its shadow root's stylesheets have loaded; + * - `placeholder-font`: the placeholder losing the site's font (it shows through the plugin's shadow root); + * - `text`: a text that must never show, in a given element (a « Chargement… » title its skeleton replaces). + * + * Self-contained (types only from Playwright): tests/e2e/loading-timeline.ts runs it with Node's type stripping. + */ +import type { BrowserContextOptions, Page, TestInfo } from '@playwright/test'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; + +/** A box in viewport pixels: x, y, width, height. */ +export type Rect = [number, number, number, number]; +export interface LoadingShift { + t: number; + value: number; + boxes: string[]; + /** Where each box was and where it went, for cls warnings to draw them. */ + moves: { from: Rect; to: Rect }[]; +} +export interface LoadingIssue { + t: number; + kind: 'empty-frame' | 'unstyled' | 'placeholder-font' | 'text'; + detail: string; +} +export interface LoadingReport { + cls: number; + shifts: LoadingShift[]; + issues: LoadingIssue[]; + /** When the placeholder went (ms from navigation start), null if it is still there. */ + placeholderGoneAt: number | null; +} +/** A text that must never show in the elements `selector` matches (in the plugin's shadow root or the page). */ +export interface ForbiddenText { + selector: string; + text: string; +} + +/** Network and CPU of a slow phone (Lighthouse's mobile: 150 ms RTT, 1.6 Mbit/s, CPU 4×), or no throttling. */ +export const PROFILES = { + phone: { cpu: 4, latency: 150, down: 1.6e6 / 8, up: 750e3 / 8 }, + slowPhone: { cpu: 6, latency: 300, down: 750e3 / 8, up: 250e3 / 8 }, + fast: { cpu: 1, latency: 0, down: -1, up: -1 }, +} as const; +export type Profile = keyof typeof PROFILES; + +/** Phones, tablets, laptops and desktops on both sides of the breakpoints (768 medium, 1200 expanded, the editor's 1440). */ +export const VIEWPORTS: Record = { + 'phone-s': { viewport: { width: 360, height: 740 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, + phone: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 3, isMobile: true, hasTouch: true }, + tablet: { viewport: { width: 820, height: 1180 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true }, + 'laptop-s': { viewport: { width: 1024, height: 768 } }, + laptop: { viewport: { width: 1280, height: 800 } }, + 'laptop-m': { viewport: { width: 1366, height: 768 } }, + desktop: { viewport: { width: 1440, height: 900 } }, + wide: { viewport: { width: 1920, height: 1080 } }, +}; + +/** Installs the recorder for the next navigations of `page` (call it before page.goto()). */ +export async function watchLoading(page: Page, forbidden: ForbiddenText[] = []): Promise { + await page.addInitScript(recorder, forbidden); +} + +/** What the recorder noted since the page loaded. */ +export async function loadingReport(page: Page): Promise { + const raw = await page.evaluate(() => (window as unknown as { __acLoading?: Omit }).__acLoading ?? null); + if (!raw) throw new Error('watchLoading() was not installed before this page loaded'); + return { ...raw, cls: Math.round(raw.shifts.reduce((sum, s) => sum + s.value, 0) * 10000) / 10000 }; +} + +/** The report's problems, one line each: issues, and a CLS over `maxCls`. Empty when the loading was smooth. */ +export function loadingProblems(report: LoadingReport, maxCls = 0.01): string[] { + const out = report.issues.map((i) => `${i.kind} at ${i.t} ms: ${i.detail}`); + const cls = clsProblem(report, maxCls); + if (cls) out.push(cls); + if (report.placeholderGoneAt === null && report.issues.length === 0) out.push('the placeholder never went away'); + return out; +} + +/** A CLS over `maxCls`, with the shifts and the boxes that moved; null within the budget. */ +export function clsProblem(report: LoadingReport, maxCls = 0.01): string | null { + if (report.cls <= maxCls) return null; + return `CLS ${report.cls} > ${maxCls}: ${report.shifts.map((s) => `${s.t} ms ${s.value} [${s.boxes.join('; ')}]`).join(' | ')}`; +} + +/** + * A CLS over budget as a warning, not a failure: the CLS of a screen varies from one CI run to the next. Attaches a + * screenshot of the settled page, the boxes that moved drawn on it (dashed: before, solid: after), and adds a + * `cls warning` annotation. With E2E_CLS_WARNINGS_DIR, also writes `.png` and `.json` there: the CI + * posts them as a pull request comment. + */ +export async function clsWarning(page: Page, testInfo: TestInfo, report: LoadingReport, maxCls: number): Promise { + const problem = clsProblem(report, maxCls); + if (!problem) return; + await page.evaluate((moves) => { + const layer = document.createElement('div'); + layer.style.cssText = 'position:fixed;inset:0;z-index:2147483647;pointer-events:none'; + for (const { from, to } of moves) { + for (const [[x, y, w, h], line] of [[from, 'dashed'], [to, 'solid']] as const) { + const box = document.createElement('div'); + box.style.cssText = `position:absolute;left:${x}px;top:${y}px;width:${w}px;height:${h}px;border:3px ${line} #e5007e;box-sizing:border-box`; + layer.append(box); + } + } + document.body.append(layer); + }, report.shifts.flatMap((s) => s.moves)); + const body = await page.screenshot(); + await testInfo.attach('cls-warning', { body, contentType: 'image/png' }); + testInfo.annotations.push({ type: 'cls warning', description: problem }); + const dir = process.env['E2E_CLS_WARNINGS_DIR']; + if (dir) { + const slug = `${testInfo.project.name}-${testInfo.title}`.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 120); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, `${slug}.png`), body); + writeFileSync(join(dir, `${slug}.json`), JSON.stringify({ + title: testInfo.title, + project: testInfo.project.name, + cls: report.cls, + budget: maxCls, + shifts: report.shifts.map(({ t, value, boxes }) => ({ t, value, boxes })), + }, null, 2)); + } +} + +/** CPU and network of `profile` for this page (Chromium, through the DevTools protocol). */ +export async function throttle(page: Page, profile: Profile): Promise { + const p = PROFILES[profile]; + const cdp = await page.context().newCDPSession(page); + await cdp.send('Emulation.setCPUThrottlingRate', { rate: p.cpu }); + await cdp.send('Network.enable'); + await cdp.send('Network.emulateNetworkConditions', { offline: false, latency: p.latency, downloadThroughput: p.down, uploadThroughput: p.up }); +} + +/** A first visit: no HTTP cache, no service worker (its own requests would escape the throttling). */ +export async function emptyCache(page: Page): Promise { + await page.evaluate(async () => { + for (const registration of (await navigator.serviceWorker?.getRegistrations()) ?? []) await registration.unregister(); + }); + const cdp = await page.context().newCDPSession(page); + await cdp.send('Network.enable'); + await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); +} + +/** Runs in the page, before its scripts (addInitScript): serialised by Playwright, so self-contained. */ +function recorder(forbidden: ForbiddenText[]): void { + const state = { shifts: [] as LoadingShift[], issues: [] as LoadingIssue[], placeholderGoneAt: null as number | null }; + (window as unknown as { __acLoading: typeof state }).__acLoading = state; + const now = () => Math.round(performance.now()); + const seen = new Set(); + const issue = (kind: LoadingIssue['kind'], detail: string) => { + if (seen.has(kind + detail)) return; + seen.add(kind + detail); + state.issues.push({ t: now(), kind, detail }); + }; + + // The element now in a box that moved: the entry's node, or (a node of a shadow root, which the entry leaves out) the + // element at the box's centre in the plugin's shadow root. `ac-select.q < div.row`: the element and two ancestors. + const describe = (node: Node | null | undefined, r: DOMRectReadOnly) => { + let el = node instanceof Element ? node : (node?.parentElement ?? null); + if (!el && r.width && r.height) { + const host = document.querySelector('[data-ac-plugin]'); + el = (host?.shadowRoot ?? document).elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); + } + const names: string[] = []; + for (let e = el; e && names.length < 3; e = e.parentElement ?? ((e.getRootNode() as ShadowRoot).host ?? null)) { + names.push(e.localName + [...e.classList].slice(0, 2).map((c) => `.${c}`).join('')); + } + return names.length ? ` (${names.join(' < ')})` : ''; + }; + type ShiftEntry = PerformanceEntry & { value: number; hadRecentInput: boolean; sources: { node?: Node | null; previousRect: DOMRectReadOnly; currentRect: DOMRectReadOnly }[] }; + new PerformanceObserver((list) => { + for (const e of list.getEntries() as ShiftEntry[]) { + if (e.hadRecentInput) continue; + const box = (r: DOMRectReadOnly) => `x${Math.round(r.x)} y${Math.round(r.y)} ${Math.round(r.width)}×${Math.round(r.height)}`; + const rect = (r: DOMRectReadOnly): Rect => [Math.round(r.x), Math.round(r.y), Math.round(r.width), Math.round(r.height)]; + state.shifts.push({ + t: Math.round(e.startTime), + value: Math.round(e.value * 10000) / 10000, + boxes: e.sources.map((s) => `${box(s.previousRect)} → ${box(s.currentRect)}${describe(s.node, s.currentRect)}`), + moves: e.sources.map((s) => ({ from: rect(s.previousRect), to: rect(s.currentRect) })), + }); + } + }).observe({ type: 'layout-shift', buffered: true }); + + const hostOf = (el: Element | null) => el?.closest('[data-ac-plugin]') ?? document.querySelector('[data-ac-plugin]'); + const rootOf = (host: Element | null) => (host?.shadowRoot ?? host)?.querySelector('.ac-plugin-root') ?? null; + const unloadedSheets = (host: Element | null) => + [...(host?.shadowRoot?.querySelectorAll('link[rel="stylesheet"]') ?? [])].filter((l) => !l.sheet).map((l) => l.href.split('/').pop()); + const shows = (root: HTMLElement) => getComputedStyle(root).visibility !== 'hidden' && root.getBoundingClientRect().height >= 1 + && !!(root.querySelector('.ac-skeleton, img, svg, canvas') || root.innerText.trim()); + + // The placeholder goes: what the plugin shows in its place is what the next frame paints (a microtask, before paint). + new MutationObserver((records) => { + for (const r of records) { + for (const node of r.removedNodes) { + if (!(node instanceof HTMLElement) || !node.classList.contains('ac-spa-placeholder') || node.isConnected) continue; + state.placeholderGoneAt = now(); + const host = hostOf(r.target as Element); + const root = rootOf(host); + if (!root || !shows(root)) issue('empty-frame', 'the placeholder went while the plugin showed nothing'); + const pending = unloadedSheets(host); + if (pending.length) issue('unstyled', `the placeholder went before ${pending.join(', ')} loaded`); + } + } + }).observe(document, { childList: true, subtree: true }); + + // Every frame: the plugin shown unstyled, the placeholder's font, forbidden texts. + let first = true; + const frame = () => { + const placeholder = document.querySelector('.ac-spa-placeholder'); + // The first frame (an SPA page: the server renders the placeholder): it must show, at its size. + if (first && document.querySelector('[data-ac-plugin]') && !(placeholder && placeholder.getBoundingClientRect().height >= 1)) { + issue('empty-frame', 'the first frame was painted without the placeholder'); + } + if (document.querySelector('[data-ac-plugin]')) first = false; + const host = hostOf(placeholder); + const root = rootOf(host); + if (root && getComputedStyle(root).visibility !== 'hidden' && root.getBoundingClientRect().height >= 1) { + const pending = unloadedSheets(host); + if (pending.length) issue('unstyled', `the plugin showed before ${pending.join(', ')} loaded`); + } + if (placeholder && document.body) { + const font = getComputedStyle(placeholder).fontFamily; + if (font !== getComputedStyle(document.body).fontFamily) issue('placeholder-font', `the placeholder turned to « ${font} »`); + } + for (const f of forbidden) { + for (const el of [...((host?.shadowRoot ?? document).querySelectorAll(f.selector))]) { + if (el.innerText.includes(f.text)) issue('text', `« ${f.text} » in ${f.selector}`); + } + } + requestAnimationFrame(frame); + }; + requestAnimationFrame(frame); +} diff --git a/tests/e2e/newsletter-email.spec.ts b/tests/e2e/newsletter-email.spec.ts new file mode 100644 index 00000000..9feef835 --- /dev/null +++ b/tests/e2e/newsletter-email.spec.ts @@ -0,0 +1,48 @@ +import { test, expect, login, evidence } from './fixtures'; + +// The signed-in user's e-mail address is never readable on screen (a creator filming the site leaked theirs through +// the prefilled newsletter field): masked, the full address only in a tooltip on hover or focus. + +const EMAIL = 'alice@example.test'; + +/** Text of the page without the tooltips: what a viewer of the screen can read. */ +async function visibleText(page: import('./fixtures').Page): Promise { + return page.evaluate(() => { + const body = document.body.cloneNode(true) as HTMLElement; + body.querySelectorAll('.ac-tooltip, script, style').forEach((el) => el.remove()); + return body.textContent ?? ''; + }); +} + +test('the homepage newsletter field is empty, and the account address subscribes without being shown', async ({ page }, testInfo) => { + await login(page, 'alice', '/'); + const field = page.locator('input[name="nl_email"]'); + await expect(field).toHaveValue(''); + await field.scrollIntoViewIfNeeded(); + await evidence(page, testInfo, 'home-newsletter'); + expect(await visibleText(page)).not.toContain(EMAIL); + + await page.locator('button[name="nl_use_account"]').click(); + await expect(page.getByRole('alert')).toContainText(/Subscription confirmed|already subscribed|Inscription confirmée|déjà inscrite/); +}); + +test('the account menu and page show the address masked, in full in a tooltip', async ({ page }, testInfo) => { + await login(page, 'alice', '/pages/account'); + expect(await visibleText(page)).not.toContain(EMAIL); + + const masked = page.locator('.ac-masked').filter({ visible: true }).first(); + await expect(masked).toContainText('a•••@example.test'); + const tooltip = masked.locator('.ac-tooltip'); + await expect(tooltip).toBeHidden(); + await masked.focus(); + await expect(tooltip).toBeVisible(); + await expect(tooltip).toHaveText(EMAIL); + await evidence(page, testInfo, 'account-page'); + + await page.locator('#azAccountBtn').click(); + const menuMasked = page.locator('#azAccountMenu .ac-masked'); + await expect(menuMasked).toContainText('a•••@example.test'); + await menuMasked.focus(); + await expect(menuMasked.locator('.ac-tooltip')).toBeVisible(); + await evidence(page, testInfo, 'account-menu'); +}); diff --git a/tests/e2e/package.json b/tests/e2e/package.json index fcf8e092..75fc410e 100644 --- a/tests/e2e/package.json +++ b/tests/e2e/package.json @@ -3,7 +3,10 @@ "private": true, "description": "Playwright tests of the shell (tests/e2e/*.spec.ts) and of every plugin (plugins/*/e2e/*.spec.ts), run against docker-compose.stack.yml. Dev tooling only: not deployed.", "scripts": { - "test": "playwright test" + "test": "playwright test", + "loading": "playwright test loading.spec", + "loading:timeline": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON loading-timeline.ts", + "loading:matrix": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON loading-matrix.ts" }, "devDependencies": { "@playwright/test": "1.63.0", diff --git a/tests/e2e/perf-ab.mjs b/tests/e2e/perf-ab.mjs new file mode 100644 index 00000000..76e620b5 --- /dev/null +++ b/tests/e2e/perf-ab.mjs @@ -0,0 +1,246 @@ +// Load performance of Re:Builder's pages, two versions of the site side by side (A/B), on an emulated slow network. +// +// node perf-ab.mjs