Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb144ae472 | ||
|
|
f3571abeaf | ||
|
|
b87b7aac7d | ||
|
|
ae172000a6 | ||
|
|
ffd647d152 | ||
|
|
4142d084f6 | ||
|
|
b7a20d9d41 | ||
|
|
b68711ef88 | ||
|
|
7b546c567c | ||
|
|
49f22f048f | ||
|
|
4472c2852e | ||
|
|
5d940b57ef | ||
|
|
bc7e1faa1c | ||
|
|
557cea3f48 | ||
|
|
4fb6302c67 | ||
|
|
59decd17e2 | ||
|
|
52fadcd617 | ||
|
|
8a5592f92b | ||
|
|
79183d6417 | ||
|
|
89243371fa | ||
|
|
4a9b430702 | ||
|
|
ac176935f5 |
32
.github/actions/force-ipv4/README.md
vendored
Normal file
32
.github/actions/force-ipv4/README.md
vendored
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# force-ipv4
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This GitHub action enforces IPv4 for all outgoing network requests. It addresses connectivity issues encountered in GitHub runners, where IPv6 requests may lead to timeouts due to the lack of IPv6 support [1] [2].
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
Some applications attempt network connections over IPv6.
|
||||||
|
Such as requests made by Node's `fetch` API causes `UND_ERR_CONNECT_TIMEOUT` [3] [4] and similar issues [5].
|
||||||
|
This happens when the software cannot handle this such as by using Happy Eyeballs [6] [7].
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
To use this action in your GitHub workflow, add the following step before any job that requires network access:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Enforce IPv4 Connectivity
|
||||||
|
uses: ./.github/actions/force-ipv4
|
||||||
|
```
|
||||||
|
|
||||||
|
## Note
|
||||||
|
|
||||||
|
This action is a workaround addressing specific IPv6-related connectivity issues on GitHub runners and may not be necessary if GitHub's infrastructure evolves to fully support IPv6 in the future.
|
||||||
|
|
||||||
|
[1]: https://archive.ph/2024.03.28-185829/https://github.com/actions/runner/issues/3138 "Actions Runner fails on IPv6 only host · Issue #3138 · actions/runner · GitHub | github.com"
|
||||||
|
[2]: https://archive.ph/2024.03.28-185838/https://github.com/actions/runner-images/issues/668 "IPv6 on GitHub-hosted runners · Issue #668 · actions/runner-images · GitHub | github.com"
|
||||||
|
[3]: https://archive.ph/2024.03.28-185847/https://github.com/actions/runner/issues/3213 "GitHub runner cannot send `fetch` with `node`, failing with IPv6 DNS error `UND_ERR_CONNECT_TIMEOUT` · Issue #3213 · actions/runner · GitHub | github.com"
|
||||||
|
[4]: https://archive.ph/2024.03.28-185853/https://github.com/actions/runner-images/issues/9540 "Cannot send outbound requests using node fetch, failing with IPv6 DNS error UND_ERR_CONNECT_TIMEOUT · Issue #9540 · actions/runner-images · GitHub | github.com"
|
||||||
|
[5]: https://archive.today/2024.03.30-113315/https://github.com/nodejs/node/issues/40537 "\"localhost\" favours IPv6 in node v17, used to favour IPv4 · Issue #40537 · nodejs/node · GitHub"
|
||||||
|
[6]: https://archive.ph/2024.03.28-185900/https://github.com/nodejs/node/issues/41625 "Happy Eyeballs support (address IPv6 issues in Node 17) · Issue #41625 · nodejs/node · GitHub | github.com"
|
||||||
|
[7]: https://archive.ph/2024.03.28-185910/https://github.com/nodejs/undici/issues/1531 "fetch times out in under 5 seconds · Issue #1531 · nodejs/undici · GitHub | github.com"
|
||||||
12
.github/actions/force-ipv4/action.yml
vendored
Normal file
12
.github/actions/force-ipv4/action.yml
vendored
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
inputs:
|
||||||
|
project-root:
|
||||||
|
required: false
|
||||||
|
default: '.'
|
||||||
|
runs:
|
||||||
|
using: composite
|
||||||
|
steps:
|
||||||
|
-
|
||||||
|
name: Run prefer IPv4 script
|
||||||
|
shell: bash
|
||||||
|
run: ./.github/actions/force-ipv4/force-ipv4.sh
|
||||||
|
working-directory: ${{ inputs.project-root }}
|
||||||
80
.github/actions/force-ipv4/force-ipv4.sh
vendored
Executable file
80
.github/actions/force-ipv4/force-ipv4.sh
vendored
Executable file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
main() {
|
||||||
|
if is_linux; then
|
||||||
|
echo 'Configuring Linux...'
|
||||||
|
|
||||||
|
configure_warp_with_doh_and_ipv6_exclusion_on_linux # [WORKS] Resolves the issue when run independently on GitHub runners lacking IPv6 support.
|
||||||
|
prefer_ipv4_on_linux # [DOES NOT WORK] It does not resolve the issue when run independently on GitHub runners without IPv6 support.
|
||||||
|
|
||||||
|
# Considered alternatives:
|
||||||
|
# - `sysctl` commands, and direct changes to `/proc/sys/net/` and `/etc/sysctl.conf` led to silent
|
||||||
|
# Node 18 exits (code: 13) when using `fetch`.
|
||||||
|
elif is_macos; then
|
||||||
|
echo 'Configuring macOS...'
|
||||||
|
|
||||||
|
configure_warp_with_doh_and_ipv6_exclusion_on_macos # [WORKS] Resolves the issue when run independently on GitHub runners lacking IPv6 support.
|
||||||
|
disable_ipv6_on_macos # [WORKS INCONSISTENTLY] Resolves the issue inconsistently when run independently on GitHub runners without IPv6 support.
|
||||||
|
fi
|
||||||
|
echo "IPv4: $(curl --ipv4 --silent --max-time 15 --retry 3 --user-agent Mozilla https://api.ip.sb/geoip)"
|
||||||
|
echo "IPv6: $(curl --ipv6 --silent --max-time 15 --retry 3 --user-agent Mozilla https://api.ip.sb/geoip)"
|
||||||
|
}
|
||||||
|
|
||||||
|
is_linux() {
|
||||||
|
[[ "$(uname -s)" == "Linux" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
is_macos() {
|
||||||
|
[[ "$(uname -s)" == "Darwin" ]]
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_warp_with_doh_and_ipv6_exclusion_on_linux() {
|
||||||
|
install_warp_on_debian
|
||||||
|
configure_warp_doh_and_exclude_ipv6
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_warp_with_doh_and_ipv6_exclusion_on_macos() {
|
||||||
|
brew install cloudflare-warp
|
||||||
|
configure_warp_doh_and_exclude_ipv6
|
||||||
|
}
|
||||||
|
|
||||||
|
configure_warp_doh_and_exclude_ipv6() {
|
||||||
|
echo 'Beginning configuration of the Cloudflare WARP client with DNS-over-HTTPS and IPv6 exclusion...'
|
||||||
|
echo 'Initiating client registration with Cloudflare...'
|
||||||
|
warp-cli --accept-tos registration new
|
||||||
|
echo 'Configuring WARP to operate in DNS-over-HTTPS mode (warp+doh)...'
|
||||||
|
warp-cli --accept-tos mode warp+doh
|
||||||
|
echo 'Excluding IPv6 traffic from WARP by configuring it as a split tunnel...'
|
||||||
|
warp-cli --accept-tos add-excluded-route '::/0' # Exclude IPv6, forcing IPv4 resolution
|
||||||
|
# `tunnel ip add` does not work with IP ranges, see https://community.cloudflare.com/t/cant-cidr-for-split-tunnling/630834
|
||||||
|
echo 'Establishing WARP connection...'
|
||||||
|
warp-cli --accept-tos connect
|
||||||
|
}
|
||||||
|
|
||||||
|
install_warp_on_debian() {
|
||||||
|
curl -fsSL https://pkg.cloudflareclient.com/pubkey.gpg | sudo gpg --yes --dearmor --output /usr/share/keyrings/cloudflare-warp-archive-keyring.gpg
|
||||||
|
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/cloudflare-warp-archive-keyring.gpg] https://pkg.cloudflareclient.com/ $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/cloudflare-client.list
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y cloudflare-warp
|
||||||
|
}
|
||||||
|
|
||||||
|
disable_ipv6_on_macos() {
|
||||||
|
networksetup -listallnetworkservices \
|
||||||
|
| tail -n +2 \
|
||||||
|
| while IFS= read -r interface; do
|
||||||
|
echo "Disabling IPv6 on: $interface..."
|
||||||
|
networksetup -setv6off "$interface"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
prefer_ipv4_on_linux() {
|
||||||
|
local -r gai_config_file_path='/etc/gai.conf'
|
||||||
|
if [ ! -f "$gai_config_file_path" ]; then
|
||||||
|
echo "Creating $gai_config_file_path since it doesn't exist..."
|
||||||
|
touch "$gai_config_file_path"
|
||||||
|
fi
|
||||||
|
echo "precedence ::ffff:0:0/96 100" | sudo tee -a "$gai_config_file_path" > /dev/null
|
||||||
|
echo "Configuration complete."
|
||||||
|
}
|
||||||
|
|
||||||
|
main
|
||||||
3
.github/actions/setup-node/action.yml
vendored
3
.github/actions/setup-node/action.yml
vendored
@@ -5,4 +5,5 @@ runs:
|
|||||||
name: Setup node
|
name: Setup node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 18.x
|
node-version: 20.x
|
||||||
|
check-latest: true
|
||||||
|
|||||||
8
.github/workflows/checks.build.yaml
vendored
8
.github/workflows/checks.build.yaml
vendored
@@ -95,6 +95,12 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Run Docker image on port 8080
|
name: Run Docker image on port 8080
|
||||||
run: docker run -d -p 8080:80 --rm --name privacy.sexy undergroundwires/privacy.sexy:latest
|
run: docker run -d -p 8080:80 --rm --name privacy.sexy undergroundwires/privacy.sexy:latest
|
||||||
|
-
|
||||||
|
name: Enforce IPv4 Connectivity # Used due to GitHub runners' lack of IPv6 support, preventing request timeouts.
|
||||||
|
uses: ./.github/actions/force-ipv4
|
||||||
-
|
-
|
||||||
name: Check server is up and returns HTTP 200
|
name: Check server is up and returns HTTP 200
|
||||||
run: node ./scripts/verify-web-server-status.js --url http://localhost:8080
|
run: >-
|
||||||
|
node ./scripts/verify-web-server-status.js \
|
||||||
|
--url http://localhost:8080 \
|
||||||
|
--max-retries ${{ matrix.os == 'macos' && '90' || '30' }}
|
||||||
|
|||||||
8
.github/workflows/checks.external-urls.yaml
vendored
8
.github/workflows/checks.external-urls.yaml
vendored
@@ -1,6 +1,7 @@
|
|||||||
name: checks.external-urls
|
name: checks.external-urls
|
||||||
|
|
||||||
on:
|
on:
|
||||||
|
push:
|
||||||
schedule:
|
schedule:
|
||||||
- cron: '0 0 * * 0' # at 00:00 on every Sunday
|
- cron: '0 0 * * 0' # at 00:00 on every Sunday
|
||||||
|
|
||||||
@@ -17,6 +18,13 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
uses: ./.github/actions/npm-install-dependencies
|
uses: ./.github/actions/npm-install-dependencies
|
||||||
|
-
|
||||||
|
name: Enforce IPv4 Connectivity # Used due to GitHub runners' lack of IPv6 support, preventing request timeouts.
|
||||||
|
uses: ./.github/actions/force-ipv4
|
||||||
-
|
-
|
||||||
name: Test
|
name: Test
|
||||||
run: npm run check:external-urls
|
run: npm run check:external-urls
|
||||||
|
env:
|
||||||
|
RANDOMIZED_URL_CHECK_LIMIT: "${{ github.event_name == 'push' && '100' || '3000' }}"
|
||||||
|
# - Scheduled checks has high limit for thorough testing.
|
||||||
|
# - For push events, triggered by code changes, the amount of URLs are limited to provide quick feedback.
|
||||||
|
|||||||
18
CHANGELOG.md
18
CHANGELOG.md
@@ -1,5 +1,23 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 0.13.1 (2024-03-22)
|
||||||
|
|
||||||
|
* ci/cd: Fix cross-platform git command compability | [255c51c](https://github.com/undergroundwires/privacy.sexy/commit/255c51c8a0524d3ea8a3b16ffc1b178650525010)
|
||||||
|
* Fix tooltip falling behind elements on fade out | [1964524](https://github.com/undergroundwires/privacy.sexy/commit/19645248ab7bc78dc872fa176c1a3650d7d6d644)
|
||||||
|
* Improve VSCode detection in `configure_vscode.py` | [98845e6](https://github.com/undergroundwires/privacy.sexy/commit/98845e6caee168db131aaf0736533e450827a52c)
|
||||||
|
* Bump TypeScript to 5.3 with `verbatimModuleSyntax` | [a721e82](https://github.com/undergroundwires/privacy.sexy/commit/a721e82a4fb603c0732ccfdffc87396c2a01363e)
|
||||||
|
* Migrate to Vite 5 and adjust configurations | [4ac1425](https://github.com/undergroundwires/privacy.sexy/commit/4ac1425f76079352268c488f3ff607d1fdc1beb2)
|
||||||
|
* win: improve and unify service start/stop logic | [adc2089](https://github.com/undergroundwires/privacy.sexy/commit/adc20898873d50a8873ffc74c48257e69a45d367)
|
||||||
|
* Upgrade vitest to v1 and fix test definitions | [e721885](https://github.com/undergroundwires/privacy.sexy/commit/e7218850ba62a7bebaf4768b13e46cba0dedd906)
|
||||||
|
* Improve URL checks to reduce false-negatives | [5abf8ff](https://github.com/undergroundwires/privacy.sexy/commit/5abf8ff216a1da737fd489864eeee880f78d6601)
|
||||||
|
* win: improve OneDrive data deletion safety | [5eff3a0](https://github.com/undergroundwires/privacy.sexy/commit/5eff3a04886d0d23a6e4c13a0178bb247105c5cb)
|
||||||
|
* Bump Electron to latest and use native ESM | [840adf9](https://github.com/undergroundwires/privacy.sexy/commit/840adf9429ed47f9e88c05e90f1d3ab930c2dfc4)
|
||||||
|
* Fix tooltip styling inconsistency | [ec34ac1](https://github.com/undergroundwires/privacy.sexy/commit/ec34ac1124e8b8ae53bf31a4dbdc88bb078b3d4e)
|
||||||
|
* win: fix VSCode manual update switch script #312 | [b71ad79](https://github.com/undergroundwires/privacy.sexy/commit/b71ad797a3af0db45143249903cb5e178692de7c)
|
||||||
|
* mac, linux, win: fix dead URLs and improve docs | [abec9de](https://github.com/undergroundwires/privacy.sexy/commit/abec9def075d82fdaee9663ef8fe1a488911f45b)
|
||||||
|
|
||||||
|
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.0...0.13.1)
|
||||||
|
|
||||||
## 0.13.0 (2024-02-11)
|
## 0.13.0 (2024-02-11)
|
||||||
|
|
||||||
* win: add disabling clipboard features #251, #247 | [c6ebba8](https://github.com/undergroundwires/privacy.sexy/commit/c6ebba85fb1b362be0d81d3078f19db71e0528b2)
|
* win: add disabling clipboard features #251, #247 | [c6ebba8](https://github.com/undergroundwires/privacy.sexy/commit/c6ebba85fb1b362be0d81d3078f19db71e0528b2)
|
||||||
|
|||||||
@@ -122,7 +122,7 @@
|
|||||||
## Get started
|
## Get started
|
||||||
|
|
||||||
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
|
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
|
||||||
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.0/privacy.sexy-Setup-0.13.0.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.0/privacy.sexy-0.13.0.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.0/privacy.sexy-0.13.0.AppImage). For more options, see [here](#additional-install-options).
|
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.1/privacy.sexy-Setup-0.13.1.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.1/privacy.sexy-0.13.1.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.1/privacy.sexy-0.13.1.AppImage). For more options, see [here](#additional-install-options).
|
||||||
|
|
||||||
For a detailed comparison of features between the desktop and web versions of privacy.sexy, see [Desktop vs. Web Features](./docs/desktop-vs-web-features.md).
|
For a detailed comparison of features between the desktop and web versions of privacy.sexy, see [Desktop vs. Web Features](./docs/desktop-vs-web-features.md).
|
||||||
|
|
||||||
|
|||||||
@@ -14,14 +14,13 @@ The presentation layer uses an event-driven architecture for bidirectional react
|
|||||||
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
||||||
- [**`index.html`**](./../src/presentation/index.html): The `index.html` entry file, located at the root of the project as required by Vite
|
- [**`index.html`**](./../src/presentation/index.html): The `index.html` entry file, located at the root of the project as required by Vite
|
||||||
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
||||||
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
|
- [**`components/`**](./../src/presentation/components/): Contains Vue components, helpers and styles coupled to Vue components.
|
||||||
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
|
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
|
||||||
- [**`Hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
|
- [**`Hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
|
||||||
- [**`/public/`**](../src/presentation/public/): Contains static assets.
|
- [**`/public/`**](../src/presentation/public/): Contains static assets.
|
||||||
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
|
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
|
||||||
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
||||||
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
|
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
|
||||||
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components.
|
|
||||||
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint..
|
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint..
|
||||||
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
|
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
|
||||||
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
|
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
|
||||||
@@ -38,6 +37,13 @@ The presentation layer uses an event-driven architecture for bidirectional react
|
|||||||
They should also have different visual state when hovering/touching on them that indicates that they are being clicked, which helps with accessibility.
|
They should also have different visual state when hovering/touching on them that indicates that they are being clicked, which helps with accessibility.
|
||||||
- **Borders**:
|
- **Borders**:
|
||||||
privacy.sexy prefers sharper edges in its design language.
|
privacy.sexy prefers sharper edges in its design language.
|
||||||
|
- **Fonts**:
|
||||||
|
- Use the primary font for regular text and monospace font for code or specific data.
|
||||||
|
- Use cursive and logo fonts solely for branding.
|
||||||
|
- Refer to [standardized font size variables](../src/presentation/assets/styles/_typography.scss) for font sizing, avoiding arbitrary `px`, `em`, `rem`, or percentage values.
|
||||||
|
- **Spacing**:
|
||||||
|
Use [global spacing variables](../src/presentation/assets/styles/_spacing.scss) for consistent margin, padding, and gap definitions.
|
||||||
|
This provides uniform spatial distribution and alignment of elements, enhancing visual harmony and making the UI more scalable and maintainable.
|
||||||
|
|
||||||
## Application data
|
## Application data
|
||||||
|
|
||||||
|
|||||||
7729
package-lock.json
generated
7729
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "privacy.sexy",
|
"name": "privacy.sexy",
|
||||||
"version": "0.13.0",
|
"version": "0.13.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"slogan": "Privacy is sexy",
|
"slogan": "Privacy is sexy",
|
||||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
|
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
|
||||||
@@ -33,62 +33,66 @@
|
|||||||
"postuninstall": "electron-builder install-app-deps"
|
"postuninstall": "electron-builder install-app-deps"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@floating-ui/vue": "^1.0.2",
|
"@floating-ui/vue": "^1.0.6",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"@types/markdown-it": "^13.0.7",
|
"ace-builds": "^1.33.0",
|
||||||
"ace-builds": "^1.30.0",
|
|
||||||
"electron-log": "^5.1.2",
|
"electron-log": "^5.1.2",
|
||||||
"electron-progressbar": "^2.1.0",
|
"electron-progressbar": "^2.2.1",
|
||||||
"electron-updater": "^6.1.4",
|
"electron-updater": "^6.1.9",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"markdown-it": "^13.0.2",
|
"markdown-it": "^14.1.0",
|
||||||
"vue": "^3.3.7"
|
"vue": "^3.4.21"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
"@modyfi/vite-plugin-yaml": "^1.1.0",
|
||||||
"@rushstack/eslint-patch": "^1.6.1",
|
"@rushstack/eslint-patch": "^1.10.2",
|
||||||
"@types/ace": "^0.0.49",
|
"@types/ace": "^0.0.52",
|
||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.7",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@types/markdown-it": "^14.0.1",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "6.21.0",
|
||||||
|
"@typescript-eslint/parser": "6.21.0",
|
||||||
"@vitejs/plugin-legacy": "^5.3.2",
|
"@vitejs/plugin-legacy": "^5.3.2",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
|
"@vue/eslint-config-airbnb-with-typescript": "^8.0.0",
|
||||||
"@vue/eslint-config-typescript": "^12.0.0",
|
"@vue/eslint-config-typescript": "12.0.0",
|
||||||
"@vue/test-utils": "^2.4.1",
|
"@vue/test-utils": "^2.4.5",
|
||||||
"autoprefixer": "^10.4.16",
|
"autoprefixer": "^10.4.19",
|
||||||
"cypress": "^13.3.1",
|
"cypress": "^13.7.3",
|
||||||
"electron": "^29.1.4",
|
"electron": "^29.3.0",
|
||||||
"electron-builder": "^24.13.3",
|
"electron-builder": "^24.13.3",
|
||||||
"electron-devtools-installer": "^3.2.0",
|
"electron-devtools-installer": "^3.2.0",
|
||||||
"electron-icon-builder": "^2.0.1",
|
"electron-icon-builder": "^2.0.1",
|
||||||
"electron-vite": "^2.1.0",
|
"electron-vite": "^2.1.0",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "8.57.0",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^2.15.1",
|
||||||
"eslint-plugin-vue": "^9.19.2",
|
"eslint-plugin-vue": "^9.25.0",
|
||||||
"eslint-plugin-vuejs-accessibility": "^2.2.0",
|
"eslint-plugin-vuejs-accessibility": "^2.2.1",
|
||||||
"icon-gen": "^4.0.0",
|
"icon-gen": "^4.0.0",
|
||||||
"jsdom": "^22.1.0",
|
"jsdom": "^24.0.0",
|
||||||
"markdownlint-cli": "^0.37.0",
|
"markdownlint-cli": "^0.39.0",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.38",
|
||||||
"remark-cli": "^12.0.0",
|
"remark-cli": "^12.0.0",
|
||||||
"remark-lint-no-dead-urls": "^1.1.0",
|
"remark-lint-no-dead-urls": "^1.1.0",
|
||||||
"remark-preset-lint-consistent": "^5.1.2",
|
"remark-preset-lint-consistent": "^6.0.0",
|
||||||
"remark-validate-links": "^13.0.0",
|
"remark-validate-links": "^13.0.1",
|
||||||
"sass": "^1.69.3",
|
"sass": "^1.75.0",
|
||||||
"start-server-and-test": "^2.0.1",
|
"start-server-and-test": "^2.0.3",
|
||||||
"svgexport": "^0.4.2",
|
"svgexport": "^0.4.2",
|
||||||
"terser": "^5.21.0",
|
"terser": "^5.30.3",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.1.6",
|
"vite": "^5.2.8",
|
||||||
"vitest": "^1.3.1",
|
"vitest": "^1.5.0",
|
||||||
"vue-tsc": "^1.8.19",
|
"vue-tsc": "^2.0.13",
|
||||||
"yaml-lint": "^1.7.0"
|
"yaml-lint": "^1.7.0"
|
||||||
},
|
},
|
||||||
"//devDependencies": {
|
"//devDependencies": {
|
||||||
"terser": "Used by `@vitejs/plugin-legacy` for minification",
|
"terser": "Used by `@vitejs/plugin-legacy` for minification",
|
||||||
"@rushstack/eslint-patch": "Needed by `@vue/eslint-config-typescript` and `@vue/eslint-config-airbnb-with-typescript`"
|
"@rushstack/eslint-patch": "Needed by `@vue/eslint-config-typescript` and `@vue/eslint-config-airbnb-with-typescript`",
|
||||||
|
"@typescript-eslint/eslint-plugin": "Cannot migrate to v7 because of `@vue/eslint-config-airbnb-with-typescript`, see https://github.com/vuejs/eslint-config-airbnb/issues/63",
|
||||||
|
"@typescript-eslint/parser": "Cannot migrate to v7 because of `@vue/eslint-config-airbnb-with-typescript`, see https://github.com/vuejs/eslint-config-airbnb/issues/63",
|
||||||
|
"@vue/eslint-config-typescript": "Cannot migrate to v13 because of `@vue/eslint-config-airbnb-with-typescript`, see https://github.com/vuejs/eslint-config-airbnb/issues/63",
|
||||||
|
"eslint": "Cannot migrate to v9 `@typescript-eslint/eslint-plugin` (≤ v7), `@typescript-eslint/parser` (≤ v7), `@vue/eslint-config-airbnb-with-typescript@` (≤ v8) requires `eslint` ≤ v8, see https://github.com/vuejs/eslint-config-airbnb/issues/65, https://github.com/typescript-eslint/typescript-eslint/issues/8211"
|
||||||
},
|
},
|
||||||
"homepage": "https://privacy.sexy",
|
"homepage": "https://privacy.sexy",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -1,4 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
/**
|
||||||
|
* Description:
|
||||||
|
* This script updates the logo images across the project based on the primary
|
||||||
|
* logo file ('img/logo.svg' file).
|
||||||
|
* It handles the creation and update of various icon sizes for different purposes,
|
||||||
|
* including desktop launcher icons, tray icons, and web favicons from a single source
|
||||||
|
* SVG logo file.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node ./scripts/logo-update.js
|
||||||
|
*/
|
||||||
|
|
||||||
import { resolve, join } from 'node:path';
|
import { resolve, join } from 'node:path';
|
||||||
import { rm, mkdtemp, stat } from 'node:fs/promises';
|
import { rm, mkdtemp, stat } from 'node:fs/promises';
|
||||||
import { spawn } from 'node:child_process';
|
import { spawn } from 'node:child_process';
|
||||||
|
|||||||
@@ -1,62 +1,87 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Description:
|
* Description:
|
||||||
* This script checks if a server, provided as a CLI argument, is up
|
* This script checks if a server, provided as a CLI argument, is up
|
||||||
* and returns an HTTP 200 status code.
|
* and returns an HTTP 200 status code.
|
||||||
* It is designed to provide easy verification of server availability
|
* It is designed to provide easy verification of server availability
|
||||||
* and will retry a specified number of times.
|
* and will retry a specified number of times.
|
||||||
*
|
*
|
||||||
* Usage:
|
* Usage:
|
||||||
* node ./scripts/verify-web-server-status.js --url [URL]
|
* node ./scripts/verify-web-server-status.js --url [URL] [--max-retries NUMBER]
|
||||||
*
|
*
|
||||||
* Options:
|
* Options:
|
||||||
* --url URL of the server to check
|
* --url URL of the server to check
|
||||||
|
* --max-retries Maximum number of retry attempts (default: 30)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { get } from 'http';
|
const DEFAULT_MAX_RETRIES = 30;
|
||||||
|
|
||||||
const MAX_RETRIES = 30;
|
|
||||||
const RETRY_DELAY_IN_SECONDS = 3;
|
const RETRY_DELAY_IN_SECONDS = 3;
|
||||||
const URL_PARAMETER_NAME = '--url';
|
const PARAMETER_NAME_URL = '--url';
|
||||||
|
const PARAMETER_NAME_MAX_RETRIES = '--max-retries';
|
||||||
|
|
||||||
function checkServer(currentRetryCount = 1) {
|
async function checkServer(currentRetryCount = 1) {
|
||||||
const serverUrl = getServerUrl();
|
const serverUrl = readRequiredParameterValue(PARAMETER_NAME_URL);
|
||||||
console.log(`Requesting ${serverUrl}...`);
|
const maxRetries = parseNumber(
|
||||||
get(serverUrl, (res) => {
|
readOptionalParameterValue(PARAMETER_NAME_MAX_RETRIES, DEFAULT_MAX_RETRIES),
|
||||||
if (res.statusCode === 200) {
|
);
|
||||||
|
console.log(`🌐 Requesting ${serverUrl}...`);
|
||||||
|
try {
|
||||||
|
const response = await fetch(serverUrl);
|
||||||
|
if (response.status === 200) {
|
||||||
console.log('🎊 Success: The server is up and returned HTTP 200.');
|
console.log('🎊 Success: The server is up and returned HTTP 200.');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} else {
|
} else {
|
||||||
console.log(`Server returned HTTP status code ${res.statusCode}.`);
|
exitWithError(`Server returned unexpected HTTP status code ${response.statusCode}.`);
|
||||||
retry(currentRetryCount);
|
|
||||||
}
|
}
|
||||||
}).on('error', (err) => {
|
} catch (error) {
|
||||||
console.error('Error making the request:', err);
|
console.error('Error making the request:', error);
|
||||||
retry(currentRetryCount);
|
scheduleNextRetry(maxRetries, currentRetryCount);
|
||||||
});
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retry(currentRetryCount) {
|
function scheduleNextRetry(maxRetries, currentRetryCount) {
|
||||||
console.log(`Attempt ${currentRetryCount}/${MAX_RETRIES}:`);
|
console.log(`Attempt ${currentRetryCount}/${maxRetries}:`);
|
||||||
console.log(`Retrying in ${RETRY_DELAY_IN_SECONDS} seconds.`);
|
console.log(`Retrying in ${RETRY_DELAY_IN_SECONDS} seconds.`);
|
||||||
|
|
||||||
const remainingTime = (MAX_RETRIES - currentRetryCount) * RETRY_DELAY_IN_SECONDS;
|
const remainingTime = (maxRetries - currentRetryCount) * RETRY_DELAY_IN_SECONDS;
|
||||||
console.log(`Time remaining before timeout: ${remainingTime}s`);
|
console.log(`Time remaining before timeout: ${remainingTime}s`);
|
||||||
|
|
||||||
if (currentRetryCount < MAX_RETRIES) {
|
if (currentRetryCount < maxRetries) {
|
||||||
setTimeout(() => checkServer(currentRetryCount + 1), RETRY_DELAY_IN_SECONDS * 1000);
|
setTimeout(() => checkServer(currentRetryCount + 1), RETRY_DELAY_IN_SECONDS * 1000);
|
||||||
} else {
|
} else {
|
||||||
console.log('Failure: The server at did not return HTTP 200 within the allocated time. Exiting.');
|
exitWithError('The server at did not return HTTP 200 within the allocated time.');
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getServerUrl() {
|
function readRequiredParameterValue(parameterName) {
|
||||||
const urlIndex = process.argv.indexOf(URL_PARAMETER_NAME);
|
const parameterValue = readOptionalParameterValue(parameterName);
|
||||||
if (urlIndex === -1 || urlIndex === process.argv.length - 1) {
|
if (parameterValue === undefined) {
|
||||||
console.error(`Parameter "${URL_PARAMETER_NAME}" is not provided.`);
|
exitWithError(`Parameter "${parameterName}" is required but not provided.`);
|
||||||
process.exit(1);
|
|
||||||
}
|
}
|
||||||
return process.argv[urlIndex + 1];
|
return parameterValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkServer();
|
function readOptionalParameterValue(parameterName, defaultValue) {
|
||||||
|
const index = process.argv.indexOf(parameterName);
|
||||||
|
if (index === -1 || index === process.argv.length - 1) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
return process.argv[index + 1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseNumber(numberLike) {
|
||||||
|
const number = parseInt(numberLike, 10);
|
||||||
|
if (Number.isNaN(number)) {
|
||||||
|
exitWithError(`Invalid number: ${numberLike}`);
|
||||||
|
}
|
||||||
|
return number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitWithError(message) {
|
||||||
|
console.error(`Failure: ${message}`);
|
||||||
|
console.log('Exiting');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await checkServer();
|
||||||
|
|||||||
12
src/application/Common/Shuffle.ts
Normal file
12
src/application/Common/Shuffle.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/*
|
||||||
|
Shuffle an array of strings, returning a new array with elements in random order.
|
||||||
|
Uses the Fisher-Yates (or Durstenfeld) algorithm.
|
||||||
|
*/
|
||||||
|
export function shuffle<T>(array: readonly T[]): T[] {
|
||||||
|
const shuffledArray = [...array];
|
||||||
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(Math.random() * (i + 1));
|
||||||
|
[shuffledArray[i], shuffledArray[j]] = [shuffledArray[j], shuffledArray[i]];
|
||||||
|
}
|
||||||
|
return shuffledArray;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
16
src/presentation/assets/styles/_spacing.scss
Normal file
16
src/presentation/assets/styles/_spacing.scss
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
// Use for fixed-size elements where consistent spacing is important
|
||||||
|
// regardless of context.
|
||||||
|
$spacing-absolute-xx-small: 3px;
|
||||||
|
$spacing-absolute-x-small : 4px;
|
||||||
|
$spacing-absolute-small : 6px;
|
||||||
|
$spacing-absolute-medium : 10px;
|
||||||
|
$spacing-absolute-large : 15px;
|
||||||
|
$spacing-absolute-x-large : 20px;
|
||||||
|
$spacing-absolute-xx-large: 30px;
|
||||||
|
|
||||||
|
// Use for elements with text content where spacing should
|
||||||
|
// scale with text size.
|
||||||
|
$spacing-relative-x-small : 0.25em;
|
||||||
|
$spacing-relative-small : 0.5em;
|
||||||
|
$spacing-relative-medium : 1em;
|
||||||
|
$spacing-relative-large : 2em;
|
||||||
@@ -5,16 +5,15 @@
|
|||||||
CSS Base applies a style foundation for HTML elements that is consistent for baseline browsers
|
CSS Base applies a style foundation for HTML elements that is consistent for baseline browsers
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@use "@/presentation/assets/styles/colors" as *;
|
@use "../colors" as *;
|
||||||
@use "@/presentation/assets/styles/mixins" as *;
|
@use "../mixins" as *;
|
||||||
@use "@/presentation/assets/styles/vite-path" as *;
|
@use "../vite-path" as *;
|
||||||
@use "@/presentation/assets/styles/typography" as *;
|
@use "../typography" as *;
|
||||||
|
@use "../spacing" as *;
|
||||||
@use "_code-styling" as *;
|
@use "_code-styling" as *;
|
||||||
@use "_margin-padding" as *;
|
@use "_margin-padding" as *;
|
||||||
@use "_link-styling" as *;
|
@use "_link-styling" as *;
|
||||||
|
|
||||||
$base-spacing: 1em;
|
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
@@ -22,7 +21,7 @@ $base-spacing: 1em;
|
|||||||
body {
|
body {
|
||||||
background: $color-background;
|
background: $color-background;
|
||||||
@include base-font-style;
|
@include base-font-style;
|
||||||
@include apply-uniform-spacing($base-spacing: $base-spacing)
|
@include apply-uniform-spacing;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
@@ -30,12 +29,12 @@ input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
blockquote {
|
blockquote {
|
||||||
padding: 0 $base-spacing;
|
padding: 0 $spacing-relative-medium;
|
||||||
border-left: .25em solid $color-primary;
|
border-left: $spacing-absolute-x-small solid $color-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
@include style-code-elements(
|
@include style-code-elements(
|
||||||
$code-block-padding: $base-spacing,
|
$code-block-padding: $spacing-relative-medium,
|
||||||
$color-background: $color-primary-darker,
|
$color-background: $color-primary-darker,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
@use 'sass:math';
|
@use 'sass:math';
|
||||||
|
@use "../spacing" as *;
|
||||||
|
|
||||||
@mixin no-margin($selectors) {
|
@mixin no-margin($selectors) {
|
||||||
#{$selectors} {
|
#{$selectors} {
|
||||||
@@ -26,7 +27,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin apply-uniform-vertical-spacing($base-vertical-spacing) {
|
@mixin apply-uniform-vertical-spacing {
|
||||||
/* Reset default top/bottom margins added by browser. */
|
/* Reset default top/bottom margins added by browser. */
|
||||||
@include no-margin('p');
|
@include no-margin('p');
|
||||||
@include no-margin('h1, h2, h3, h4, h5, h6');
|
@include no-margin('h1, h2, h3, h4, h5, h6');
|
||||||
@@ -36,29 +37,27 @@
|
|||||||
@include no-margin('ul, ol');
|
@include no-margin('ul, ol');
|
||||||
|
|
||||||
/* Add spacing between elements using `margin-bottom` only (bottom-up instead of top-down strategy). */
|
/* Add spacing between elements using `margin-bottom` only (bottom-up instead of top-down strategy). */
|
||||||
$small-vertical-spacing: math.div($base-vertical-spacing, 2);
|
@include bottom-margin('p', $spacing-relative-medium);
|
||||||
@include bottom-margin('p', $base-vertical-spacing);
|
@include bottom-margin('li > p', $spacing-relative-small); // Reduce margin for paragraphs directly within list items to visually group related content.
|
||||||
@include bottom-margin('li > p', $small-vertical-spacing); // Reduce margin for paragraphs directly within list items to visually group related content.
|
@include bottom-margin('h1, h2, h3, h4, h5, h6', $spacing-relative-small);
|
||||||
@include bottom-margin('h1, h2, h3, h4, h5, h6', $small-vertical-spacing);
|
@include bottom-margin('ul, ol', $spacing-relative-medium);
|
||||||
@include bottom-margin('ul, ol', $base-vertical-spacing);
|
@include bottom-margin('li', $spacing-relative-small);
|
||||||
@include bottom-margin('li', $small-vertical-spacing);
|
@include bottom-margin('table', $spacing-relative-medium);
|
||||||
@include bottom-margin('table', $base-vertical-spacing);
|
@include bottom-margin('blockquote', $spacing-relative-medium);
|
||||||
@include bottom-margin('blockquote', $base-vertical-spacing);
|
@include bottom-margin('pre', $spacing-relative-medium);
|
||||||
@include bottom-margin('pre', $base-vertical-spacing);
|
@include bottom-margin('article', $spacing-relative-medium);
|
||||||
@include bottom-margin('article', $base-vertical-spacing);
|
@include bottom-margin('hr', $spacing-relative-medium);
|
||||||
@include bottom-margin('hr', $base-vertical-spacing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin apply-uniform-horizontal-spacing($base-horizontal-spacing) {
|
@mixin apply-uniform-horizontal-spacing {
|
||||||
/* Reset default left/right paddings added by browser. */
|
/* Reset default left/right paddings added by browser. */
|
||||||
@include no-padding('ul, ol');
|
@include no-padding('ul, ol');
|
||||||
|
|
||||||
/* Add spacing for list items. */
|
/* Add spacing for list items. */
|
||||||
$large-horizontal-spacing: $base-horizontal-spacing * 2;
|
@include left-padding('ul, ol', $spacing-relative-large);
|
||||||
@include left-padding('ul, ol', $large-horizontal-spacing);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin apply-uniform-spacing($base-spacing) {
|
@mixin apply-uniform-spacing {
|
||||||
@include apply-uniform-vertical-spacing($base-spacing);
|
@include apply-uniform-vertical-spacing;
|
||||||
@include apply-uniform-horizontal-spacing($base-spacing);
|
@include apply-uniform-horizontal-spacing;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
$card-gap: 15px;
|
|
||||||
@@ -5,6 +5,5 @@
|
|||||||
@forward "./media";
|
@forward "./media";
|
||||||
@forward "./colors";
|
@forward "./colors";
|
||||||
@forward "./base";
|
@forward "./base";
|
||||||
|
@forward "./spacing";
|
||||||
@forward "./mixins";
|
@forward "./mixins";
|
||||||
|
|
||||||
@forward "./components/card";
|
|
||||||
|
|||||||
@@ -50,24 +50,48 @@ function getOptionalDevToolkitComponent(): Component | undefined {
|
|||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use 'sass:math';
|
||||||
|
|
||||||
|
@mixin responsive-spacing {
|
||||||
|
// Avoid using percentage-based values for spacing the avoid unintended layout shifts.
|
||||||
|
margin-left: $spacing-absolute-medium;
|
||||||
|
margin-right: $spacing-absolute-medium;
|
||||||
|
padding: $spacing-absolute-xx-large;
|
||||||
|
@media screen and (max-width: $media-screen-big-width) {
|
||||||
|
margin-left: $spacing-absolute-small;
|
||||||
|
margin-right: $spacing-absolute-small;
|
||||||
|
padding: $spacing-absolute-x-large;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: $media-screen-medium-width) {
|
||||||
|
margin-left: $spacing-absolute-x-small;
|
||||||
|
margin-right: $spacing-absolute-x-small;
|
||||||
|
padding: $spacing-absolute-medium;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: $media-screen-small-width) {
|
||||||
|
margin-left: 0;
|
||||||
|
margin-right: 0;
|
||||||
|
padding: $spacing-absolute-small;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#app {
|
#app {
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
max-width: 1600px;
|
max-width: 1600px;
|
||||||
.app__wrapper {
|
.app__wrapper {
|
||||||
margin: 0% 2% 0% 2%;
|
|
||||||
background-color: $color-surface;
|
background-color: $color-surface;
|
||||||
color: $color-on-surface;
|
color: $color-on-surface;
|
||||||
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
|
box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.06);
|
||||||
padding: 2%;
|
|
||||||
|
@include responsive-spacing;
|
||||||
|
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
.app__row {
|
.app__row {
|
||||||
margin-bottom: 10px;
|
margin-bottom: $spacing-absolute-large;
|
||||||
}
|
}
|
||||||
.app__code-buttons {
|
.app__code-buttons {
|
||||||
padding-bottom: 10px;
|
padding-bottom: $spacing-absolute-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export default defineComponent({
|
|||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
|
|
||||||
border: none;
|
border: none;
|
||||||
padding: 20px;
|
|
||||||
transition-duration: 0.4s;
|
transition-duration: 0.4s;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 3px 9px $color-primary-darkest;
|
box-shadow: 0 3px 9px $color-primary-darkest;
|
||||||
|
|||||||
@@ -54,14 +54,14 @@ export default defineComponent({
|
|||||||
|
|
||||||
.copyable-command {
|
.copyable-command {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
padding: 0.25em;
|
padding: $spacing-relative-x-small;
|
||||||
font-size: $font-size-absolute-small;
|
font-size: $font-size-absolute-small;
|
||||||
.dollar {
|
.dollar {
|
||||||
margin-right: 0.5rem;
|
margin-right: $spacing-relative-small;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
.copy-action-container {
|
.copy-action-container {
|
||||||
margin-left: 1rem;
|
margin-left: $spacing-relative-medium;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'InstructionSteps', // Define component name for empty component for Vue build and ESLint compatibility.
|
// Empty component for ESLint compatibility, workaround for https://github.com/vuejs/vue-eslint-parser/issues/125.
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,6 @@
|
|||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
name: 'InstructionSteps', // Define component name for empty component for Vue build and ESLint compatibility.
|
// Empty component for ESLint compatibility, workaround for https://github.com/vuejs/vue-eslint-parser/issues/125.
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -34,12 +34,15 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 30px;
|
gap: $spacing-absolute-xx-large;
|
||||||
}
|
}
|
||||||
|
|
||||||
.code-button {
|
.code-button {
|
||||||
width: 10%;
|
width: 10%;
|
||||||
min-width: 90px;
|
min-width: 90px;
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ interface DevAction {
|
|||||||
right: 0;
|
right: 0;
|
||||||
background-color: rgba($color-on-surface, 0.5);
|
background-color: rgba($color-on-surface, 0.5);
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
padding: 10px;
|
padding: $spacing-absolute-medium;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
|
|
||||||
display:flex;
|
display:flex;
|
||||||
@@ -113,14 +113,14 @@ interface DevAction {
|
|||||||
.action-buttons {
|
.action-buttons {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 10px;
|
gap: $spacing-absolute-medium;
|
||||||
@include reset-ul;
|
@include reset-ul;
|
||||||
|
|
||||||
.action-button {
|
.action-button {
|
||||||
@include reset-button;
|
@include reset-button;
|
||||||
|
|
||||||
display: block;
|
display: block;
|
||||||
padding: 5px 10px;
|
padding: $spacing-absolute-x-small $spacing-absolute-medium;
|
||||||
background-color: $color-primary;
|
background-color: $color-primary;
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default defineComponent({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
$gap: 0.25rem;
|
$gap: $spacing-relative-x-small;
|
||||||
.list {
|
.list {
|
||||||
display: flex;
|
display: flex;
|
||||||
:deep(.items) {
|
:deep(.items) {
|
||||||
|
|||||||
@@ -37,8 +37,10 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.circle-rating {
|
.circle-rating {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.2em;
|
gap: $spacing-relative-x-small;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,19 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<svg
|
<span class="circle-container">
|
||||||
:style="{
|
<svg :viewBox="viewBox">
|
||||||
'--circle-stroke-width': `${circleStrokeWidthInPx}px`,
|
<circle
|
||||||
}"
|
:cx="circleRadiusInPx"
|
||||||
:viewBox="viewBox"
|
:cy="circleRadiusInPx"
|
||||||
>
|
:r="circleRadiusWithoutStrokeInPx"
|
||||||
<circle
|
:stroke-width="circleStrokeWidthStyleValue"
|
||||||
:cx="circleRadiusInPx"
|
:class="{
|
||||||
:cy="circleRadiusInPx"
|
filled,
|
||||||
:r="circleRadiusWithoutStrokeInPx"
|
}"
|
||||||
:class="{
|
/>
|
||||||
filled,
|
</svg>
|
||||||
}"
|
</span>
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
@@ -43,10 +41,13 @@ export default defineComponent({
|
|||||||
const height = circleDiameterInPx + circleStrokeWidthInPx;
|
const height = circleDiameterInPx + circleStrokeWidthInPx;
|
||||||
return `${minX} ${minY} ${width} ${height}`;
|
return `${minX} ${minY} ${width} ${height}`;
|
||||||
});
|
});
|
||||||
|
const circleStrokeWidthStyleValue = computed(() => {
|
||||||
|
return `${circleStrokeWidthInPx}px`;
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
circleRadiusInPx,
|
circleRadiusInPx,
|
||||||
circleDiameterInPx,
|
circleDiameterInPx,
|
||||||
circleStrokeWidthInPx,
|
circleStrokeWidthStyleValue,
|
||||||
circleRadiusWithoutStrokeInPx,
|
circleRadiusWithoutStrokeInPx,
|
||||||
viewBox,
|
viewBox,
|
||||||
};
|
};
|
||||||
@@ -55,17 +56,18 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$circleColor: currentColor;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
$circleHeight: 0.8em;
|
|
||||||
$circleStrokeWidth: var(--circle-stroke-width);
|
$circle-color: currentColor;
|
||||||
|
$circle-height: $font-size-relative-smaller;
|
||||||
|
|
||||||
svg {
|
svg {
|
||||||
height: $circleHeight;
|
font-size: $circle-height;
|
||||||
|
height: 1em;
|
||||||
circle {
|
circle {
|
||||||
stroke: $circleColor;
|
stroke: $circle-color;
|
||||||
stroke-width: $circleStrokeWidth;
|
|
||||||
&.filled {
|
&.filled {
|
||||||
fill: $circleColor;
|
fill: $circle-color;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
@mixin horizontal-stack {
|
@mixin horizontal-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin apply-icon-color($color) {
|
@mixin apply-icon-color($color) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
@mixin horizontal-stack {
|
@mixin horizontal-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5em;
|
gap: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
@mixin apply-icon-color($color) {
|
@mixin apply-icon-color($color) {
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div id="container">
|
<div class="scripts-menu">
|
||||||
<div class="item rows">
|
<div class="scripts-menu-item scripts-menu-rows">
|
||||||
<TheRecommendationSelector class="item" />
|
<TheRecommendationSelector class="scripts-menu-item" />
|
||||||
<TheRevertSelector class="item" />
|
<TheRevertSelector class="scripts-menu-item" />
|
||||||
</div>
|
</div>
|
||||||
<TheOsChanger class="item" />
|
<TheOsChanger class="scripts-menu-item" />
|
||||||
<TheViewChanger
|
<TheViewChanger
|
||||||
v-if="!isSearching"
|
v-if="!isSearching"
|
||||||
class="item"
|
class="scripts-menu-item"
|
||||||
@view-changed="$emit('viewChanged', $event)"
|
@view-changed="$emit('viewChanged', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,29 +67,44 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
$margin-between-lines: 7px;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
#container {
|
@use 'sass:math';
|
||||||
|
|
||||||
|
@mixin center-middle-flex-item {
|
||||||
|
&:first-child, &:last-child {
|
||||||
|
flex-grow: 1;
|
||||||
|
flex-basis: 0;
|
||||||
|
}
|
||||||
|
&:last-child {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$responsive-alignment-breakpoint: $media-screen-medium-width;
|
||||||
|
|
||||||
|
.scripts-menu {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: -$margin-between-lines;
|
column-gap: $spacing-relative-medium;
|
||||||
.item {
|
row-gap: $spacing-relative-small;
|
||||||
flex: 1;
|
flex-wrap: wrap;
|
||||||
white-space: nowrap;
|
align-items: center;
|
||||||
|
margin-left: $spacing-absolute-small;
|
||||||
|
margin-right: $spacing-absolute-small;
|
||||||
|
@media screen and (max-width: $responsive-alignment-breakpoint) {
|
||||||
|
justify-content: space-around;
|
||||||
|
}
|
||||||
|
.scripts-menu-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
@media screen and (min-width: $responsive-alignment-breakpoint) {
|
||||||
align-items: center;
|
@include center-middle-flex-item;
|
||||||
margin: $margin-between-lines 5px 0 5px;
|
|
||||||
&:first-child {
|
|
||||||
justify-content: flex-start;
|
|
||||||
}
|
|
||||||
&:last-child {
|
|
||||||
justify-content: flex-end;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.rows {
|
.scripts-menu-rows {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
row-gap: $spacing-relative-x-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div class="slider">
|
||||||
class="slider"
|
|
||||||
:style="{
|
|
||||||
'--vertical-margin': verticalMargin,
|
|
||||||
'--first-min-width': firstMinWidth,
|
|
||||||
'--first-initial-width': firstInitialWidth,
|
|
||||||
'--second-min-width': secondMinWidth,
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<div ref="firstElement" class="first">
|
<div ref="firstElement" class="first">
|
||||||
<slot name="first" />
|
<slot name="first" />
|
||||||
</div>
|
</div>
|
||||||
@@ -27,10 +19,6 @@ export default defineComponent({
|
|||||||
SliderHandle,
|
SliderHandle,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
verticalMargin: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
firstMinWidth: {
|
firstMinWidth: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
@@ -71,21 +59,18 @@ export default defineComponent({
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
.first {
|
.first {
|
||||||
min-width: var(--first-min-width);
|
min-width: v-bind(firstMinWidth);
|
||||||
width: var(--first-initial-width);
|
width: v-bind(firstInitialWidth);
|
||||||
}
|
}
|
||||||
.second {
|
.second {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-width: var(--second-min-width);
|
min-width: v-bind(secondMinWidth);
|
||||||
}
|
}
|
||||||
@media screen and (max-width: $media-vertical-view-breakpoint) {
|
@media screen and (max-width: $media-vertical-view-breakpoint) {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
.first {
|
.first {
|
||||||
width: auto !important;
|
width: auto !important;
|
||||||
}
|
}
|
||||||
.second {
|
|
||||||
margin-top: var(--vertical-margin);
|
|
||||||
}
|
|
||||||
.handle {
|
.handle {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ $cursor : v-bind(cursorCssValue);
|
|||||||
.icon {
|
.icon {
|
||||||
color: $color;
|
color: $color;
|
||||||
}
|
}
|
||||||
margin-right: 5px;
|
margin-right: $spacing-absolute-small;
|
||||||
margin-left: 5px;
|
margin-left: $spacing-absolute-small;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,8 +2,7 @@
|
|||||||
<div class="script-area">
|
<div class="script-area">
|
||||||
<TheScriptsMenu @view-changed="currentView = $event" />
|
<TheScriptsMenu @view-changed="currentView = $event" />
|
||||||
<HorizontalResizeSlider
|
<HorizontalResizeSlider
|
||||||
class="row"
|
class="horizontal-slider"
|
||||||
vertical-margin="15px"
|
|
||||||
first-initial-width="55%"
|
first-initial-width="55%"
|
||||||
first-min-width="20%"
|
first-min-width="20%"
|
||||||
second-min-width="20%"
|
second-min-width="20%"
|
||||||
@@ -42,9 +41,17 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.script-area {
|
.script-area {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: $spacing-absolute-medium;
|
||||||
|
}
|
||||||
|
|
||||||
|
.horizontal-slider {
|
||||||
|
// Add row gap between lines on mobile (smaller screens)
|
||||||
|
// when the slider turns into rows.
|
||||||
|
row-gap: $spacing-absolute-large;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<template>
|
||||||
|
<transition name="card-expand-collapse-transition">
|
||||||
|
<slot />
|
||||||
|
</transition>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
// Empty component for ESLint compatibility, workaround for https://github.com/vuejs/vue-eslint-parser/issues/125.
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
.card-expand-collapse-transition-enter-active {
|
||||||
|
transition:
|
||||||
|
opacity 0.3s ease-in-out,
|
||||||
|
max-height 0.3s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-expand-collapse-transition-enter-from {
|
||||||
|
opacity: 0; // Fade-in
|
||||||
|
max-height: 0; // Expand
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<template>
|
||||||
|
<div class="arrow-container">
|
||||||
|
<div class="arrow" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script lang="ts">
|
||||||
|
import { defineComponent } from 'vue';
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
|
// Empty component for ESLint compatibility, workaround for https://github.com/vuejs/vue-eslint-parser/issues/125.
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
$arrow-size: $font-size-absolute-normal;
|
||||||
|
|
||||||
|
.arrow-container {
|
||||||
|
position: relative;
|
||||||
|
.arrow {
|
||||||
|
position: absolute;
|
||||||
|
left: calc(50% - $arrow-size * 1.5);
|
||||||
|
top: calc(-0.35 * $arrow-size);
|
||||||
|
border: solid $color-primary-darker;
|
||||||
|
border-width: 0 $arrow-size $arrow-size 0;
|
||||||
|
padding: $arrow-size;
|
||||||
|
transform: rotate(-135deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -125,6 +125,7 @@ function isClickable(element: Element) {
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use "./card-gap" as *;
|
||||||
|
|
||||||
.cards {
|
.cards {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -135,7 +136,7 @@ function isClickable(element: Element) {
|
|||||||
It ensures that there's room to grow, so the animation is shown without overflowing
|
It ensures that there's room to grow, so the animation is shown without overflowing
|
||||||
with scrollbars.
|
with scrollbars.
|
||||||
*/
|
*/
|
||||||
padding: 10px;
|
padding: $spacing-absolute-medium;
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
ref="cardElement"
|
ref="cardElement"
|
||||||
class="card"
|
class="card"
|
||||||
:class="{
|
:class="{
|
||||||
'is-collapsed': !isExpanded,
|
|
||||||
'is-inactive': activeCategoryId && activeCategoryId !== categoryId,
|
'is-inactive': activeCategoryId && activeCategoryId !== categoryId,
|
||||||
'is-expanded': isExpanded,
|
'is-expanded': isExpanded,
|
||||||
}"
|
}"
|
||||||
@@ -29,20 +28,28 @@
|
|||||||
:category-id="categoryId"
|
:category-id="categoryId"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__expander" @click.stop>
|
<CardExpandTransition>
|
||||||
<div class="card__expander__close-button">
|
<div v-show="isExpanded">
|
||||||
<FlatButton
|
<CardExpansionArrow />
|
||||||
icon="xmark"
|
<div
|
||||||
@click="collapse()"
|
class="card__expander"
|
||||||
/>
|
@click.stop
|
||||||
|
>
|
||||||
|
<div class="card__expander__close-button">
|
||||||
|
<FlatButton
|
||||||
|
icon="xmark"
|
||||||
|
@click="collapse()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="card__expander__content">
|
||||||
|
<ScriptsTree
|
||||||
|
:category-id="categoryId"
|
||||||
|
:has-top-padding="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card__expander__content">
|
</CardExpandTransition>
|
||||||
<ScriptsTree
|
|
||||||
:category-id="categoryId"
|
|
||||||
:has-top-padding="false"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -56,6 +63,8 @@ import { injectKey } from '@/presentation/injectionSymbols';
|
|||||||
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
|
||||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||||
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
import CardSelectionIndicator from './CardSelectionIndicator.vue';
|
||||||
|
import CardExpandTransition from './CardExpandTransition.vue';
|
||||||
|
import CardExpansionArrow from './CardExpansionArrow.vue';
|
||||||
|
|
||||||
export default defineComponent({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
@@ -63,6 +72,8 @@ export default defineComponent({
|
|||||||
AppIcon,
|
AppIcon,
|
||||||
CardSelectionIndicator,
|
CardSelectionIndicator,
|
||||||
FlatButton,
|
FlatButton,
|
||||||
|
CardExpandTransition,
|
||||||
|
CardExpansionArrow,
|
||||||
},
|
},
|
||||||
props: {
|
props: {
|
||||||
categoryId: {
|
categoryId: {
|
||||||
@@ -127,16 +138,14 @@ export default defineComponent({
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
@use "./card-gap" as *;
|
||||||
|
|
||||||
$card-inner-padding : 30px;
|
$card-inner-padding : $spacing-absolute-xx-large;
|
||||||
$arrow-size : 15px;
|
$expanded-margin-top : $spacing-absolute-xx-large;
|
||||||
$expanded-margin-top : 30px;
|
|
||||||
$card-horizontal-gap : $card-gap;
|
$card-horizontal-gap : $card-gap;
|
||||||
|
|
||||||
.card {
|
.card {
|
||||||
transition: all 0.2s ease-in-out;
|
.card__inner {
|
||||||
|
|
||||||
&__inner {
|
|
||||||
padding-top: $card-inner-padding;
|
padding-top: $card-inner-padding;
|
||||||
padding-right: $card-inner-padding;
|
padding-right: $card-inner-padding;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
@@ -149,7 +158,7 @@ $card-horizontal-gap : $card-gap;
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease-in-out;
|
transition: transform 0.2s ease-in-out;
|
||||||
|
|
||||||
display:flex;
|
display:flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -160,9 +169,6 @@ $card-horizontal-gap : $card-gap;
|
|||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
&:after {
|
|
||||||
transition: all 0.3s ease-in-out;
|
|
||||||
}
|
|
||||||
.card__inner__title {
|
.card__inner__title {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -173,19 +179,18 @@ $card-horizontal-gap : $card-gap;
|
|||||||
.card__inner__selection_indicator {
|
.card__inner__selection_indicator {
|
||||||
height: $card-inner-padding;
|
height: $card-inner-padding;
|
||||||
margin-right: -$card-inner-padding;
|
margin-right: -$card-inner-padding;
|
||||||
padding-right: 10px;
|
padding-right: $spacing-absolute-medium;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
.card__inner__expand-icon {
|
.card__inner__expand-icon {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin-top: .25em;
|
margin-top: $spacing-relative-x-small;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.card__expander {
|
.card__expander {
|
||||||
transition: all 0.2s ease-in-out;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
background-color: $color-primary-darker;
|
background-color: $color-primary-darker;
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
@@ -205,7 +210,7 @@ $card-horizontal-gap : $card-gap;
|
|||||||
.card__expander__close-button {
|
.card__expander__close-button {
|
||||||
font-size: $font-size-absolute-large;
|
font-size: $font-size-absolute-large;
|
||||||
align-self: flex-end;
|
align-self: flex-end;
|
||||||
margin-right: 0.25em;
|
margin-right: $spacing-absolute-small;
|
||||||
@include clickable;
|
@include clickable;
|
||||||
color: $color-primary-light;
|
color: $color-primary-light;
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
@@ -214,43 +219,15 @@ $card-horizontal-gap : $card-gap;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-collapsed {
|
|
||||||
.card__inner {
|
|
||||||
&:after {
|
|
||||||
content: "";
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.card__expander {
|
|
||||||
max-height: 0;
|
|
||||||
min-height: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-expanded {
|
&.is-expanded {
|
||||||
.card__inner {
|
.card__inner {
|
||||||
height: auto;
|
height: auto;
|
||||||
background-color: $color-secondary;
|
background-color: $color-secondary;
|
||||||
color: $color-on-secondary;
|
color: $color-on-secondary;
|
||||||
&:after { // arrow
|
|
||||||
content: "";
|
|
||||||
display: block;
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(-1 * #{$expanded-margin-top});
|
|
||||||
left: calc(50% - #{$arrow-size});
|
|
||||||
border-left: #{$arrow-size} solid transparent;
|
|
||||||
border-right: #{$arrow-size} solid transparent;
|
|
||||||
border-bottom: #{$arrow-size} solid $color-primary-darker;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card__expander {
|
.card__expander {
|
||||||
min-height: 200px;
|
|
||||||
margin-top: $expanded-margin-top;
|
margin-top: $expanded-margin-top;
|
||||||
opacity: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@include hover-or-touch {
|
@include hover-or-touch {
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
|
$card-gap: $spacing-absolute-large;
|
||||||
@@ -128,10 +128,7 @@ export default defineComponent({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
$margin-inner: 4px;
|
|
||||||
|
|
||||||
.scripts-view {
|
.scripts-view {
|
||||||
margin-top: $margin-inner;
|
|
||||||
@media screen and (min-width: $media-vertical-view-breakpoint) {
|
@media screen and (min-width: $media-vertical-view-breakpoint) {
|
||||||
// so the current code is always visible
|
// so the current code is always visible
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
@@ -143,16 +140,17 @@ $margin-inner: 4px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
background-color: $color-scripts-bg;
|
background-color: $color-scripts-bg;
|
||||||
|
padding-top: $spacing-absolute-large;
|
||||||
|
padding-bottom:$spacing-absolute-large;
|
||||||
.search__query {
|
.search__query {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-top: 1em;
|
|
||||||
color: $color-primary-light;
|
color: $color-primary-light;
|
||||||
.search__query__close-button {
|
.search__query__close-button {
|
||||||
font-size: $font-size-absolute-large;
|
font-size: $font-size-absolute-large;
|
||||||
margin-left: 0.25rem;
|
margin-left: $spacing-relative-x-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.search-no-matches {
|
.search-no-matches {
|
||||||
@@ -161,11 +159,9 @@ $margin-inner: 4px;
|
|||||||
word-break:break-word;
|
word-break:break-word;
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
font-size: $font-size-absolute-large;
|
font-size: $font-size-absolute-large;
|
||||||
padding:10px;
|
padding: $spacing-absolute-medium;
|
||||||
text-align:center;
|
text-align:center;
|
||||||
> div {
|
gap: $spacing-relative-small;
|
||||||
padding-bottom:13px;
|
|
||||||
}
|
|
||||||
a {
|
a {
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default defineComponent({
|
|||||||
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
||||||
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
|
||||||
*:not(:first-child) {
|
*:not(:first-child) {
|
||||||
margin-left: 5px;
|
margin-left: $spacing-absolute-small;
|
||||||
}
|
}
|
||||||
.header {
|
.header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -80,10 +80,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
.docs {
|
.docs {
|
||||||
background: $color-primary-darkest;
|
background: $color-primary-darkest;
|
||||||
margin-top: 0.25em;
|
margin-top: $spacing-relative-x-small;
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
text-transform: none;
|
text-transform: none;
|
||||||
padding: 0.5em;
|
padding: $spacing-absolute-medium;
|
||||||
&-collapsed {
|
&-collapsed {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
import type { MarkdownRenderer } from '../MarkdownRenderer';
|
import type { MarkdownRenderer } from '../MarkdownRenderer';
|
||||||
import type { RenderRule } from 'markdown-it/lib/renderer';
|
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'; // eslint-disable-line import/extensions
|
||||||
|
|
||||||
export class MarkdownItHtmlRenderer implements MarkdownRenderer {
|
export class MarkdownItHtmlRenderer implements MarkdownRenderer {
|
||||||
public render(markdownContent: string): string {
|
public render(markdownContent: string): string {
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<DocumentableNode :docs="nodeMetadata.docs">
|
<DocumentableNode
|
||||||
<div id="node">
|
class="node-content-wrapper"
|
||||||
<div class="item">
|
:docs="nodeMetadata.docs"
|
||||||
|
>
|
||||||
|
<div class="node-content">
|
||||||
|
<div class="node-content-item">
|
||||||
<NodeTitle :title="nodeMetadata.text" />
|
<NodeTitle :title="nodeMetadata.text" />
|
||||||
</div>
|
</div>
|
||||||
<RevertToggle
|
<RevertToggle
|
||||||
v-if="nodeMetadata.isReversible"
|
v-if="nodeMetadata.isReversible"
|
||||||
class="item"
|
class="node-content-item"
|
||||||
:node="nodeMetadata"
|
:node="nodeMetadata"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -38,13 +41,22 @@ export default defineComponent({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
#node {
|
.node-content-wrapper {
|
||||||
display: flex;
|
/*
|
||||||
flex-direction: row;
|
Compensate word breaking when it causes overflows of the node content,
|
||||||
flex-wrap: wrap;
|
This issue happens on small devices when nodes are being rendered during search where the node header or
|
||||||
|
documentation grows to cause to overflow.
|
||||||
|
*/
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.item:not(:first-child) {
|
.node-content {
|
||||||
margin-left: 5px;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
.node-content-item:not(:first-child) {
|
||||||
|
margin-left: $spacing-relative-small;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<ToggleSwitch
|
<ToggleSwitch
|
||||||
v-model="isReverted"
|
v-model="isReverted"
|
||||||
:stop-click-propagation="true"
|
:stop-click-propagation="true"
|
||||||
:label="'Revert'"
|
label="Revert"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -83,12 +83,12 @@ $color-text-unchecked : $color-on-primary;
|
|||||||
$color-text-checked : $color-on-secondary;
|
$color-text-checked : $color-on-secondary;
|
||||||
$color-bg-unchecked : $color-primary;
|
$color-bg-unchecked : $color-primary;
|
||||||
$color-bg-checked : $color-secondary;
|
$color-bg-checked : $color-secondary;
|
||||||
$padding-horizontal : calc($font-size * 0.4);
|
$padding-horizontal : $spacing-relative-small;
|
||||||
$padding-vertical : calc($font-size * 0.3);
|
$padding-vertical : $spacing-absolute-small;
|
||||||
$size-height : calc($font-size + ($padding-vertical * 2));
|
$size-height : calc($font-size + ($padding-vertical * 2));
|
||||||
$size-circle : math.div($size-height * 2, 3);
|
$size-circle : calc($size-height * 2/3);
|
||||||
|
|
||||||
$gap-between-circle-and-text : 0.25em;
|
$gap-between-circle-and-text : $spacing-relative-x-small;
|
||||||
|
|
||||||
@mixin locateNearCircle($direction: 'left') {
|
@mixin locateNearCircle($direction: 'left') {
|
||||||
$circle-width: calc(#{$size-circle} + #{$padding-horizontal});
|
$circle-width: calc(#{$size-circle} + #{$padding-horizontal});
|
||||||
|
|||||||
@@ -73,7 +73,8 @@ export default defineComponent({
|
|||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
$padding: 20px;
|
$padding-horizontal : $spacing-absolute-large;
|
||||||
|
$padding-vertical : $spacing-absolute-x-large;
|
||||||
|
|
||||||
.scripts-tree-container {
|
.scripts-tree-container {
|
||||||
display: flex; // We could provide `block`, but `flex` is more versatile.
|
display: flex; // We could provide `block`, but `flex` is more versatile.
|
||||||
@@ -84,11 +85,11 @@ $padding: 20px;
|
|||||||
|
|
||||||
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
flex: 1; // Expands the container to fill available horizontal space, enabling alignment of child items.
|
||||||
|
|
||||||
padding-bottom: $padding;
|
padding-bottom: $padding-vertical;
|
||||||
padding-left: $padding;
|
padding-left: $padding-horizontal;
|
||||||
padding-right: $padding;
|
padding-right: $padding-horizontal;
|
||||||
&.top-padding {
|
&.top-padding {
|
||||||
padding-top: $padding;
|
padding-top: $padding-vertical;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
:tree-root="treeRoot"
|
:tree-root="treeRoot"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="expand-collapse-arrow"
|
class="expand-collapse-caret"
|
||||||
:class="{
|
:class="{
|
||||||
expanded: isExpanded,
|
expanded: isExpanded,
|
||||||
'has-children': hasChildren,
|
'has-children': hasChildren,
|
||||||
@@ -141,10 +141,13 @@ export default defineComponent({
|
|||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
||||||
.expand-collapse-arrow {
|
.expand-collapse-caret {
|
||||||
|
$caret-size: 24px;
|
||||||
|
$padding-right: $spacing-absolute-small;
|
||||||
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
height: 30px;
|
height: $caret-size;
|
||||||
margin-left: 30px;
|
margin-left: $caret-size + $padding-right;
|
||||||
width: 0;
|
width: 0;
|
||||||
|
|
||||||
@include clickable;
|
@include clickable;
|
||||||
@@ -157,25 +160,32 @@ export default defineComponent({
|
|||||||
|
|
||||||
&.has-children {
|
&.has-children {
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
width: 30px;
|
width: $caret-size + $padding-right;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
|
$caret-dimension: $caret-size * 0.375;
|
||||||
|
$caret-stroke-width: 1.5px;
|
||||||
&:after {
|
&:after {
|
||||||
border: 1.5px solid $color-node-arrow;
|
border: $caret-stroke-width solid $color-node-arrow;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
border-left: 0;
|
border-left: 0;
|
||||||
border-top: 0;
|
border-top: 0;
|
||||||
left: 9px;
|
left: $caret-dimension;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
height: 9px;
|
height: $caret-dimension;
|
||||||
width: 9px;
|
width: $caret-dimension;
|
||||||
transform: rotate(-45deg) translateY(-50%) translateX(0);
|
transform:
|
||||||
|
rotate(-45deg)
|
||||||
|
translateY(-50%)
|
||||||
|
translateX($caret-dimension * 0.2);
|
||||||
transition: transform .25s;
|
transition: transform .25s;
|
||||||
transform-origin: center;
|
transform-origin: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
&.expanded:after {
|
&.expanded:after {
|
||||||
transform: rotate(45deg) translateY(-50%) translateX(-5px);
|
transform:
|
||||||
|
rotate(45deg)
|
||||||
|
translateY(-50%)
|
||||||
|
translateX($caret-dimension * -0.5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,18 +76,18 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.node {
|
.node {
|
||||||
margin-bottom: 3px;
|
margin-bottom: $spacing-absolute-xx-small;
|
||||||
margin-top: 3px;
|
margin-top: $spacing-absolute-xx-small;
|
||||||
padding-bottom: 3px;
|
padding-bottom: $spacing-absolute-xx-small;
|
||||||
padding-top: 3px;
|
padding-top: $spacing-absolute-xx-small;
|
||||||
padding-right: 6px;
|
padding-right: $spacing-absolute-small;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
display: flex; // We could provide `block`, but `flex` is more versatile.
|
display: flex; // We could provide `block`, but `flex` is more versatile.
|
||||||
color: $color-node-fg;
|
color: $color-node-fg;
|
||||||
padding-left: 9px;
|
padding-left: $spacing-relative-small;
|
||||||
padding-right: 6px;
|
padding-right: $spacing-absolute-x-small;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ export default defineComponent({
|
|||||||
|
|
||||||
.flat-button {
|
.flat-button {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: 0.5em;
|
gap: $spacing-relative-small;
|
||||||
&.disabled {
|
&.disabled {
|
||||||
@include flat-button($disabled: true);
|
@include flat-button($disabled: true);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@
|
|||||||
@click="onBackgroundOverlayClick"
|
@click="onBackgroundOverlayClick"
|
||||||
/>
|
/>
|
||||||
<ModalContent
|
<ModalContent
|
||||||
class="modal-content"
|
|
||||||
:show="isOpen"
|
:show="isOpen"
|
||||||
@transitioned-out="onContentTransitionedOut"
|
@transitioned-out="onContentTransitionedOut"
|
||||||
>
|
>
|
||||||
@@ -138,6 +137,8 @@ export default defineComponent({
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.modal-container {
|
.modal-container {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -55,11 +55,11 @@ export default defineComponent({
|
|||||||
$modal-content-transition-duration: 400ms;
|
$modal-content-transition-duration: 400ms;
|
||||||
$modal-content-color-shadow: $color-on-surface;
|
$modal-content-color-shadow: $color-on-surface;
|
||||||
$modal-content-color-background: $color-surface;
|
$modal-content-color-background: $color-surface;
|
||||||
$modal-content-offset-upward: 20px;
|
$modal-content-offset-upward: $spacing-absolute-x-large;
|
||||||
|
|
||||||
@mixin scrollable() {
|
@mixin scrollable() {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
max-height: 90vh;
|
max-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-wrapper {
|
.modal-content-wrapper {
|
||||||
@@ -74,6 +74,17 @@ $modal-content-offset-upward: 20px;
|
|||||||
right: 0;
|
right: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
|
|
||||||
|
// Margin around modal content ensures visual comfort and consistency across devices.
|
||||||
|
// It provides:
|
||||||
|
// - A visually comfortable space preventing a claustrophobic feeling, especially on smaller screens.
|
||||||
|
// - Consistent appearance on various screen sizes by using absolute spacing.
|
||||||
|
// - Focus on the modal by dimming the background and emphasizing the task.
|
||||||
|
// - Sufficient space on small devices for users to tap outside and close the modal.
|
||||||
|
margin: $spacing-absolute-xx-large;
|
||||||
|
@media screen and (max-width: $media-screen-small-width) {
|
||||||
|
margin: $spacing-absolute-x-large; // Google and Apple recommend at least 44x44px for touch targets
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-content-content {
|
.modal-content-content {
|
||||||
|
|||||||
@@ -63,19 +63,25 @@ export default defineComponent({
|
|||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.dialog {
|
.dialog {
|
||||||
margin-bottom: 10px;
|
margin-bottom: $spacing-absolute-medium;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: row;
|
flex-direction: row;
|
||||||
|
|
||||||
&__content {
|
&__content {
|
||||||
margin: 5%;
|
margin: $spacing-absolute-xx-large;
|
||||||
|
@media screen and (max-width: $media-screen-big-width) {
|
||||||
|
margin: $spacing-absolute-x-large;
|
||||||
|
}
|
||||||
|
@media screen and (max-width: $media-screen-medium-width) {
|
||||||
|
margin: $spacing-absolute-large;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.dialog__close-button {
|
.dialog__close-button {
|
||||||
color: $color-primary-dark;
|
color: $color-primary-dark;
|
||||||
width: auto;
|
width: auto;
|
||||||
font-size: $font-size-absolute-large;
|
font-size: $font-size-absolute-large;
|
||||||
margin-right: 0.25em;
|
margin-right: $spacing-absolute-small;
|
||||||
align-self: flex-start;
|
align-self: flex-start;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -219,7 +219,7 @@ $color-tooltip-background: $color-primary-darkest;
|
|||||||
background: $color-tooltip-background;
|
background: $color-tooltip-background;
|
||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 12px 10px;
|
padding: $spacing-absolute-large $spacing-absolute-medium;
|
||||||
|
|
||||||
// Explicitly set font styling for tooltips to prevent inconsistent appearances due to style inheritance from trigger elements.
|
// Explicitly set font styling for tooltips to prevent inconsistent appearances due to style inheritance from trigger elements.
|
||||||
@include base-font-style;
|
@include base-font-style;
|
||||||
@@ -230,8 +230,8 @@ $color-tooltip-background: $color-primary-darkest;
|
|||||||
and balanced layout.
|
and balanced layout.
|
||||||
Avoiding setting vertical margin as it disrupts the arrow rendering.
|
Avoiding setting vertical margin as it disrupts the arrow rendering.
|
||||||
*/
|
*/
|
||||||
margin-left: 2px;
|
margin-left: $spacing-absolute-xx-small;
|
||||||
margin-right: 2px;
|
margin-right: $spacing-absolute-xx-small;
|
||||||
|
|
||||||
// Setting max-width increases readability and consistency reducing overlap and clutter.
|
// Setting max-width increases readability and consistency reducing overlap and clutter.
|
||||||
@include set-property-ch-value-with-fallback(
|
@include set-property-ch-value-with-fallback(
|
||||||
|
|||||||
@@ -67,10 +67,10 @@ export default defineComponent({
|
|||||||
}
|
}
|
||||||
.description {
|
.description {
|
||||||
&__icon {
|
&__icon {
|
||||||
margin-right: 0.5em;
|
margin-right: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
&__text {
|
&__text {
|
||||||
margin-right: 0.3em;
|
margin-right: $spacing-relative-x-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -79,7 +79,7 @@ export default defineComponent({
|
|||||||
&:not(:first-child)::before {
|
&:not(:first-child)::before {
|
||||||
content: "|";
|
content: "|";
|
||||||
font-size: $font-size-absolute-x-small;
|
font-size: $font-size-absolute-x-small;
|
||||||
padding: 0 5px;
|
padding: 0 $spacing-relative-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ export default defineComponent({
|
|||||||
@use "@/presentation/assets/styles/main" as *;
|
@use "@/presentation/assets/styles/main" as *;
|
||||||
|
|
||||||
.icon {
|
.icon {
|
||||||
margin-right: 0.5em;
|
margin-right: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer {
|
.footer {
|
||||||
@@ -119,18 +119,19 @@ export default defineComponent({
|
|||||||
@media screen and (max-width: $media-screen-big-width) {
|
@media screen and (max-width: $media-screen-big-width) {
|
||||||
justify-content: space-around;
|
justify-content: space-around;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
column-gap: $spacing-relative-medium;
|
||||||
&:not(:first-child) {
|
&:not(:first-child) {
|
||||||
margin-top: 0.7em;
|
margin-top: $spacing-relative-small;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
&__item:not(:first-child) {
|
&__item:not(:first-child) {
|
||||||
&::before {
|
&::before {
|
||||||
content: "|";
|
content: "|";
|
||||||
padding: 0 5px;
|
padding: 0 $spacing-relative-small;
|
||||||
}
|
}
|
||||||
@media screen and (max-width: $media-screen-big-width) {
|
@media screen and (max-width: $media-screen-big-width) {
|
||||||
margin-top: 3px;
|
margin-top: $spacing-absolute-xx-small;
|
||||||
&::before {
|
&::before {
|
||||||
content: "";
|
content: "";
|
||||||
padding: 0;
|
padding: 0;
|
||||||
|
|||||||
@@ -106,10 +106,9 @@ export default defineComponent({
|
|||||||
min-width: 60px;
|
min-width: 60px;
|
||||||
border: 1.5px solid $color-primary;
|
border: 1.5px solid $color-primary;
|
||||||
border-right: none;
|
border-right: none;
|
||||||
height: 36px;
|
|
||||||
border-radius: 3px 0 0 3px;
|
border-radius: 3px 0 0 3px;
|
||||||
padding-left:10px;
|
padding-left: $spacing-absolute-medium;
|
||||||
padding-right:10px;
|
padding-right: $spacing-absolute-medium;
|
||||||
outline: none;
|
outline: none;
|
||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
font-size: $font-size-absolute-normal;
|
font-size: $font-size-absolute-normal;
|
||||||
@@ -127,6 +126,6 @@ export default defineComponent({
|
|||||||
color: $color-on-primary;
|
color: $color-on-primary;
|
||||||
border-radius: 0 5px 5px 0;
|
border-radius: 0 5px 5px 0;
|
||||||
font-size: $font-size-absolute-large;
|
font-size: $font-size-absolute-large;
|
||||||
padding:5px;
|
padding: $spacing-absolute-x-small;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
69
tests/checks/external-urls/DocumentationUrlExtractor.ts
Normal file
69
tests/checks/external-urls/DocumentationUrlExtractor.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { IApplication } from '@/domain/IApplication';
|
||||||
|
import type { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
||||||
|
|
||||||
|
interface UrlExtractionContext {
|
||||||
|
readonly logger: TestExecutionDetailsLogger;
|
||||||
|
readonly application: IApplication;
|
||||||
|
readonly urlExclusionPatterns: readonly RegExp[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractDocumentationUrls(
|
||||||
|
context: UrlExtractionContext,
|
||||||
|
): string[] {
|
||||||
|
const urlsInApplication = extractUrlsFromApplication(context.application);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Extracted URLs from application',
|
||||||
|
urlsInApplication.length.toString(),
|
||||||
|
);
|
||||||
|
const uniqueUrls = filterDuplicateUrls(urlsInApplication);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Unique URLs after deduplication',
|
||||||
|
`${uniqueUrls.length} (duplicates removed)`,
|
||||||
|
);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'Exclusion patterns for URLs',
|
||||||
|
context.urlExclusionPatterns.length === 0
|
||||||
|
? 'None (all URLs included)'
|
||||||
|
: context.urlExclusionPatterns.map((pattern, index) => `${index + 1}) ${pattern.toString()}`).join('\n'),
|
||||||
|
);
|
||||||
|
const includedUrls = filterUrlsExcludingPatterns(uniqueUrls, context.urlExclusionPatterns);
|
||||||
|
context.logger.logLabeledInformation(
|
||||||
|
'URLs extracted for testing',
|
||||||
|
`${includedUrls.length} (after applying exclusion patterns; ${uniqueUrls.length - includedUrls.length} URLs ignored)`,
|
||||||
|
);
|
||||||
|
return includedUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsFromApplication(application: IApplication): string[] {
|
||||||
|
return [ // Get all executables
|
||||||
|
...application.collections.flatMap((c) => c.getAllCategories()),
|
||||||
|
...application.collections.flatMap((c) => c.getAllScripts()),
|
||||||
|
]
|
||||||
|
// Get all docs
|
||||||
|
.flatMap((documentable) => documentable.docs)
|
||||||
|
// Parse all URLs
|
||||||
|
.flatMap((docString) => extractUrlsExcludingCodeBlocks(docString));
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterDuplicateUrls(urls: readonly string[]): string[] {
|
||||||
|
return urls.filter((url, index, array) => array.indexOf(url) === index);
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterUrlsExcludingPatterns(
|
||||||
|
urls: readonly string[],
|
||||||
|
patterns: readonly RegExp[],
|
||||||
|
): string[] {
|
||||||
|
return urls.filter((url) => !patterns.some((pattern) => pattern.test(url)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractUrlsExcludingCodeBlocks(textWithInlineCode: string): string[] {
|
||||||
|
/*
|
||||||
|
Matches URLs:
|
||||||
|
- Excludes inline code blocks as they may contain URLs not intended for user interaction
|
||||||
|
and not guaranteed to support expected HTTP methods, leading to false-negatives.
|
||||||
|
- Supports URLs containing parentheses, avoiding matches within code that might not represent
|
||||||
|
actual links.
|
||||||
|
*/
|
||||||
|
const nonCodeBlockUrlRegex = /(?<!`)(https?:\/\/[^\s`"<>()]+(?:\([^\s`"<>()]*\))?[^\s`"<>()]*)/g;
|
||||||
|
return textWithInlineCode.match(nonCodeBlockUrlRegex) || [];
|
||||||
|
}
|
||||||
26
tests/checks/external-urls/TestExecutionDetailsLogger.ts
Normal file
26
tests/checks/external-urls/TestExecutionDetailsLogger.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { indentText } from '@tests/shared/Text';
|
||||||
|
|
||||||
|
export class TestExecutionDetailsLogger {
|
||||||
|
public logTestSectionStartDelimiter(): void {
|
||||||
|
this.logSectionDelimiterLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public logTestSectionEndDelimiter(): void {
|
||||||
|
this.logSectionDelimiterLine();
|
||||||
|
}
|
||||||
|
|
||||||
|
public logLabeledInformation(
|
||||||
|
label: string,
|
||||||
|
detailedInformation: string,
|
||||||
|
): void {
|
||||||
|
console.log([
|
||||||
|
`${label}:`,
|
||||||
|
indentText(detailedInformation),
|
||||||
|
].join('\n'));
|
||||||
|
}
|
||||||
|
|
||||||
|
private logSectionDelimiterLine(): void {
|
||||||
|
const horizontalLine = '─'.repeat(40);
|
||||||
|
console.log(horizontalLine);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,19 +1,26 @@
|
|||||||
import { test, expect } from 'vitest';
|
import { test, expect } from 'vitest';
|
||||||
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
import { parseApplication } from '@/application/Parser/ApplicationParser';
|
||||||
import type { IApplication } from '@/domain/IApplication';
|
|
||||||
import { indentText } from '@tests/shared/Text';
|
import { indentText } from '@tests/shared/Text';
|
||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
import { shuffle } from '@/application/Common/Shuffle';
|
||||||
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
import { type UrlStatus, formatUrlStatus } from './StatusChecker/UrlStatus';
|
||||||
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
import { getUrlStatusesInParallel, type BatchRequestOptions } from './StatusChecker/BatchStatusChecker';
|
||||||
|
import { TestExecutionDetailsLogger } from './TestExecutionDetailsLogger';
|
||||||
|
import { extractDocumentationUrls } from './DocumentationUrlExtractor';
|
||||||
|
|
||||||
// arrange
|
// arrange
|
||||||
|
const logger = new TestExecutionDetailsLogger();
|
||||||
|
logger.logTestSectionStartDelimiter();
|
||||||
const app = parseApplication();
|
const app = parseApplication();
|
||||||
const urls = collectUniqueUrls({
|
let urls = extractDocumentationUrls({
|
||||||
application: app,
|
logger,
|
||||||
excludePatterns: [
|
urlExclusionPatterns: [
|
||||||
/^https:\/\/archive\.ph/, // Drops HEAD/GET requests via fetch/curl, responding to Postman/Chromium.
|
/^https:\/\/archive\.ph/, // Drops HEAD/GET requests via fetch/curl, responding to Postman/Chromium.
|
||||||
],
|
],
|
||||||
|
application: app,
|
||||||
});
|
});
|
||||||
|
urls = filterUrlsToEnvironmentCheckLimit(urls);
|
||||||
|
logger.logLabeledInformation('URLs submitted for testing', urls.length.toString());
|
||||||
const requestOptions: BatchRequestOptions = {
|
const requestOptions: BatchRequestOptions = {
|
||||||
domainOptions: {
|
domainOptions: {
|
||||||
sameDomainParallelize: false, // be nice to our third-party servers
|
sameDomainParallelize: false, // be nice to our third-party servers
|
||||||
@@ -30,55 +37,64 @@ const requestOptions: BatchRequestOptions = {
|
|||||||
enableCookies: true,
|
enableCookies: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
logger.logLabeledInformation('HTTP request options', JSON.stringify(requestOptions, null, 2));
|
||||||
const testTimeoutInMs = urls.length * 60 /* seconds */ * 1000;
|
const testTimeoutInMs = urls.length * 60 /* seconds */ * 1000;
|
||||||
|
logger.logLabeledInformation('Scheduled test duration', convertMillisecondsToHumanReadableFormat(testTimeoutInMs));
|
||||||
|
logger.logTestSectionEndDelimiter();
|
||||||
test(`all URLs (${urls.length}) should be alive`, async () => {
|
test(`all URLs (${urls.length}) should be alive`, async () => {
|
||||||
// act
|
// act
|
||||||
const results = await getUrlStatusesInParallel(urls, requestOptions);
|
const results = await getUrlStatusesInParallel(urls, requestOptions);
|
||||||
// assert
|
// assert
|
||||||
const deadUrls = results.filter((r) => r.code === undefined || !isOkStatusCode(r.code));
|
const deadUrls = results.filter((r) => r.code === undefined || !isOkStatusCode(r.code));
|
||||||
expect(deadUrls).to.have.lengthOf(0, formatAssertionMessage([formatUrlStatusReport(deadUrls)]));
|
expect(deadUrls).to.have.lengthOf(
|
||||||
|
0,
|
||||||
|
formatAssertionMessage([createReportForDeadUrlStatuses(deadUrls)]),
|
||||||
|
);
|
||||||
}, testTimeoutInMs);
|
}, testTimeoutInMs);
|
||||||
|
|
||||||
function isOkStatusCode(statusCode: number): boolean {
|
function isOkStatusCode(statusCode: number): boolean {
|
||||||
return statusCode >= 200 && statusCode < 300;
|
return statusCode >= 200 && statusCode < 300;
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectUniqueUrls(
|
function createReportForDeadUrlStatuses(deadUrlStatuses: readonly UrlStatus[]): string {
|
||||||
options: {
|
|
||||||
readonly application: IApplication,
|
|
||||||
readonly excludePatterns?: readonly RegExp[],
|
|
||||||
},
|
|
||||||
): string[] {
|
|
||||||
return [ // Get all nodes
|
|
||||||
...options.application.collections.flatMap((c) => c.getAllCategories()),
|
|
||||||
...options.application.collections.flatMap((c) => c.getAllScripts()),
|
|
||||||
]
|
|
||||||
// Get all docs
|
|
||||||
.flatMap((documentable) => documentable.docs)
|
|
||||||
// Parse all URLs
|
|
||||||
.flatMap((docString) => extractUrls(docString))
|
|
||||||
// Remove duplicates
|
|
||||||
.filter((url, index, array) => array.indexOf(url) === index)
|
|
||||||
// Exclude certain URLs based on patterns
|
|
||||||
.filter((url) => !shouldExcludeUrl(url, options.excludePatterns ?? []));
|
|
||||||
}
|
|
||||||
|
|
||||||
function shouldExcludeUrl(url: string, patterns: readonly RegExp[]): boolean {
|
|
||||||
return patterns.some((pattern) => pattern.test(url));
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatUrlStatusReport(deadUrlStatuses: readonly UrlStatus[]): string {
|
|
||||||
return `\n${deadUrlStatuses.map((status) => indentText(formatUrlStatus(status))).join('\n---\n')}\n`;
|
return `\n${deadUrlStatuses.map((status) => indentText(formatUrlStatus(status))).join('\n---\n')}\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractUrls(textWithInlineCode: string): string[] {
|
function filterUrlsToEnvironmentCheckLimit(originalUrls: string[]): string[] {
|
||||||
/*
|
const { RANDOMIZED_URL_CHECK_LIMIT } = process.env;
|
||||||
Matches URLs:
|
logger.logLabeledInformation('URL check limit', RANDOMIZED_URL_CHECK_LIMIT || 'Unlimited');
|
||||||
- Excludes inline code blocks as they may contain URLs not intended for user interaction
|
if (RANDOMIZED_URL_CHECK_LIMIT !== undefined && RANDOMIZED_URL_CHECK_LIMIT !== '') {
|
||||||
and not guaranteed to support expected HTTP methods, leading to false-negatives.
|
const maxUrlsInTest = parseInt(RANDOMIZED_URL_CHECK_LIMIT, 10);
|
||||||
- Supports URLs containing parentheses, avoiding matches within code that might not represent
|
if (Number.isNaN(maxUrlsInTest)) {
|
||||||
actual links.
|
throw new Error(`Invalid URL limit: ${RANDOMIZED_URL_CHECK_LIMIT}`);
|
||||||
*/
|
}
|
||||||
const nonCodeBlockUrlRegex = /(?<!`)(https?:\/\/[^\s`"<>()]+(?:\([^\s`"<>()]*\))?[^\s`"<>()]*)/g;
|
if (maxUrlsInTest < originalUrls.length) {
|
||||||
return textWithInlineCode.match(nonCodeBlockUrlRegex) || [];
|
return shuffle(originalUrls).slice(0, maxUrlsInTest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return originalUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertMillisecondsToHumanReadableFormat(milliseconds: number): string {
|
||||||
|
const timeParts: string[] = [];
|
||||||
|
const addTimePart = (amount: number, label: string) => {
|
||||||
|
if (amount === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
timeParts.push(`${amount} ${label}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const hours = milliseconds / (1000 * 60 * 60);
|
||||||
|
const absoluteHours = Math.floor(hours);
|
||||||
|
addTimePart(absoluteHours, 'hours');
|
||||||
|
|
||||||
|
const minutes = (hours - absoluteHours) * 60;
|
||||||
|
const absoluteMinutes = Math.floor(minutes);
|
||||||
|
addTimePart(absoluteMinutes, 'minutes');
|
||||||
|
|
||||||
|
const seconds = (minutes - absoluteMinutes) * 60;
|
||||||
|
const absoluteSeconds = Math.floor(seconds);
|
||||||
|
addTimePart(absoluteSeconds, 'seconds');
|
||||||
|
|
||||||
|
return timeParts.join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
|
||||||
|
import { getCurrentHighlightRange } from './support/interactions/code-area';
|
||||||
|
import { selectAllScripts } from './support/interactions/script-selection';
|
||||||
import { openCard } from './support/interactions/card';
|
import { openCard } from './support/interactions/card';
|
||||||
|
|
||||||
describe('script selection highlighting', () => {
|
describe('script selection highlighting', () => {
|
||||||
@@ -6,11 +8,12 @@ describe('script selection highlighting', () => {
|
|||||||
it('highlights more when multiple scripts are selected', () => {
|
it('highlights more when multiple scripts are selected', () => {
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
selectLastScript();
|
selectLastScript();
|
||||||
getCurrentHighlightRange((lastScriptHighlightRange) => {
|
getNonZeroCurrentHighlightRangeValue().then((lastScriptHighlightRange) => {
|
||||||
cy.log(`Highlight height for last script: ${lastScriptHighlightRange}`);
|
cy.log(`Highlight height for last script: ${lastScriptHighlightRange}`);
|
||||||
|
expect(lastScriptHighlightRange).to.be.greaterThan(0);
|
||||||
cy.visit('/');
|
cy.visit('/');
|
||||||
selectAllScripts();
|
selectAllScripts();
|
||||||
getCurrentHighlightRange((allScriptsHighlightRange) => {
|
getNonZeroCurrentHighlightRangeValue().then((allScriptsHighlightRange) => {
|
||||||
cy.log(`Highlight height for all scripts: ${allScriptsHighlightRange}`);
|
cy.log(`Highlight height for all scripts: ${allScriptsHighlightRange}`);
|
||||||
expect(allScriptsHighlightRange).to.be.greaterThan(lastScriptHighlightRange);
|
expect(allScriptsHighlightRange).to.be.greaterThan(lastScriptHighlightRange);
|
||||||
});
|
});
|
||||||
@@ -18,6 +21,15 @@ describe('script selection highlighting', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function getNonZeroCurrentHighlightRangeValue() {
|
||||||
|
return getCurrentHighlightRange()
|
||||||
|
.should('not.equal', '0')
|
||||||
|
.then((rangeValue) => {
|
||||||
|
expectExists(rangeValue);
|
||||||
|
return parseInt(rangeValue, 10);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function selectLastScript() {
|
function selectLastScript() {
|
||||||
openCard({
|
openCard({
|
||||||
cardIndex: -1, // last card
|
cardIndex: -1, // last card
|
||||||
@@ -26,22 +38,3 @@ function selectLastScript() {
|
|||||||
.last()
|
.last()
|
||||||
.click({ force: true });
|
.click({ force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectAllScripts() {
|
|
||||||
cy.contains('span', 'All')
|
|
||||||
.click();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCurrentHighlightRange(
|
|
||||||
callback: (highlightedRange: number) => void,
|
|
||||||
) {
|
|
||||||
cy
|
|
||||||
.get('#codeEditor')
|
|
||||||
.invoke('attr', 'data-test-highlighted-range')
|
|
||||||
.should('not.be.empty')
|
|
||||||
.and('not.equal', '0')
|
|
||||||
.then((range) => {
|
|
||||||
expectExists(range);
|
|
||||||
callback(parseInt(range, 10));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|||||||
65
tests/e2e/no-unintended-layout-shifts.cy.ts
Normal file
65
tests/e2e/no-unintended-layout-shifts.cy.ts
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
|
||||||
|
import { openCard } from './support/interactions/card';
|
||||||
|
import { selectAllScripts, unselectAllScripts } from './support/interactions/script-selection';
|
||||||
|
import { assertLayoutStability } from './support/assert/layout-stability';
|
||||||
|
|
||||||
|
describe('Layout stability', () => {
|
||||||
|
ViewportTestScenarios.forEach(({ // some shifts are observed only on extra small or large screens
|
||||||
|
name, width, height,
|
||||||
|
}) => {
|
||||||
|
// Regression test for a bug where opening a modal caused layout shift
|
||||||
|
describe('Modal interaction', () => {
|
||||||
|
it(name, () => {
|
||||||
|
// arrange
|
||||||
|
cy.viewport(width, height);
|
||||||
|
cy.visit('/');
|
||||||
|
// act & assert
|
||||||
|
assertLayoutStability('body', () => {
|
||||||
|
cy
|
||||||
|
.contains('button', 'Privacy')
|
||||||
|
.click();
|
||||||
|
cy
|
||||||
|
.get('.modal-content-content')
|
||||||
|
.should('be.visible');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression test for a bug where selecting a script with an open card caused layout shift
|
||||||
|
describe('Initial script selection', () => {
|
||||||
|
it(name, () => {
|
||||||
|
// arrange
|
||||||
|
cy.viewport(width, height);
|
||||||
|
cy.visit('/');
|
||||||
|
cy.contains('span', 'Windows')
|
||||||
|
.click();
|
||||||
|
// act & assert
|
||||||
|
assertLayoutStability('#app', () => {
|
||||||
|
openCard({
|
||||||
|
cardIndex: 0,
|
||||||
|
});
|
||||||
|
selectAllScripts();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Regression test for a bug where unselecting selected with an open card caused layout shift
|
||||||
|
describe('Deselection script selection', () => {
|
||||||
|
it(name, () => {
|
||||||
|
// arrange
|
||||||
|
cy.viewport(width, height);
|
||||||
|
cy.visit('/');
|
||||||
|
cy.contains('span', 'Windows')
|
||||||
|
.click();
|
||||||
|
openCard({
|
||||||
|
cardIndex: 0,
|
||||||
|
});
|
||||||
|
selectAllScripts();
|
||||||
|
// act & assert
|
||||||
|
assertLayoutStability('#app', () => {
|
||||||
|
unselectAllScripts();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,38 +1,37 @@
|
|||||||
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
|
|
||||||
|
|
||||||
describe('Modal interaction and layout stability', () => {
|
export function assertLayoutStability(selector: string, action: ()=> void): void {
|
||||||
ViewportTestScenarios.forEach(({ // some shifts are observed only on extra small or large screens
|
// arrange
|
||||||
name, width, height,
|
let initialMetrics: ViewportMetrics | undefined;
|
||||||
}) => {
|
captureViewportMetrics(selector, (metrics) => {
|
||||||
it(name, () => {
|
initialMetrics = metrics;
|
||||||
cy.viewport(width, height);
|
|
||||||
cy.visit('/');
|
|
||||||
|
|
||||||
let metricsBeforeModal: ViewportMetrics | undefined;
|
|
||||||
|
|
||||||
captureViewportMetrics((metrics) => {
|
|
||||||
metricsBeforeModal = metrics;
|
|
||||||
});
|
|
||||||
|
|
||||||
cy
|
|
||||||
.contains('button', 'Privacy')
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy
|
|
||||||
.get('.modal-content')
|
|
||||||
.should('be.visible');
|
|
||||||
|
|
||||||
captureViewportMetrics((metrics) => {
|
|
||||||
const metricsAfterModal = metrics;
|
|
||||||
expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, formatAssertionMessage([
|
|
||||||
`Expected (initial metrics before modal): ${JSON.stringify(metricsBeforeModal)}`,
|
|
||||||
`Actual (metrics after modal is opened): ${JSON.stringify(metricsAfterModal)}`,
|
|
||||||
]));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
// act
|
||||||
|
action();
|
||||||
|
// assert
|
||||||
|
captureViewportMetrics(selector, (metrics) => {
|
||||||
|
const finalMetrics = metrics;
|
||||||
|
expect(initialMetrics).to.deep.equal(finalMetrics, formatAssertionMessage([
|
||||||
|
`Expected (initial metrics before action): ${JSON.stringify(initialMetrics)}`,
|
||||||
|
`Actual (final metrics after action): ${JSON.stringify(finalMetrics)}`,
|
||||||
|
]));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function captureViewportMetrics(
|
||||||
|
selector: string,
|
||||||
|
callback: (metrics: ViewportMetrics) => void,
|
||||||
|
): void {
|
||||||
|
cy.window().then((win) => {
|
||||||
|
cy.get(selector)
|
||||||
|
.then((elements) => {
|
||||||
|
const element = elements[0];
|
||||||
|
const position = getElementViewportMetrics(element, win);
|
||||||
|
cy.log(`Captured metrics (\`${selector}\`): ${JSON.stringify(position)}`);
|
||||||
|
callback(position);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
interface ViewportMetrics {
|
interface ViewportMetrics {
|
||||||
readonly x: number;
|
readonly x: number;
|
||||||
@@ -44,17 +43,6 @@ interface ViewportMetrics {
|
|||||||
*/
|
*/
|
||||||
}
|
}
|
||||||
|
|
||||||
function captureViewportMetrics(callback: (metrics: ViewportMetrics) => void): void {
|
|
||||||
cy.window().then((win) => {
|
|
||||||
cy.get('body')
|
|
||||||
.then((body) => {
|
|
||||||
const position = getElementViewportMetrics(body[0], win);
|
|
||||||
cy.log(`Captured metrics: ${JSON.stringify(position)}`);
|
|
||||||
callback(position);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getElementViewportMetrics(element: HTMLElement, win: Window): ViewportMetrics {
|
function getElementViewportMetrics(element: HTMLElement, win: Window): ViewportMetrics {
|
||||||
const elementXRelativeToViewport = getElementXRelativeToViewport(element, win);
|
const elementXRelativeToViewport = getElementXRelativeToViewport(element, win);
|
||||||
const elementYRelativeToViewport = getElementYRelativeToViewport(element, win);
|
const elementYRelativeToViewport = getElementYRelativeToViewport(element, win);
|
||||||
@@ -4,3 +4,27 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import './commands';
|
import './commands';
|
||||||
|
|
||||||
|
// Mitigates a Chrome-specific 'ResizeObserver' error in Cypress tests.
|
||||||
|
// The 'ResizeObserver loop limit exceeded' error is non-critical but can cause
|
||||||
|
// false negatives in CI/CD environments, particularly with GitHub runners.
|
||||||
|
// The issue is absent in actual browser usage and local Cypress testing.
|
||||||
|
// Community discussions and contributions have led to this handler being
|
||||||
|
// recommended as a user-level fix rather than a Cypress core inclusion.
|
||||||
|
// Relevant discussions and attempted core fixes:
|
||||||
|
// - Original fix suggestion: https://github.com/cypress-io/cypress/issues/8418#issuecomment-992564877
|
||||||
|
// - Proposed Cypress core PRs:
|
||||||
|
// https://github.com/cypress-io/cypress/pull/20257
|
||||||
|
// https://github.com/cypress-io/cypress/pull/20284
|
||||||
|
// - Current issue tracking: https://github.com/cypress-io/cypress/issues/20341
|
||||||
|
// - Related Quasar framework issue: https://github.com/quasarframework/quasar/issues/2233
|
||||||
|
// - Chromium bug tracker discussion: https://issues.chromium.org/issues/41369140
|
||||||
|
// - Stack Overflow on safely ignoring the error:
|
||||||
|
// https://stackoverflow.com/questions/49384120/resizeobserver-loop-limit-exceeded/50387233#50387233
|
||||||
|
// https://stackoverflow.com/questions/63653605/resizeobserver-loop-limit-exceeded-api-is-never-used/63653711#63653711
|
||||||
|
// - Spec issue related to 'ResizeObserver': https://github.com/WICG/resize-observer/issues/38
|
||||||
|
Cypress.on(
|
||||||
|
'uncaught:exception',
|
||||||
|
// Ignore this specific error to prevent false test failures
|
||||||
|
(err) => !err.message.includes('ResizeObserver loop limit exceeded'),
|
||||||
|
);
|
||||||
|
|||||||
7
tests/e2e/support/interactions/code-area.ts
Normal file
7
tests/e2e/support/interactions/code-area.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export function getCurrentHighlightRange() {
|
||||||
|
return cy
|
||||||
|
.get('#codeEditor')
|
||||||
|
.invoke('attr', 'data-test-highlighted-range')
|
||||||
|
.should('be.a', 'string')
|
||||||
|
.should('not.equal', '');
|
||||||
|
}
|
||||||
15
tests/e2e/support/interactions/script-selection.ts
Normal file
15
tests/e2e/support/interactions/script-selection.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { getCurrentHighlightRange } from './code-area';
|
||||||
|
|
||||||
|
export function selectAllScripts() {
|
||||||
|
cy.contains('span', 'All')
|
||||||
|
.click();
|
||||||
|
getCurrentHighlightRange()
|
||||||
|
.should('not.equal', '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unselectAllScripts() {
|
||||||
|
cy.contains('span', 'None')
|
||||||
|
.click();
|
||||||
|
getCurrentHighlightRange()
|
||||||
|
.should('equal', '0');
|
||||||
|
}
|
||||||
@@ -12,7 +12,9 @@ describe('TreeView', () => {
|
|||||||
it('renders all provided root nodes correctly', async () => {
|
it('renders all provided root nodes correctly', async () => {
|
||||||
// arrange
|
// arrange
|
||||||
const nodes = createSampleNodes();
|
const nodes = createSampleNodes();
|
||||||
const wrapper = createTreeViewWrapper(nodes);
|
const { wrapper } = mountWrapperComponent({
|
||||||
|
initialNodeData: nodes,
|
||||||
|
});
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await waitForStableDom(wrapper.element);
|
await waitForStableDom(wrapper.element);
|
||||||
@@ -33,10 +35,12 @@ describe('TreeView', () => {
|
|||||||
const secondNodeLabel = 'Node 2';
|
const secondNodeLabel = 'Node 2';
|
||||||
const initialNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node1', data: { label: firstNodeLabel } }];
|
const initialNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node1', data: { label: firstNodeLabel } }];
|
||||||
const updatedNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node2', data: { label: secondNodeLabel } }];
|
const updatedNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node2', data: { label: secondNodeLabel } }];
|
||||||
const wrapper = createTreeViewWrapper(initialNodes);
|
const { wrapper, nodes } = mountWrapperComponent({
|
||||||
|
initialNodeData: initialNodes,
|
||||||
|
});
|
||||||
|
|
||||||
// act
|
// act
|
||||||
await wrapper.setProps({ nodes: updatedNodes });
|
nodes.value = updatedNodes;
|
||||||
await waitForStableDom(wrapper.element);
|
await waitForStableDom(wrapper.element);
|
||||||
|
|
||||||
// assert
|
// assert
|
||||||
@@ -45,15 +49,17 @@ describe('TreeView', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeDataWithMetadata[]) {
|
function mountWrapperComponent(options?: {
|
||||||
return mount(defineComponent({
|
readonly initialNodeData?: readonly TreeInputNodeDataWithMetadata[],
|
||||||
|
}) {
|
||||||
|
const nodes = shallowRef(options?.initialNodeData ?? createSampleNodes());
|
||||||
|
const wrapper = mount(defineComponent({
|
||||||
components: {
|
components: {
|
||||||
TreeView,
|
TreeView,
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
provideDependencies(new ApplicationContextStub());
|
provideDependencies(new ApplicationContextStub());
|
||||||
|
|
||||||
const nodes = shallowRef(initialNodeData);
|
|
||||||
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
|
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +78,10 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeDataWithMe
|
|||||||
</TreeView>
|
</TreeView>
|
||||||
`,
|
`,
|
||||||
}));
|
}));
|
||||||
|
return {
|
||||||
|
wrapper,
|
||||||
|
nodes,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TreeInputMetadata {
|
interface TreeInputMetadata {
|
||||||
|
|||||||
52
tests/unit/application/Common/Shuffle.spec.ts
Normal file
52
tests/unit/application/Common/Shuffle.spec.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { shuffle } from '@/application/Common/Shuffle';
|
||||||
|
|
||||||
|
describe('Shuffle', () => {
|
||||||
|
describe('shuffle', () => {
|
||||||
|
it('returns a new array', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).not.to.equal(inputArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an array of the same length', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result.length).toBe(inputArray.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('contains the same elements', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).to.have.members(inputArray);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify the input array', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray = ['a', 'b', 'c', 'd'];
|
||||||
|
const inputArrayCopy = [...inputArray];
|
||||||
|
// act
|
||||||
|
shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(inputArray).to.deep.equal(inputArrayCopy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles an empty array correctly', () => {
|
||||||
|
// arrange
|
||||||
|
const inputArray: string[] = [];
|
||||||
|
// act
|
||||||
|
const result = shuffle(inputArray);
|
||||||
|
// assert
|
||||||
|
expect(result).have.lengthOf(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { shallowMount } from '@vue/test-utils';
|
import { shallowMount } from '@vue/test-utils';
|
||||||
import RatingCircle from '@/presentation/components/Scripts/Menu/Recommendation/Rating/RatingCircle.vue';
|
import RatingCircle from '@/presentation/components/Scripts/Menu/Recommendation/Rating/RatingCircle.vue';
|
||||||
|
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
|
||||||
|
|
||||||
const DOM_SVG_SELECTOR = 'svg';
|
const DOM_SVG_SELECTOR = 'svg';
|
||||||
const DOM_CIRCLE_SELECTOR = `${DOM_SVG_SELECTOR} > circle`;
|
const DOM_CIRCLE_SELECTOR = `${DOM_SVG_SELECTOR} > circle`;
|
||||||
@@ -39,12 +40,6 @@ describe('RatingCircle.vue', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('SVG and circle styles', () => {
|
describe('SVG and circle styles', () => {
|
||||||
it('sets --circle-stroke-width style correctly', () => {
|
|
||||||
const wrapper = shallowMount(RatingCircle);
|
|
||||||
const svgElement = wrapper.find(DOM_SVG_SELECTOR).element;
|
|
||||||
expect(svgElement.style.getPropertyValue('--circle-stroke-width')).to.equal('2px');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders circle with correct fill attribute when filled prop is true', () => {
|
it('renders circle with correct fill attribute when filled prop is true', () => {
|
||||||
const wrapper = shallowMount(RatingCircle, {
|
const wrapper = shallowMount(RatingCircle, {
|
||||||
propsData: {
|
propsData: {
|
||||||
@@ -58,32 +53,49 @@ describe('RatingCircle.vue', () => {
|
|||||||
|
|
||||||
it('renders circle with the correct viewBox property', () => {
|
it('renders circle with the correct viewBox property', () => {
|
||||||
const wrapper = shallowMount(RatingCircle);
|
const wrapper = shallowMount(RatingCircle);
|
||||||
const circle = wrapper.find(DOM_SVG_SELECTOR);
|
const circleElement = wrapper.find(DOM_SVG_SELECTOR);
|
||||||
|
|
||||||
expect(circle.attributes('viewBox')).to.equal('-1 -1 22 22');
|
expect(circleElement.attributes('viewBox')).to.equal('-1 -1 22 22');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('circle attributes', () => {
|
describe('circle attributes', () => {
|
||||||
it('renders circle with the correct cx attribute', () => {
|
const testScenarios: ReadonlyArray<{
|
||||||
const wrapper = shallowMount(RatingCircle);
|
readonly attributeKey: string;
|
||||||
const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR);
|
readonly expectedValue: string;
|
||||||
|
}> = [
|
||||||
expect(circleElement.attributes('cx')).to.equal('10'); // Based on circleDiameterInPx = 20
|
{
|
||||||
});
|
attributeKey: 'stroke-width',
|
||||||
|
expectedValue: '2px', // Based on circleStrokeWidthInPx = 2
|
||||||
it('renders circle with the correct cy attribute', () => {
|
},
|
||||||
const wrapper = shallowMount(RatingCircle);
|
{
|
||||||
const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR);
|
attributeKey: 'cx',
|
||||||
|
expectedValue: '10', // Based on circleDiameterInPx = 20
|
||||||
expect(circleElement.attributes('cy')).to.equal('10'); // Based on circleDiameterInPx = 20
|
},
|
||||||
});
|
{
|
||||||
|
attributeKey: 'cy',
|
||||||
it('renders circle with the correct r attribute', () => {
|
expectedValue: '10', // Based on circleStrokeWidthInPx = 2
|
||||||
const wrapper = shallowMount(RatingCircle);
|
},
|
||||||
const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR);
|
{
|
||||||
|
attributeKey: 'r',
|
||||||
expect(circleElement.attributes('r')).to.equal('9'); // Based on circleRadiusWithoutStrokeInPx = circleDiameterInPx / 2 - circleStrokeWidthInPx / 2
|
expectedValue: '9', // Based on circleRadiusWithoutStrokeInPx = circleDiameterInPx / 2 - circleStrokeWidthInPx / 2
|
||||||
|
},
|
||||||
|
];
|
||||||
|
testScenarios.forEach(({
|
||||||
|
attributeKey, expectedValue,
|
||||||
|
}) => {
|
||||||
|
it(`renders circle with the correct ${attributeKey} attribute`, () => {
|
||||||
|
// act
|
||||||
|
const wrapper = shallowMount(RatingCircle);
|
||||||
|
const circleElement = wrapper.find(DOM_CIRCLE_SELECTOR);
|
||||||
|
const actualValue = circleElement.attributes(attributeKey);
|
||||||
|
// assert
|
||||||
|
expect(actualValue).to.equal(expectedValue, formatAssertionMessage([
|
||||||
|
`Expected value: ${expectedValue}`,
|
||||||
|
`Actual value: ${actualValue}`,
|
||||||
|
`Attribute: ${attributeKey}`,
|
||||||
|
]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user