Compare commits

...
31 Commits
Author SHA1 Message Date
zemion 4a664e90eb chore(toolbox-portal): bump toolbox release version for translator inclusion
Verify / verify (push) Canceled after 0s
2026-09-02 22:10:31 +02:00
zemion 13af32f535 feat(toolbox-portal): include translator-tools in release lock
Verify / verify (push) Canceled after 0s
2026-09-02 21:52:15 +02:00
zemion 965a776aad Release Toolbox v0.20.0
Verify / verify (push) Canceled after 0s
2026-09-02 13:44:29 +02:00
zemion 45dcaff659 Release Toolbox v0.19.0 2026-09-01 16:29:17 +02:00
zemion ea5f76554a Publish Toolbox 0.18.1 with local-only Random Tools 2026-09-01 08:32:15 +02:00
zemion 08a01d843a feat: publish Toolbox 0.18.0 with sixteen new tools 2026-09-01 03:02:08 +02:00
zemion d2d0c9098d feat: publish Toolbox 0.17.0 with Colour and Office Tools 2026-09-01 00:54:22 +02:00
zemion 01f6c59adf feat: publish Toolbox 0.16.0 with Sudoku Tools 0.2.1 2026-08-31 16:28:36 +02:00
zemion bc07e13e3b feat: publish Toolbox 0.15.0 with Sudoku Tools 0.2.0 2026-08-31 08:44:44 +02:00
zemion 7bd34371a9 feat: publish Toolbox 0.14.0 with Sudoku Tools 2026-08-30 14:27:22 +02:00
zemion 8d68557d5e feat: publish Toolbox 0.13.0 with Auth Tools 0.3.0 2026-08-20 00:25:18 +02:00
zemion 7ba733d9f2 feat: publish Toolbox 0.12.0 with Auth Tools 0.2.0 2026-08-19 14:18:18 +02:00
zemion 0baeda8be8 feat: add OTP and Passkey Tools to Toolbox 0.11.0 2026-08-19 12:20:27 +02:00
zemion 5ed7134d42 feat: add SVG Tools to Toolbox 0.10.0 2026-08-02 16:36:22 +02:00
zemion 55b2b12434 feat: publish Toolbox 0.9.3 with AV Tools 0.3.0 2026-07-28 00:33:36 +02:00
zemion f0b2ce34e4 feat: publish Toolbox 0.9.2 from LocalToolBox 2026-07-27 15:58:41 +02:00
zemion 43670a5824 fix: serve engine modules with JavaScript MIME 2026-07-27 13:08:06 +02:00
zemion a50944e7cb feat: publish Toolbox 0.9.0 2026-07-27 11:48:21 +02:00
zemion 9347554324 feat: publish Toolbox 0.8.0 2026-07-27 02:09:09 +02:00
zemion 470ee49ca2 feat: publish Toolbox 0.7.0 2026-07-27 01:16:20 +02:00
zemion 5c052bc6d1 feat: publish Toolbox 0.6.0 with regex-tools 2026-07-24 18:14:47 +02:00
zemion a9c31c8986 fix: harden media and WebAssembly delivery 2026-07-24 15:09:33 +02:00
zemion 1f00e6def1 feat: publish Toolbox 0.5.0 with av-tools 2026-07-24 15:09:28 +02:00
zemion bda9da044d drag handle adjustment 2026-07-23 14:41:21 +02:00
zemion 2c13c56362 feat: add compact Toolbox tiles and details overlay 2026-07-23 13:02:29 +02:00
zemion 803397ff52 fix: reverse Toolbox header orientation 2026-07-23 01:57:52 +02:00
zemion 21283ce25f fix: preserve hidden tool visibility 2026-07-23 01:23:32 +02:00
zemion e44d35fdfb fix: stabilize Toolbox navigation layout 2026-07-23 01:21:16 +02:00
zemion 67ba5e3eca feat: redesign Toolbox navigation and personalization 2026-07-23 00:33:03 +02:00
zemion 587aadb79b release: publish toolbox aggregate 0.3.1 2026-07-22 20:55:34 +02:00
zemion de51ec0bd5 add traefik label priority 2026-07-22 20:39:07 +02:00
39 changed files with 4095 additions and 1487 deletions
+3 -2
View File
@@ -1,8 +1,9 @@
# Optional overrides for compose.yaml. Its defaults already match production. # Optional overrides for compose.yaml. Its defaults already match production.
TOOLBOX_HOST=toolbox.add-ideas.de TOOLBOX_HOST=toolbox.add-ideas.de
AUTH_TOOLS_HOST=auth.toolbox.add-ideas.de
TRAEFIK_NETWORK=internal TRAEFIK_NETWORK=internal
TRAEFIK_CERT_RESOLVER=netcup TRAEFIK_CERT_RESOLVER=netcup
# Keep these two values paired when deploying a newer published toolbox release. # Keep these two values paired when deploying a newer published toolbox release.
TOOLBOX_RELEASE_VERSION=0.3.0 TOOLBOX_RELEASE_VERSION=0.20.0
TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e TOOLBOX_RELEASE_SHA256=450618902a1ee87405da463881e6f51f685691c2081259aff9c25a4aa6fe22ed
+35
View File
@@ -0,0 +1,35 @@
name: Verify
on:
push:
branches: [main]
pull_request:
workflow_dispatch:
concurrency:
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
cancel-in-progress: true
permissions:
contents: read
jobs:
verify:
runs-on: ubuntu-latest
timeout-minutes: 60
env:
CI: 'true'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
- name: Select declared npm version
run: npm install --global npm@11.17.0
- name: Install dependencies
run: npm ci
- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=moderate
- name: Check, test, and build
run: npm run check
+1 -1
View File
@@ -1 +1 @@
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/ @add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
+50 -3
View File
@@ -2,9 +2,9 @@ ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded3
FROM ${NGINX_IMAGE} AS release FROM ${NGINX_IMAGE} AS release
ARG TOOLBOX_RELEASE_VERSION=0.3.0 ARG TOOLBOX_RELEASE_VERSION=0.20.0
ARG TOOLBOX_RELEASE_SHA256=718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e ARG TOOLBOX_RELEASE_SHA256=450618902a1ee87405da463881e6f51f685691c2081259aff9c25a4aa6fe22ed
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/lotobo/toolbox-portal/releases/download
RUN set -eu; \ RUN set -eu; \
archive="add-ideas-toolbox-${TOOLBOX_RELEASE_VERSION}.zip"; \ archive="add-ideas-toolbox-${TOOLBOX_RELEASE_VERSION}.zip"; \
@@ -21,6 +21,53 @@ RUN set -eu; \
test -f /tmp/toolbox/apps/pdf/toolbox-app.json; \ test -f /tmp/toolbox/apps/pdf/toolbox-app.json; \
test -f /tmp/toolbox/apps/xslt/toolbox-app.json; \ test -f /tmp/toolbox/apps/xslt/toolbox-app.json; \
test -f /tmp/toolbox/apps/onenote/toolbox-app.json; \ test -f /tmp/toolbox/apps/onenote/toolbox-app.json; \
test -f /tmp/toolbox/apps/av/toolbox-app.json; \
test -f /tmp/toolbox/apps/regex/toolbox-app.json; \
test -f /tmp/toolbox/apps/svg/toolbox-app.json; \
test -f /tmp/toolbox/apps/auth/toolbox-app.json; \
test -f /tmp/toolbox/apps/sudoku/toolbox-app.json; \
test -f /tmp/toolbox/apps/colour/toolbox-app.json; \
test -f /tmp/toolbox/apps/office/toolbox-app.json; \
test -f /tmp/toolbox/apps/image/toolbox-app.json; \
test -f /tmp/toolbox/apps/file/toolbox-app.json; \
test -f /tmp/toolbox/apps/epub/toolbox-app.json; \
test -f /tmp/toolbox/apps/archive/toolbox-app.json; \
test -f /tmp/toolbox/apps/privacy/toolbox-app.json; \
test -f /tmp/toolbox/apps/crypto/toolbox-app.json; \
test -f /tmp/toolbox/apps/barcode/toolbox-app.json; \
test -f /tmp/toolbox/apps/network/toolbox-app.json; \
test -f /tmp/toolbox/apps/geo/toolbox-app.json; \
test -f /tmp/toolbox/apps/helper/toolbox-app.json; \
test -f /tmp/toolbox/apps/rand/toolbox-app.json; \
test -f /tmp/toolbox/apps/data/toolbox-app.json; \
test -f /tmp/toolbox/apps/diff/toolbox-app.json; \
test -f /tmp/toolbox/apps/time/toolbox-app.json; \
test -f /tmp/toolbox/apps/text/toolbox-app.json; \
test -f /tmp/toolbox/apps/unicode/toolbox-app.json; \
test -f /tmp/toolbox/apps/flow/toolbox-app.json; \
test -f /tmp/toolbox/apps/subtitle/toolbox-app.json; \
test -f /tmp/toolbox/apps/midi/toolbox-app.json; \
test -f /tmp/toolbox/apps/3d/toolbox-app.json; \
test -f /tmp/toolbox/apps/query/toolbox-app.json; \
test -f /tmp/toolbox/apps/scan/toolbox-app.json; \
test -f /tmp/toolbox/apps/package/toolbox-app.json; \
test -f /tmp/toolbox/apps/label/toolbox-app.json; \
test -f /tmp/toolbox/apps/font/toolbox-app.json; \
test -f /tmp/toolbox/apps/fixture/toolbox-app.json; \
test -f /tmp/toolbox/apps/minimize/toolbox-app.json; \
test -f /tmp/toolbox/apps/format-lab/toolbox-app.json; \
test -f /tmp/toolbox/apps/repro/toolbox-app.json; \
test -f /tmp/toolbox/apps/schema/toolbox-app.json; \
test -f /tmp/toolbox/apps/log/toolbox-app.json; \
test -f /tmp/toolbox/apps/binary/toolbox-app.json; \
test -f /tmp/toolbox/apps/git/toolbox-app.json; \
test -f /tmp/toolbox/apps/api/toolbox-app.json; \
test -f /tmp/toolbox/apps/mail/toolbox-app.json; \
test -f /tmp/toolbox/apps/calendar/toolbox-app.json; \
test -f /tmp/toolbox/apps/contact/toolbox-app.json; \
test -f /tmp/toolbox/apps/diagram/toolbox-app.json; \
test -f /tmp/toolbox/apps/token/toolbox-app.json; \
test -f /tmp/toolbox/apps/device/toolbox-app.json; \
rm "/tmp/${archive}" rm "/tmp/${archive}"
FROM ${NGINX_IMAGE} FROM ${NGINX_IMAGE}
+216 -84
View File
@@ -1,9 +1,11 @@
# add·ideas Toolbox Portal # add·ideas Toolbox Portal
`toolbox-portal` is the static launcher and release assembler for the add·ideas `toolbox-portal` is the static launcher and release assembler for the add·ideas
browser toolbox. Version `0.1.0` reads one same-origin catalogue, shows each app browser toolbox. Version `0.2.23` reads one same-origin catalogue, shows each app
as a full-page launch card, and keeps personal pins, ordering, visibility, and as a compact launch tile, and keeps personal pins, drag-and-drop ordering,
appearance in the current browser. visibility, and appearance in the current browser. Pinned tools have their own
section; search, category, and clickable tag filters stay close to the tool
list.
The v1 boundary is intentionally small: The v1 boundary is intentionally small:
@@ -28,15 +30,27 @@ npm run build
npm run dev npm run dev
``` ```
The package lock resolves the `^0.1.0` SDK range to the published `0.1.1` The package lock resolves the `^0.3.0` SDK ranges to the published `0.3.0`
packages. A sibling SDK checkout is only needed when intentionally developing packages. A sibling SDK checkout is only needed when intentionally developing
the SDK and portal together. the SDK and portal together.
Vite uses `base: './'`, so the built portal works at `/` or a nested static path. Vite uses `base: './'`, so the built portal works at `/` or a nested static path.
The development catalogue at `public/toolbox.catalog.json` points to assembled The development catalogue at `public/toolbox.catalog.json` points to assembled
paths (`./apps/pdf/`, `./apps/xslt/`, and `./apps/onenote/`). Those manifests paths (`./apps/pdf/`, `./apps/xslt/`, `./apps/onenote/`, `./apps/av/`,
will correctly appear as unavailable until an assembled release is being `./apps/regex/`, `./apps/svg/`, `./apps/auth/`, `./apps/sudoku/`,
served. `./apps/colour/`, `./apps/office/`, `./apps/image/`, `./apps/file/`,
`./apps/epub/`, `./apps/archive/`, `./apps/privacy/`, `./apps/crypto/`,
`./apps/barcode/`, `./apps/network/`, `./apps/geo/`, `./apps/helper/`,
`./apps/rand/`, `./apps/data/`, `./apps/diff/`, `./apps/time/`,
`./apps/text/`, `./apps/unicode/`, `./apps/flow/`, `./apps/subtitle/`,
`./apps/midi/`, `./apps/3d/`, `./apps/query/`, `./apps/scan/`,
`./apps/package/`, `./apps/label/`, `./apps/font/`, `./apps/fixture/`,
`./apps/minimize/`, `./apps/format-lab/`, `./apps/repro/`, `./apps/schema/`,
`./apps/log/`, `./apps/binary/`, `./apps/git/`, `./apps/api/`, `./apps/mail/`,
`./apps/calendar/`, `./apps/contact/`, `./apps/diagram/`, `./apps/token/`, and
`./apps/device/`).
Those manifests will correctly appear as unavailable until an assembled release
is being served.
## Catalogue and launches ## Catalogue and launches
@@ -66,7 +80,7 @@ privacy and executable-code warning. Light, dark, and system modes are supported
## Production deployment behind Traefik ## Production deployment behind Traefik
The committed `compose.yaml` is the shortest production path. Its release The committed `compose.yaml` is the shortest production path. Its release
container downloads the immutable Toolbox 0.3.0 ZIP during the image build, container downloads the immutable Toolbox 0.20.0 ZIP during the image build,
verifies SHA-256 before extracting it, and then copies only the verified static verifies SHA-256 before extracting it, and then copies only the verified static
files into the pinned unprivileged nginx image. Node.js and a local portal files into the pinned unprivileged nginx image. Node.js and a local portal
assembly are not required on the deployment host. assembly are not required on the deployment host.
@@ -75,27 +89,40 @@ Prerequisites:
- Docker with the Compose plugin; - Docker with the Compose plugin;
- Traefik attached to the existing external Docker network `internal`; - Traefik attached to the existing external Docker network `internal`;
- a Traefik HTTPS entrypoint named `websecure`; - Traefik HTTP and HTTPS entrypoints named `web` and `websecure`;
- a certificate resolver named `netcup`; - a certificate resolver named `netcup`;
- DNS for `toolbox.add-ideas.de` pointing to that Traefik instance; and - DNS for `toolbox.add-ideas.de` and `auth.toolbox.add-ideas.de` pointing to that Traefik instance; and
- outbound HTTPS access to `git.add-ideas.de` while the image is built. - outbound HTTPS access to `git.add-ideas.de` while the image is built.
Deploy a fresh clone with: Deploy a fresh clone with:
```sh ```sh
git clone https://git.add-ideas.de/zemion/toolbox-portal.git git clone https://git.add-ideas.de/lotobo/toolbox-portal.git
cd toolbox-portal cd toolbox-portal
docker compose up -d --build docker compose up -d --build
docker compose ps docker compose ps
``` ```
No host port is published. Traefik routes No host port is published. Traefik permanently redirects
`https://toolbox.add-ideas.de` to nginx on the shared network at port 8080 and `http://toolbox.add-ideas.de` to HTTPS, routes
`https://toolbox.add-ideas.de` to nginx on the shared network at port 8080, and
obtains its TLS certificate through `netcup`. The hostname, network, resolver, obtains its TLS certificate through `netcup`. The hostname, network, resolver,
release version, and matching checksum can be overridden through environment release version, and matching checksum can be overridden through environment
variables; copy `.env.example` to `.env` only when an override is needed. For variables; copy `.env.example` to `.env` only when an override is needed. For
example, set `TOOLBOX_HOST=staging.toolbox.add-ideas.de` for a staging host. example, set `TOOLBOX_HOST=staging.toolbox.add-ideas.de` for a staging host.
The same container also exposes only the packaged authentication app at
`https://auth.toolbox.add-ideas.de/`. Traefik adds the internal `/apps/auth`
prefix while the public URL remains `/`, so assets remain relative and the browser
binds WebAuthn credentials to the dedicated `auth.toolbox.add-ideas.de` RP ID.
The main Toolbox copy at `/apps/auth/` remains useful for offline inspection but
disables live ceremonies because all Portal apps share that origin. The router
hostname can be overridden with `AUTH_TOOLS_HOST`, but the bundled Auth Tools
release enables live ceremonies only on its pinned production host (or
localhost); other hosts remain inspect-only. A different production host
therefore also requires rebuilding Auth Tools with the new exact host. Never
widen the RP ID to `add-ideas.de`.
The external network is deliberately not created by this project. Creating a The external network is deliberately not created by this project. Creating a
private project-local network would prevent an independently managed Traefik private project-local network would prevent an independently managed Traefik
container from reaching the service. container from reaching the service.
@@ -117,9 +144,16 @@ The app release should also publish a matching `.sha256` sidecar. The manifest
must declare the pinned reverse-DNS id and version. Application and portal must declare the pinned reverse-DNS id and version. Application and portal
versions are independent. versions are independent.
`release/toolbox.lock.json` is the reviewed v0.3.0 lock. Its URLs and checksums `release/toolbox.lock.json` is the reviewed Toolbox v0.20.0 / Portal v0.2.23
pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.2.0 lock. Its URLs and checksums pin PDF Tools 0.4.4, XSLT Tools 0.4.3, OneNote
release bytes. Use Tools 0.3.4, Audio & Video Tools 0.3.0, Regex Tools 0.4.2, OTP & Passkey Tools
0.3.1, Sudoku Tools 0.2.1, and the coordinated 0.2.0 releases of SVG, Colour,
Office, Image, File, EPUB, Archive, Privacy, Crypto, Barcode, Network, Geo,
Helper, Random, Data, Diff, Time, Text, Unicode, Flow, Subtitle, MIDI, 3D,
Query, Scan, Package, Label, Font, Fixture, Minimize, Format Lab, Repro, Schema,
Log, Binary, Git, API, Mail, Calendar, Contact, Diagram, Token, and Device
Tools.
Use
`release/toolbox.lock.example.json` as the template for a future release and `release/toolbox.lock.example.json` as the template for a future release and
verify every downloaded asset before committing updated values. Artifacts may be: verify every downloaded asset before committing updated values. Artifacts may be:
@@ -140,7 +174,7 @@ npm run assemble -- \
--lock release/toolbox.lock.json \ --lock release/toolbox.lock.json \
--portal-dist dist \ --portal-dist dist \
--output build/toolbox \ --output build/toolbox \
--archive build/add-ideas-toolbox-0.3.0.zip --archive build/add-ideas-toolbox-0.20.0.zip
``` ```
Existing output is refused. Pass `--force` only when replacing those exact Existing output is refused. Pass `--force` only when replacing those exact
@@ -158,10 +192,57 @@ build/toolbox/
└── apps/ └── apps/
├── pdf/ ├── pdf/
├── xslt/ ├── xslt/
── onenote/ ── onenote/
├── av/
├── regex/
├── svg/
├── auth/
├── sudoku/
├── colour/
├── office/
├── image/
├── file/
├── epub/
├── archive/
├── privacy/
├── crypto/
├── barcode/
├── network/
├── geo/
├── helper/
├── rand/
├── data/
├── diff/
├── time/
├── text/
├── unicode/
├── flow/
├── subtitle/
├── midi/
├── 3d/
├── query/
├── scan/
├── package/
├── label/
├── font/
├── fixture/
├── minimize/
├── format-lab/
├── repro/
├── schema/
├── log/
├── binary/
├── git/
├── api/
├── mail/
├── calendar/
├── contact/
├── diagram/
├── token/
└── device/
build/add-ideas-toolbox-0.3.0.zip build/add-ideas-toolbox-0.20.0.zip
build/add-ideas-toolbox-0.3.0.zip.sha256 build/add-ideas-toolbox-0.20.0.zip.sha256
``` ```
The assembler verifies SHA-256 before opening an artifact, validates every app The assembler verifies SHA-256 before opening an artifact, validates every app
@@ -182,26 +263,42 @@ directory separately:
```sh ```sh
npm run package:static -- \ npm run package:static -- \
--input build/toolbox \ --input build/toolbox \
--output artifacts/add-ideas-toolbox-0.3.0.zip --output artifacts/add-ideas-toolbox-0.20.0.zip
``` ```
This also emits a `.sha256` sidecar. This also emits a `.sha256` sidecar.
## Local assembled container and publication ## Local assembled container and publication
`Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, immutable `Containerfile` serves only the assembled files with unprivileged nginx on port 8080. It adds restrictive browser headers, same-origin isolation, explicit
caching only for Vite-hashed assets, and revalidation for all other files. `application/javascript` for `.mjs` engine modules, `application/wasm`, and
Assemble first, then: `application/gzip` for the bundled Scan OCR language model,
immutable caching only for Vite-hashed assets and narrowly matched
semver-versioned FFmpeg core files, and revalidation for all other files. The
opaque-origin SVG preview iframe loads its packaged controller with a URI-exact
CORS and cross-origin resource-policy exception; all other files retain the
same-origin policy and every normal security header. Camera access is denied by
default. It is granted to the dedicated `auth.toolbox.add-ideas.de` origin and,
on the shared Toolbox host, only to `/apps/barcode/`, `/apps/scan/`, and
`/apps/device/`. Device Tools alone additionally receives microphone and screen-
capture policy at its exact path; every probe remains explicitly user initiated.
If `AUTH_TOOLS_HOST` is customized, update
the exact host entry in `deploy/nginx.conf` and Auth Tools' pinned WebAuthn host
at the same time. Every Toolbox app, including Random Tools, is constrained to
same-origin connections. Random Tools implements its random generators locally
with browser APIs and never calls a third-party randomness service.
The packaged policy intentionally does not grant CSP `unsafe-eval`. SaxonJS's The packaged policy intentionally does not grant CSP `unsafe-eval`. SaxonJS's
`ixsl:eval()` extension is therefore unsupported in this deployment profile; `ixsl:eval()` extension is therefore unsupported in this deployment profile;
normal local XML/XSLT transformations do not require that permission. normal local XML/XSLT transformations do not require that permission.
Assemble first, then:
```sh ```sh
podman build -f Containerfile \ podman build -f Containerfile \
-t git.add-ideas.de/zemion/toolbox:0.3.0 . -t git.add-ideas.de/lotobo/toolbox:0.20.0 .
podman run --rm -p 8080:8080 \ podman run --rm -p 8080:8080 \
git.add-ideas.de/zemion/toolbox:0.3.0 git.add-ideas.de/lotobo/toolbox:0.20.0
``` ```
For a direct-port local deployment after assembly, use the separate example: For a direct-port local deployment after assembly, use the separate example:
@@ -218,8 +315,8 @@ docker login git.add-ideas.de
docker buildx build \ docker buildx build \
--platform linux/amd64,linux/arm64 \ --platform linux/amd64,linux/arm64 \
--file Containerfile.release \ --file Containerfile.release \
--tag git.add-ideas.de/zemion/toolbox:0.3.0 \ --tag git.add-ideas.de/lotobo/toolbox:0.20.0 \
--tag git.add-ideas.de/zemion/toolbox:0.3 \ --tag git.add-ideas.de/lotobo/toolbox:0.20 \
--push . --push .
``` ```
@@ -230,9 +327,9 @@ notes.
## Manual Gitea publication checklist ## Manual Gitea publication checklist
No credentials belong in this repository. Before the first release, create the No credentials belong in this repository. The publishing workstation or runner
`zemion/toolbox-portal` Gitea repository and grant the workstation or runner needs permission to push code, releases, and container packages to
permission to push code, releases, and container packages. `lotobo/toolbox-portal`.
1. Run `npm ci`, `npm test`, `npm run lint`, and `npm run build` from a clean 1. Run `npm ci`, `npm test`, `npm run lint`, and `npm run build` from a clean
checkout of the intended portal tag. checkout of the intended portal tag.
@@ -241,11 +338,11 @@ permission to push code, releases, and container packages.
3. Verify downloaded app assets with `sha256sum -c <asset>.sha256`; copy those 3. Verify downloaded app assets with `sha256sum -c <asset>.sha256`; copy those
exact values into a reviewed toolbox lock. exact values into a reviewed toolbox lock.
4. Run the assembler and serve `build/toolbox` from a nested test path. Open 4. Run the assembler and serve `build/toolbox` from a nested test path. Open
all three apps, switch between them, and confirm the encoded `toolbox` all 50 apps, switch between them, and confirm the encoded `toolbox`
context. context.
5. Verify the distribution with 5. Verify the distribution with
`(cd build && sha256sum -c add-ideas-toolbox-0.3.0.zip.sha256)`. `(cd build && sha256sum -c add-ideas-toolbox-0.20.0.zip.sha256)`.
6. Commit the reviewed lock, tag the toolbox release (for example `v0.3.0`), and 6. Commit the reviewed lock, tag the toolbox release (for example `v0.20.0`), and
push the branch and tag to Gitea. push the branch and tag to Gitea.
7. In Gitea, open **Releases → New release**, select the tag, and upload the 7. In Gitea, open **Releases → New release**, select the tag, and upload the
static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a static ZIP plus its `.sha256` file. Do not use a mutable “latest” URL in a
@@ -258,57 +355,92 @@ the exact tag, install with `npm ci`, run the same verification/assembly command
and upload only the already-created ZIP/checksum and OCI image. The workflow and upload only the already-created ZIP/checksum and OCI image. The workflow
must not re-resolve app versions or substitute a newer release. must not re-resolve app versions or substitute a newer release.
## Planned / existing tools ## Toolbox 0.20.0 portfolio expansion
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations | Toolbox 0.20.0 publishes the coordinated multi-repository depth pass. It adds:
| -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`pdf-tools`** | Existing; dedicated repository | Merge, split, reorder, rotate and export PDF pages | Thumbnail workspace; multi-document operations; ZIP and PDF output; saved local workspace | Workers and IndexedDB; large-document memory control; future signature inspection through `crypto-tools`; metadata and safe-sharing through `privacy-tools` | - bounded byte/stream/worker/blob helpers, manifest I/O and capability profiles,
| **`xslt-tools`** | Existing; dedicated repository | Develop, test and run XSLT transformations | XML/XSLT editors; transformation results; local files; saved projects; validation and diagnostics | Lazy SaxonJS loading; relocatable assets; useful handoffs to `data-tools`, `schema-tools` and `diff-tools` | versioned evidence envelopes, and same-origin one-time artifact transfers;
| **`svg-tools`** | Planned; dedicated repository | Source and visual SVG editing | Synchronized DOM tree, code and canvas; optimization; path simplification; symbols; accessibility; animation; sanitization | SVG is potentially active content; previews require sanitization or sandboxing; integrate with `colour-tools`, `token-tools`, `image-tools` and `label-tools` | - deeper batch, conversion, safe-share, schema, semantic diff, reproducibility,
| **`regex-tools`** | Planned; dedicated repository | Develop, test and apply regular expressions | Corpus mode; replacement preview; capture table; flavour comparison; performance visualization; generated test cases; failing-input minimization | Never claim exact equivalence across regex engines without qualification; bound execution; integrate with `text-tools`, `log-tools` and `minimize-tools` | archive, text, Unicode, colour, random, network, barcode, crypto and geospatial
| **`colour-tools`** | Planned; small-to-medium dedicated repository | Colour conversion, calculation and palette work | sRGB, Display P3, Lab, LCH, OKLab and OKLCH; gamut mapping; contrast; colour-vision simulation; gradients; palette extraction; design-token export | Make colour-space assumptions explicit; distinguish mathematical conversion from display simulation; integrate with SVG, image and token tools | workflows;
| **`rand-tools`** | Planned; small dedicated repository | Generate random numbers, strings, identifiers and samples | Cryptographically secure generation; deterministic seeded generation; UUIDs, ULIDs, passphrases, dice notation, distributions, weighted selection and shuffling | Clearly separate secure randomness from reproducible pseudorandomness; no misleading “strength” claims; natural foundation for `fixture-tools` | - richer EPUB, Office, SVG, image, subtitle, MIDI, 3D, scan, label, font,
| **`barcode-tools`** | Planned; dedicated repository | Generate, inspect and decode QR and barcodes | QR, common one- and two-dimensional codes; camera/image decoding; GS1 assistance; CSV batch generation; quiet-zone and print-size checks; structured payload builders | Treat decoded payloads as untrusted; never open links automatically; camera permission must be optional; integrate with `label-tools`, `contact-tools` and `crypto-tools` | diagram, design-token and structured-flow authoring/inspection, including
| **`av-tools`** | Planned; large dedicated repository | Light audio and video editing and transcoding | Trim, split, concatenate, crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, thumbnail/contact-sheet generation and export presets | Heavy WASM and worker workloads; temporary-file storage; configurable memory limits; possibly special cross-origin-isolation headers; integrate with `subtitle-tools` and `image-tools` | bounded HTML/Markdown/text-to-EPUB adaptation and TTC/OTC face inspection;
| **`crypto-tools`** | Planned; dedicated repository | Inspect keys, certificates and signatures; validate chains | X.509 PEM/DER, CSR, CRL, PKCS #8, encrypted PKCS #8, PKCS #12/PFX, JWK/JWKS; key/certificate matching; path construction; hostname, purpose and time checks; CMS/JWS later | Inspection-first; explicit trust anchors; no implication that browser/OS trust stores are used; no private-key persistence; offline revocation first; network checks opt-in; restrictive CSP and worker isolation | - modern PBES2/PBKDF2 PKCS #12 inspection, structural 7z/RAR inventory, and
| **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity | OpenAPI 3.1/3.2 webhook receiver analysis with explicit fail-closed limits;
| **`data-tools`** | High priority; dedicated repository | Inspect, edit, validate, query and convert structured data | JSON, YAML, TOML, XML, CSV and NDJSON; tree/table/source views; schema inference; flattening; filtering; field mapping; JSONPath-style queries; conversion recipes | Every conversion should disclose information loss, coercion and round-trip instability; XML-specialist workflows hand off to `xslt-tools` | - expanded Query, Fixture, Minimize, Format Lab, Binary, Git, API, Mail,
| **`image-tools`** | High priority; dedicated repository | Edit and batch-process raster images | Crop, resize, rotate, compress, convert, contact sheets, sprites, responsive sets, favicon generation, metadata removal and visual quality comparison | Use workers and off-main-thread rendering; preserve or deliberately remove colour profiles; integrate with colour, SVG, PDF and privacy tools | Calendar, Contact, Log and Device evidence and interoperability; and
| **`diff-tools`** | High priority; dedicated repository or shared engine plus UI | Compare documents and files semantically | Text; JSON object-aware comparison; XML normalization; CSV keyed comparison; images with overlay/heatmap; archives and directories; PDF pages; optional audio waveform comparison | Normalization and ignored properties must be visible; produce portable reports and standard patch formats where possible | - repository-native Gitea verification workflows covering formatting, lint,
| **`file-tools`** | High priority; dedicated repository | Identify, inspect and organize local files | Magic-byte detection; hex view; decoded strings; checksums; directory manifests; split/join; duplicate detection; trailing-data detection; batch-rename preview | All inputs are untrusted; format inspectors require strict size and nesting limits; natural entry point to archive, privacy, crypto and package tools | unit tests, production builds, Toolbox smoke checks, runtime dependency audit
| **`archive-tools`** | Medium-to-high priority; dedicated repository | Inspect, create, compare and extract archives | ZIP, TAR, gzip and selected additional formats; selective extraction; content preview; deterministic archives; timestamp normalization; archive comparison | Expansion-ratio, entry-count and total-size limits; traversal-safe extraction; never execute extracted files | and browser tests where applicable.
| **`text-tools`** | Medium priority; one app with deep-linked panels | Transform and normalize plain text | Unicode normalization; line-ending conversion; encoding; sorting; deduplication; case changes; transliteration; escaping; column operations; transformation pipelines | Preserve exact input/output visibility; warn about lossy encodings and Unicode confusables; integrate regex operations |
| **`query-tools`** | High-value but large; dedicated repository | Analyse local tabular and relational data with SQL | CSV, JSON, NDJSON, Parquet and SQLite; schema inference; joins; aggregation; pivots; charts; export to common data formats | WASM memory and streaming; distinguish this analytical product from document-oriented `data-tools`; query work must remain local | Every addition remains local-first, bounded and explicit about unsupported
| **`privacy-tools`** | High priority; dedicated repository | Inspect and remove metadata before sharing | EXIF and GPS; document author/comments; embedded thumbnails; PDF metadata and attachments; hidden package entries; AV tags; personal filenames; structured safe-sharing report | Never promise absolute anonymity; explicitly state inspected, removed, preserved and unsupported structures; integrate with most file-oriented apps | formats or incomplete standards coverage. The release order is Toolbox SDK
| **`time-tools`** | Medium priority; small dedicated app or helper family | Work with dates, timestamps, time zones and recurrences | Unix timestamps; date arithmetic; durations; ISO 8601; timezone comparison; cron; RRULE; ICS generation; business days; DST-transition visualization | Visualize actual recurrence instances and ambiguous/skipped local times; do not rely on simplified fixed-offset calculations | `0.3.0`, `@add-ideas/toolbox-helpers@0.2.0`, the 43 app releases at `0.2.0`, and
| **`subtitle-tools`** | Medium priority; dedicated repository | Edit, validate and synchronize captions | SRT, WebVTT and ASS; timing shifts; stretching; frame-rate conversion; overlap and reading-speed checks; waveform synchronization; revision comparison | Direct integration with `av-tools`; preserve style information when supported and disclose conversion losses | Portal `0.2.23` with aggregate Toolbox `0.20.0`. The reviewed lock pins those
| **`font-tools`** | Medium priority; dedicated repository | Inspect, preview and prepare fonts | Glyph coverage; variable axes; metadata; fallback comparison; subsetting; CSS generation; arbitrary-text previews | Font licensing and embedding restrictions must be visible; use untrusted-font isolation and strict parser limits | exact immutable app archives and checksums.
| **`label-tools`** | Medium priority; dedicated repository | Generate labels and badges from templates and data | CSV/JSON merge into SVG templates; QR/barcode fields; serial numbers; A4 label sheets; badges; asset tags; cut marks; calibration | Strong suite demonstration linking data, SVG, barcode, random and PDF capabilities; print dimensions must be explicit and testable |
| **`flow-tools`** | Later platform layer | Compose local processing operations across tools | Example: CSV → filter → QR generation → SVG template → PDF → ZIP; reusable recipes; typed inputs and outputs; progress and cancellation | Do not load complete React applications into one runtime; expose separate worker-safe operation packages only after several apps have stable operations | ## Existing tools
| **`fixture-tools`** | Medium priority | Generate deterministic test data | JSON Schema, XSD, SQL DDL, CSV headings or interactive models to JSON, CSV, XML, SQL and NDJSON; foreign keys; distributions; boundary and invalid cases | Seeded reproducibility; uniqueness constraints; privacy-safe synthetic data; natural extension of `rand-tools` |
| **`minimize-tools`** | Distinctive specialist tool | Reduce a failing input while preserving a failure | Minimize XML triggering an XSLT error; shortest regex pathological input; smallest JSON schema failure; selectable failure predicates | Expensive repeated execution must be bounded and cancellable; record the exact predicate and transformation versions | | Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
| **`format-lab`** | Later, distinctive product | Explore conversion paths and information loss | Format graph; round-trip tests; property preservation; type coercion; metadata loss; recommended path selection across data, image, AV and subtitle formats | No conversion should be described as lossless without testing relevant properties | | -------------------- | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`repro-tools`** | Medium-to-later | Create manifests and provenance records | File hashes; directory manifests; operation history; tool/version/parameter records; deterministic package creation; verification reports | Useful foundation for reproducible workflows and signed manifests; integrates with crypto, archive and flow tools | | **`pdf-tools`** | Existing; dedicated repository | Merge, split, reorder, rotate and export PDF pages | Thumbnail workspace; multi-document operations; ZIP and PDF output; saved local workspace | Workers and IndexedDB; large-document memory control; future signature inspection through `crypto-tools`; metadata and safe-sharing through `privacy-tools` |
| **`package-tools`** | Medium priority | Inspect compound and package-based formats | EPUB; DOCX/XLSX/PPTX; ODF; JAR; APK; browser extensions; package trees; manifests; relationships; embedded media; signatures; orphaned parts | Generic container inspector with format adapters; complements rather than replaces `onenote-tools`, `epub-tools` and `office-tools` | | **`xslt-tools`** | Existing; dedicated repository | Develop, test and run XSLT transformations | XML/XSLT editors; transformation results; local files; saved projects; validation and diagnostics | Lazy SaxonJS loading; relocatable assets; useful handoffs to `data-tools`, `schema-tools` and `diff-tools` |
| **`schema-tools`** | Dedicated specialist app | Validate, inspect and generate examples for JSON Schema, XSD, Relax NG, Schematron and OpenAPI; visualize references and incompatibilities | | | **`onenote-tools`** | Existing rich-reader release; dedicated repository | Open, browse, inspect and export OneNote sections and packages locally | Desktop/unfragmented FSSHTTPB `.one`/`.onetoc2`; none/LZX/MSZIP/Quantum and multi-cabinet `.onepkg`; trees, rich text, images, tables, ink, attachments and export | Native TypeScript worker with no Wasm; bounded parsing, rendering and export; fragmented FSSHTTP fails safely; no editing or pixel-perfect fidelity |
| **`log-tools`** | Dedicated app | Stream and filter large logs; parse common formats; correlate records; build timelines; extract fields; anonymize values; avoid loading the complete file into memory | | | **`av-tools`** | Existing local-first v0.3.0 release; dedicated repository | Convert and lightly edit audio and video locally in the browser | Immediate native playback and basic file information; progressive stream inspection on demand; quick conversion; trim, split and crossfade concatenate; crop, resize, normalize, fades, waveform, metadata, chapters, subtitles, scene thumbnails/contact sheets, presets and export | Reviewed ffmpeg.wasm 0.12.10 ST/MT build with verified Opus and bundled label font; bounded queue, temporary storage and resource policy; isolation enables MT with automatic ST fallback; versioned core assets are immutable |
| **`network-tools`** | Helper family or small app | IPv4/IPv6 subnetting; CIDR ranges; URL and query parsing; DNS-record construction; HTTP headers; CSP generation; MIME lookup; avoid turning it into an intrusive scanner | | | **`regex-tools`** | Current local-first v0.4.2 release; dedicated repository | Develop, explain, test and apply JavaScript, PCRE2, PHP, Perl, Python, Ruby, Java, C++, Go, .NET, Rust and Scala/JVM-compatible regular expressions | Deterministic syntax trees; engine-native matching, captures and bounded replacement; stable double-buffered live results; PCRE2 tracing and C17 generation; comparison; corpus apply; tests; risk/growth/benchmark analysis; generated cases; bounded minimization; validated ECMAScript formatting; project import/export and optional local persistence | Open-source regexpp and pinned local WebAssembly runtimes for PCRE2, PHP preg, legacy Perl, CPython, CRuby, TeaVM Java/Scala compatibility, libc++ C++, Go, .NET and Rust in killable workers; explicit source/licence inventories, resource limits and engine-specific offset semantics |
| **`binary-tools`** | Dedicated specialist app | Base64, hexadecimal, CBOR, MessagePack, ASN.1 and Protocol Buffers; schema-assisted decoding; byte-range highlighting; strict depth and size limits | | | **`svg-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect and edit SVG source, structure, geometry, references and accessibility locally | Synchronized source/tree/canvas/inspector; path and transform tools; reference analysis; optimization; app-owned, validated opacity-animation preview; exact, sanitized, raster and project export | Untrusted SVG is sanitized into an opaque-origin sandbox; bounded parsing and decompression, explicit security findings and a URI-scoped controller header exception; later milestone slices remain intentionally tracked in the app repository |
| **`git-tools`** | Small-to-medium app | Patch inspection and editing; `.gitignore` testing; semantic-version comparison; conventional commits; changelog normalization; no repository-hosting dependency | | | **`auth-tools`** | Current local-first v0.3.1 release; dedicated repository | Generate, migrate and diagnose OTP credentials, and inspect, verify and test WebAuthn/passkey ceremonies locally | HOTP/TOTP/OCRA with time travel, resynchronization and rotation planning; QR, Google, Aegis and encrypted PSKC migration; WebAuthn extension experiments and replayable traces; assertion, attestation, signer-chain and historical metadata-policy evaluation | Authentication material remains memory-only unless explicitly exported; redaction is deliberate but not a secrecy guarantee; live ceremonies and camera scanning are enabled only at the exact dedicated `auth.toolbox.add-ideas.de` origin, while the shared Portal path remains inspect-only; v0.3.1 corrects source provenance and cross-timezone artifact reproducibility; no raw CTAP administration |
| **`api-tools`** | Medium app | OpenAPI validation; request and response examples; curl and client-command generation; saved HTTP-exchange inspection; direct browser requests remain subject to CORS | | | **`sudoku-tools`** | Current local-first v0.2.1 release; dedicated repository | Set, play, generate, analyse and solve classic, Killer and rich variant Sudoku locally | 4×416×16 grids; registry-backed constraint packs including cages, lines, dots, XV, inequalities, indexing and Fog of War; staged hints, candidate maintenance, advanced logical techniques, setter quality checks, mixed-variant generation, source-preserving f-puzzles/SudokuPad interoperability, autosave recovery and searchable local projects | Worker-bounded solving, generation and quality analysis; strict inert imported-visual validation; fog-safe play assistance; IndexedDB history with memory fallback; stable workspace geometry, responsive zoom and pan, a 3×3 standard keypad, tap selection, patterned colours, accessibility controls and an offline-capable PWA shell |
| **`calendar-tools`** | Small-to-medium app | ICS inspection, merging, deduplication, repair, recurrence visualization and timezone diagnostics | | | **`colour-tools`** | Current local-first v0.2.0 release; dedicated repository | Convert, composite, interpolate, pick, sample, analyse and export colours locally | General colour conversion; arbitrary RGBA compositing; multi-stop colour steps and gradient editing; large visual/native/EyeDropper pickers; local image sampling and palette extraction; contrast, gamut, Delta E and colour-vision analysis; harmonies; DTCG 2025.10 token import/export with local reference resolution; directional contrast matrices; CSS and recipe export | Processing and image access remain local; colour spaces, encoded versus linear-light compositing, interpolation and gamut-mapping assumptions are explicit; mathematical conversion is distinguished from display simulation; integrates with SVG, image and token tools |
| **`contact-tools`** | Small-to-medium app | vCard inspection and editing; CSV mapping; deduplication; contact QR generation; treat all contact data as sensitive | | | **`office-tools`** | Current local-first v0.2.0 read-only release; dedicated repository | Open, inspect and view word-processing documents, spreadsheets and presentations locally; download exact source copies and bounded ODF semantic exports | DOCX/DOCM/DOTX/DOTM, XLSX/XLSM/XLTX/XLTM and PPTX/PPTM/POTX/POTM/PPSX/PPSM plus ODT/ODS/ODP; search/navigation/zoom; text, tables, images, styles, metadata, cached formula values and notes; exact source-copy plus ODF text/CSV/outline/JSON export | Bounded worker-based inert package and XML parsers never execute macros, scripts, formulas, active content or external resources; legacy binary DOC, XLS and PPT are unsupported; viewing is intentionally read-only and does not promise pixel-perfect Office-suite layout fidelity |
| **`mail-tools`** | Medium app | EML and MIME inspection; header analysis; attachment extraction; body-part comparison; HTML mail must be heavily sandboxed | | | **`image-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, crop, rotate, resize, preview before/after and batch-convert static raster images locally | Static JPEG/PNG/WebP; bounded pre-decode animation/profile inspection; synchronized 25400% source/result zoom; pointer/keyboard crop and fit/fill/exact resize; versioned recipes; cancellable batch ZIP with per-image reports; capability-probed PNG/JPEG/WebP export | Animated and multi-picture inputs are inspect-only; canvas re-encoding does not preserve arbitrary metadata, ICC profiles, HDR precision or byte identity |
| **`epub-tools`** | Medium app | EPUB inspection, metadata and cover editing, link validation, chapter extraction and structural repair; sanitize embedded HTML and SVG | | | **`file-tools`** | Current local-first v0.2.0 release; dedicated repository | Identify, inspect, hash, compare, inventory, rename-copy, split and join local files | Signature and claimed-MIME comparison; paged hex/ASCII and strings; SHA-256/SHA-512; deterministic manifests; duplicate candidates; collision-safe rename/copy plans; byte-exact split/join | Detection and entropy are heuristics rather than validation or malware analysis; rename operations export safe copies and never mutate the source filesystem |
| **`scan-tools`** | Large app | Camera/document correction; crop, deskew and threshold; page assembly; local OCR; PDF output; model downloads and memory use must be explicit | | | **`epub-tools`** | Current local-first v0.2.0 release; dedicated repository | Read, inspect and validate EPUB 2/3 publications; adapt text formats, edit metadata and covers, and rebuild EPUBs locally | EPUB 2/3 package/nav/spine inspection; determinate open progress; sandboxed reflow/fixed-layout reading with packaged CSS/fonts/images/media; bounded HTML/XHTML/Markdown/text adapters; search, bookmarks and annotations; metadata/cover editing; text/Markdown/safe-HTML/chapter/report exports; normalized rebuilds with IDPF font-obfuscation preservation | Strict bounded ZIP and XML handling; active and external content are blocked; DRM is detected but never bypassed; focused preflight is not a complete EPUBCheck implementation |
| **`office-tools`** | Medium-to-large app | Inspect rather than reproduce an office suite: package structure, relationships, metadata, comments, embedded images and basic repairs | | | **`archive-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, create, compare and safely extract archive content | ZIP/ZIP64, TAR/USTAR/PAX, gzip and tar.gz plus bounded structural 7z and RAR4/RAR5 inspection; inert previews; ZipCrypto/WinZip AES read and create; deterministic unencrypted ZIP/TAR/tar.gz; cross-format comparison; selected-file verification and repackaging | Path, entry-count, expanded-size and compression-ratio limits; no direct filesystem restoration or nested expansion; multipart ZIP and 7z/RAR decompression/extraction remain unsupported; links and special entries are inventory-only and blocked from restoration |
| **`geo-tools`** | Medium app | GeoJSON, GPX, KML and coordinate CSV; format conversion; track simplification; distance/elevation analysis; coordinate-reference assumptions must be explicit | | | **`privacy-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect image metadata and make independently verified sharing copies | Deep JPEG/PNG/WebP EXIF/IPTC/XMP/profile/preview/provenance/trailing-data scans; orientation-normalized re-encoding with mandatory output rescan and pixel comparison; source/output hashes; pseudonymized safe-share and detailed JSON/ZIP reports; reusable remove/preserve/review policy evidence | Not an anonymity guarantee; verified re-encoding is limited to complete static JPEG/PNG/WebP scans, while other recognized formats are inventory/inspect-only; re-encoding can change pixels and remove colour, resolution and authenticity information; detailed reports can contain sensitive metadata |
| **`diagram-tools`** | Medium app | Code and visual editing for Mermaid, Graphviz and related representations; renderers require sanitization and sandboxing | | | **`crypto-tools`** | Current local-first v0.2.0 inspection and operations release; dedicated repository | Inspect certificates, requests, CRLs, keys, encrypted PKCS #8, PKCS #12/PFX and JWK/JWKS material, and run fixed-profile local cryptographic operations | Bounded X.509/CSR/CRL, PBES2/PBKDF2 PKCS #8 and PKCS #12 inspection/decryption; JWK/JWKS and RFC 7638; explicit certificate-path checks without trust claims; DNS SAN checks; fixed-profile RSA-PSS/PKCS1, ECDSA and browser-supported Ed25519 sign/verify; RSA-OAEP and AES-256-GCM text operations | No browser/OS trust implication, complete RFC 5280 validation, revocation service, issuance, CMS/JWS, legacy PKCS #1/SEC1 conversion, legacy PKCS #12 PBE or general-purpose file cryptography; sensitive inputs remain bounded and memory-only, with documented temporary-buffer wiping |
| **`token-tools`** | Small-to-medium app | Design-token editing and conversion between JSON, CSS custom properties, Sass, Android and iOS forms; integrate with colour and SVG tools | | | **`barcode-tools`** | Current local-first v0.2.0 release; dedicated repository | Generate and decode QR codes and common barcodes | SVG QR, Data Matrix, PDF417, Aztec and selected linear formats; bounded image or explicit camera decode; escaped structured QR payloads; deterministic CSV batch ZIP; common GS1 AI/FNC1/date/check-digit/decimal validation; GTIN checks; physical/raster planning and print-sized SVG export | Decoded payloads remain inert text and are never opened automatically; camera is path-scoped and user initiated; generated output, GS1 allocation and print quality still require applicable interoperability checks |
| **`3d-tools`** | Large specialist app | Inspect STL, OBJ and glTF; dimensions, bounding boxes, mesh simplification and common geometry defects; worker/GPU use and file limits | | | **`network-tools`** | Current offline v0.2.0 release; dedicated repository | Calculate IP networks and construct or inspect common network values | IPv4/IPv6 canonicalization and CIDR ranges plus largest-first IPv4 VLSM; URL/query parsing with password-redacted display; DNS-record construction and bounded inert zone-file parsing; CSP and HTTP security-header analysis/builder; offline MIME reference | Performs no DNS, URL, HTTP or scanning request; pasted values remain bounded and inert; results are construction and calculation aids rather than live network observations |
| **`midi-tools`** | Medium specialist app | MIDI event inspection and editing; transpose, quantize, tempo maps and controller data; device access should remain optional | | | **`geo-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, edit, convert, simplify, measure and analyse geospatial files locally | GeoJSON, GPX, KML and coordinate CSV; full Point/MultiPoint/LineString/MultiLineString/Polygon/MultiPolygon/nested GeometryCollection model; bounds, distance, elevation and simplification; validated feature/coordinate edits and line reversal; track inventory; local distance/bearing/midpoint ruler; coordinate-only sketch | Full geometry model retains scalar properties, while GPX/CSV conversion may flatten grouping; no topology repair, CRS transformation, basemap request or survey-grade claim; conversion losses are explicit |
| **`device-tools`** | Specialist app | Serial terminal, sensor logger, microcontroller console and controlled firmware installation; explicit permission and browser-capability checks | | | **`helper-tools`** | Current v0.2.0 app and reusable package release | Encode, convert, inspect and calculate with shared bounded primitives | Base/byte/text/URL conversion; byte cursors and streams; worker jobs; Unicode and line transforms; exact numbers and units; incremental hashes; CIDR; timestamps; hardened JSON/CSV; random primitives | Framework-free `@add-ideas/toolbox-helpers` 0.2.0 is consumed directly across the Toolbox app set; operations disclose strictness, limits and non-cryptographic seeded boundaries |
| **`helpers-tools`** | One shared artifact with deep links | Stateless Base64, URL encoding, case conversion, number bases, Unicode code points, unit conversion, small checksums, subnet and timestamp calculators; avoid a repository per calculator | | | **`rand-tools`** | Local-only v0.2.0 release; dedicated repository | Generate secure or reproducible random values through focused workspaces | Unbiased WebCrypto integers/strings; explicitly seeded deterministic generation; UUIDv4/v7 and ULID; dice, samples, shuffles, passphrases and normal values; local coin/card/date/fraction/spherical draws; exact weighted sampling; versioned executable recipes; domain-separated WebCrypto commitreveal | Secure local generation never falls back; deterministic output is reproducible rather than secret; the app makes no third-party randomness request and the Portal grants it no external network destination |
| **`data-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, format, query, infer schemas, edit, flatten and convert structured data | JSON, YAML 1.2, TOML, XML, CSV, TSV and NDJSON; exact JSON numbers; bounded tree/table/leaf views; JSON Pointer and safe path queries; sample-labelled JSON Schema inference; reviewed immutable RFC 6901 add/replace/remove edits; registry-backed all-format conversion with loss/coercion reports; spreadsheet-formula neutralization | Disposable bounded parser worker; rejects active XML constructs and unsafe object keys; conversions surface information loss and round-trip instability |
| **`diff-tools`** | Current local-first v0.2.0 release; dedicated repository | Compare text and structured files, build directory manifests and perform bounded three-way merges locally | Line/word/code-point/grapheme text diff; exact-number JSON/RFC 6902; namespace-aware XML; keyed CSV/TSV; bounded directory manifests; line-oriented three-way merge; portable reports | Normalization and ignored distinctions remain visible; bounded disposable workers and two-file hash concurrency; directory comparison is manifest-based and merge conflicts remain explicit |
| **`time-tools`** | Current local-first v0.2.0 release; dedicated repository | Convert exact timestamps, plan across IANA zones and inspect calendars, arithmetic and recurrences locally | Signed nanosecond epochs; IANA-zone/DST comparison; wall-clock versus elapsed arithmetic; explicit Unix/Vixie, GitHub Actions, Croner, Quartz and EventBridge cron dialects; RRULE previews; multi-participant meeting planner; business days; bounded ICS import with EXDATE/RDATE and recurrence overrides; escaped UTC ICS export | Uses browser IANA data and does not model leap seconds; holidays are only those supplied; custom VTIMEZONE data is inventoried but not guessed when the browser cannot execute its TZID; URLs and attachments are inert and never fetched |
| **`text-tools`** | Current local-first v0.2.0 release; dedicated repository | Build ordered, inspectable plain-text transformation pipelines | Ordered literal/line transforms; locale case, Unicode normalization and transliteration; strict escape/decode stages; RFC-style quoted CSV column selection; explicit UTF-8, UTF-16LE and Latin-1 file decoding with byte evidence; versioned recipes and paired text/provenance artifacts | Exact source/result and per-step changes stay visible; compatibility normalization, transliteration, narrow encodings and selected transforms may be lossy; artifact evidence is portable, but automatic Toolbox handoff is not enabled in v0.2.0 |
| **`unicode-tools`** | Current local-first v0.2.0 release; dedicated repository | Search and inspect Unicode characters, emoji, scripts, text and confusables | Checksum-pinned Unicode 17.0 catalogue and virtualized code chart; name/alias/code-point search; emoji and named sequences with bounded variant construction; UTF-8/UTF-16/escape and grapheme inspection; normalization; UTS #39 skeleton, Identifier_Status/Type and restriction evidence; selected Unihan fields; source-identity reports | Official Unicode data is redistributed under Unicode-3.0 and no symbol-site content is scraped; unassigned, private-use, noncharacter and surrogate states remain distinct; constructed emoji do not claim RGI or font support; confusable/mixed-script findings are review signals, not verdicts |
| **`flow-tools`** | Current local-first v0.2.0 release; dedicated repository | Design and run deterministic structured-data pipelines | Bounded JSON, CSV, NDJSON and XML sources; select, rename, filter, map, sort, group, join, type-format, deduplicate, slice and explode stages; per-stage snapshots, analysis and loss notes; validated recipes plus JSON/CSV and inert pipeline-SVG export | 2 MiB sources, 10,000 input rows, 20,000 expanded rows, 200 fields, 30 stages and 256 KiB recipes; no arbitrary code, eval, plug-in or network stage |
| **`subtitle-tools`** | Current local-first v0.2.0 release; dedicated repository | Edit, validate, synchronize, compare and convert captions | Bounded SRT, WebVTT, ASS/SSA and flat-profile TTML/DFXP; cue add/duplicate/split/merge/delete and direct editing; shift/stretch, frame-rate conversion and snapping; editorial diagnostics and index-aligned revision comparison; immediate media playback with cancellable waveform extraction and navigation; explicit UTF-8/UTF-16/Latin-1 decoding | Preserves format-specific data where possible and reports conversion loss; TTML regions, styling definitions and inherited timing are inspected but not laid out and export is a flat resource-free profile; no speech recognition, automatic alignment, semantic comparison or pixel-perfect ASS rendering |
| **`midi-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, visualize, edit, audition and export Standard MIDI files | SMF type 0/1 events and metadata; worker-backed edit/export with progress and cancellation; tempo/time/key timelines and piano roll; paired-note editing, transpose, quantize, tempo, crop and channel operations with undo; Web Audio preview and opt-in Web MIDI output; bounded local SF2/SF3 structure inspection; deterministic MIDI/CSV/JSON export | 8 MiB, 256-track and 250,000-event caps; PPQN only; Web MIDI is permission-gated and sends channel-note messages without SysEx; SF2/SF3 is inspection-only; the bounded sine preview is not a General MIDI or SoundFont renderer |
| **`3d-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, preview, repair and export triangle meshes | OBJ, binary/ASCII STL, ASCII/binary little-endian PLY and embedded glTF/GLB 2; selected-scene hierarchy/TRS/matrix and instancing flattening; material, node, bounds, area and degeneracy statistics; worker-backed capped WebGL preview; normal/degeneracy repair and transform baking; normalized OBJ, binary STL and ASCII PLY export | 32 MiB and one-million-vertex/triangle caps; glTF is an embedded FLOAT POSITION/NORMAL TRIANGLES subset with no external assets, sparse accessors, animation, skins, texture or compression decoding; selected-scene transforms and instances are baked into the normalized mesh, but source material graphs are not recreated |
| **`query-tools`** | Current local-first v0.2.0 release; dedicated repository | Query structured local data through bounded row and path languages | JSON, CSV, NDJSON and inert XML with raw inference/precision evidence; parsed SQL/path syntax trees; focused SQL-like and JSONPath-like modes; optional bounded read-only DuckDB-WASM SELECT mode; table/tree/JSON views, saved queries and JSON/CSV/NDJSON export | Built-in modes are not full SQL, JSONPath or JMESPath; 2 MiB/10,000-row-or-value/200-field/200,000-node caps; DuckDB is capped at 10,000 input and 1,000 result rows, 128 MiB, 30-second startup and ten-second queries with no files, network, extensions, DDL/DML, COPY or PRAGMA; hosting needs correct WASM MIME/cache and scoped `wasm-unsafe-eval` |
| **`scan-tools`** | Current local-first v0.2.0 release; dedicated repository | Correct photographed pages, run optional OCR and assemble local output | Up to 24 PNG/JPEG/WebP pages; precise four-corner projective correction, rotate/deskew, tonal, grayscale and threshold controls in cancellable workers with bounded fallback; drag reorder; PNG/JPEG and image-only PDF export; bundled same-origin English OCR with word geometry plus text, hOCR, TSV and combined multipage-text export | Camera is exact-path and opt-in; strict pixel/byte/page caps; English-only OCR may use roughly 100300 MiB transient memory and is not an accuracy guarantee; no PDF input, automatic corners, handwriting guarantee, shadow/finger removal or searchable PDF text layer |
| **`package-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect and compare ZIP/ZIP64 compound packages | ZIP/ZIP64 tree and risk diagnostics; EPUB, OOXML, ODF, JAR, APK and WebExtension adapters; relationships, manifests and orphan evidence; signature-material/digest plus dependency/licence inventories; bounded text/media preview and verified entry download/share; decompressed SHA-256 comparison with safe semantic diffs; JSON inventory export | Inspection is not a specialized renderer, APK signing-block decoder, signature/trust verifier, dependency resolver, vulnerability scanner or legal licence interpretation; encrypted, split, unsafe, unsupported and over-budget entries stay inventory-only; an explicitly chosen OS share target may upload an entry even though the app itself does not |
| **`label-tools`** | Current local-first v0.2.0 release; dedicated repository | Merge local records into printable labels, badges and asset tags | CSV/JSON mapping; visual text/image/barcode/shape template designer with drag, keyboard and exact-unit positioning; serial/random values; ten QR/1D/2D barcode formats; stock/custom grids, calibration and marks; template JSON; paginated preview; physical-size SVG, deterministic multi-page ZIP and vector PDF export | 2 MiB/2,000-row/100-field data bounds and bounded images/pages; stock names are geometries, not vendor certification; system fonts are not embedded; vector PDF uses built-in Helvetica/WinAnsi, visibly replaces unsupported characters and omits WebP images, so it is print-dimensioned rather than archival/press quality |
| **`font-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, compare, preview and prepare local fonts | TTF, OTF, WOFF, WOFF2, TTC and OTC metadata/tables; Unicode coverage, variable axes, GSUB/GPOS inventories and embedding signals; sandboxed shaping/fallback comparison; safe CSS; validated collection face selection; rights-confirmed static glyf/CFF subset with loss report | Disposable eight-second worker and strict file/table/face/glyph bounds; WOFF2 is decoded locally and TTC/OTC faces are reconstructed only for bounded in-memory preview; WOFF2/collection subset and collection export remain disabled; fsType is technical evidence, variable flattening is unsupported and browser shaping is not a HarfBuzz reference |
| **`fixture-tools`** | Current local-first v0.2.0 release; dedicated repository | Generate deterministic synthetic test fixtures | Focused JSON Schema, SQL DDL, XSD, CSV-heading and portable-model import; scalar/composite uniqueness and foreign keys; dependency-topological generation with uniform, sequential, normal and constant distributions; boundary/intentional-negative cases and a violation ledger; coverage matrix; person/network/commerce provider profiles; JSON/CSV/NDJSON/XML/SQL/recipe export | Importers are documented subsets; remote references, schema code, database access, XML entities, unsupported XSD/SQL constructs and cyclic relational recipes are rejected; 2 MiB input, 20 tables, 100 fields/table, 50,000 rows, 100,000 values and 8 MiB/output caps; synthetic data is not anonymization and seeded output is reproducible, not secret |
| **`minimize-tools`** | Current local-first v0.2.0 release; dedicated repository | Reduce failing inputs while continuously preserving a selected predicate | Line/token/code-point and structure-aware JSON/XML reduction; literal, syntax, timing, isolated-regex, focused JSON Schema and restricted local-XSLT predicates with failure-signature preservation; single/majority/strict retry voting and flaky evidence; bounded multi-file bundle adapters; budgets, cancellation and deterministic minimized-case/trace/report export | Locally minimal under attempted transformations and budget, not globally shortest; schema/XSLT subsets fail closed; 2 MiB input, 2,000-test/30-second, one-second-regex and 200-XSLT-test caps; bundles are limited to 100 files/4 MiB and adapters neither emulate a filesystem nor execute a target program |
| **`format-lab`** | Current local-first v0.2.0 release; dedicated repository | Explore conversion paths and measure representative round-trip loss | Conservative data/image/AV/subtitle format graphs; executable JSON/NDJSON/CSV/typed-XML record conversion; property selection and loss-aware path ranking; measured round trips beside catalogue expectations; versioned evidence envelopes and catalogue export; masked-offset signature catalogue; deterministic edge-case corpus and reports | Media graphs plan handoffs rather than duplicate codecs; executable conversion handles bounded canonical records, not arbitrary documents; catalogue expectations are not file measurements; signature hits suggest likely containers but do not validate safety or format conformance; catalogue-only formats are not claimed executable |
| **`repro-tools`** | Current local-first v0.2.0 release; dedicated repository | Build, compare, sign and package reproducible local file inventories | Cancellable streamed SHA-256/SHA-512 manifests with deterministic paths and JSON/CSV; reference comparison; fixed-metadata ZIP; streaming ZIP-content manifests and compression-independent comparison; CycloneDX/SPDX/npm-lock inventory with portable evidence and explicit non-conformant local provenance; optional memory-only P-256 signature envelope | 64 MiB/file, 256 MiB/selection and 10,000-entry caps; ZIP creation is capped at 128 MiB and inspection at 5,000 entries/256 MiB expanded; signatures prove integrity only relative to an independently authenticated public key and are intentionally not byte-reproducible; provenance makes no SLSA, authority or builder-identity claim |
| **`schema-tools`** | Hardened local-first v0.2.0 release; dedicated repository | Inspect, validate, compare and derive examples from schema workspaces | Local JSON Schema/OpenAPI reference graphs; focused JSON Schema validation; OpenAPI operation/sample/change analysis; XSD, Relax NG and Schematron structure; bounded heuristic XML samples | Remote/escaping references, entities, code and XPath are blocked; JSON Schema/OpenAPI support is a documented subset, while XML languages receive structural inspection rather than complete instance validation; shared work/node/depth/text/output budgets, linear cached traversal and well-formed XML fallbacks are regression-tested |
| **`log-tools`** | Current local-first v0.2.0 release; dedicated repository | Stream, inspect, correlate, redact and export large logs | Incremental UTF-8 file scan; plain, JSONL, nginx, syslog and logfmt parsing with configurable Java/.NET/Python/generic multiline assembly; whole-scan aggregates; bounded multi-file temporal merge with trace/span and request-ID correlation; search/filter and deterministic redaction; OTLP/JSON plus CSV/NDJSON/text export, including a complete cancellable second pass with direct-file or bounded-Blob output | Eight-GiB gate and five-million-line stop without whole-file strings; interactive search/redaction uses a 10,000-record/32 MiB preview, while complete single-source export reapplies filters and recipes in a second streaming pass; multi-file correlation uses explicitly bounded retained evidence; parsers and auto/multiline detection are focused heuristics |
| **`binary-tools`** | Current local-first v0.2.0 release; dedicated repository | Convert, inspect and decode byte-oriented values | Synchronized UTF-8/hex/Base64/Base32/Base58; checked byte insert/replace/delete; exact and wildcard search with isolated-worker deadline; paged byte inspector and endian integer/float/timestamp interpretations; bookmarks and audited PNG/RIFF/ELF templates; CBOR, MessagePack, strict DER and schema-assisted Protocol Buffers | Inert with no requests or evaluation; 1 MiB input/edit/output, depth-64 and 10,000-node bounds; Base58 and search patterns are capped at 4 KiB and large searches use a five-second disposable worker; protobuf groups/full generated runtime and complete template-based format validation are out of scope |
| **`git-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect and author Git interchange text without repository access | Unified/git patch inspection and bounded authoring; exact atomic patch apply/reverse against explicit text snapshots with JSON/ZIP export; layered `.gitignore` plus tracked-state explanations; root `.gitattributes` evaluation; SemVer ranges; Conventional Commit build/lint and advisory release plan; changelog normalization | Patch/diff-matrix/snapshot bounds; patch application is exact, atomic and text-only against an explicit local JSON snapshot; ignore analysis models layered supplied global/info/nested sources and tracked state but not command-line excludes or submodule boundaries; attribute macros/nested attribute files are unsupported; no workspace or `.git` access |
| **`api-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect and compare HTTP and event API descriptions and check saved HTTP evidence | Bounded JSON/YAML OpenAPI 3.03.2 and AsyncAPI 2.03.1; local multi-file `$ref`; path/webhook/channel/message navigation; security inventory and focused examples; inert curl/Fetch/Python text; conservative revision comparison; HAR 1.2 coverage and focused contract checks | Never executes HTTP, opens a broker connection, follows URLs, resolves remote references or stores credentials; focused OpenAPI/AsyncAPI/JSON Schema validation, examples and HAR checks are not a complete validator, code generator, HTTP/broker client, proxy or security scanner |
| **`mail-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, thread, compare, download and redact EML/MIME and mbox content | Original-octet EML/MIME with RFC 2047 and RFC 2231 headers; bounded multipart/nested-message tree; Base64/quoted-printable and byte-safe attachments; mbox/mboxrd threading/search; text and opaque sandboxed HTML with safe local CID images; auth-header diagnostics plus pasted-key RSA/Ed25519 DKIM verification; canonical and deeper redacted export | Remote/active HTML is removed and never loaded; no mail/DNS contact, mailbox client, decryption, sender-identity, malware or anonymity claim; local DKIM verification proves preserved bytes only against the pasted key and cannot establish that key's authenticity or currency; canonical/redacted export does not promise byte identity |
| **`calendar-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, repair, merge and reconcile iCalendar data | RFC 5545 events, VTODO, VJOURNAL, VFREEBUSY, VALARM, attendees and VTIMEZONE; recurrence preview with moved/cancelled/duration-changing/RANGE=THISANDFUTURE overrides; duplicate/overlap diagnostics and UID/RECURRENCE-ID merge; canonical repair and DST evidence; agenda/month views, bounded event/task authoring and free-slot planning; lossless jCal and focused JSCalendar Event/Task interchange | 4 MiB plus line/component/event and recurrence-horizon caps; browser IANA data and focused recurrence interpretation are evidence, not a calendar-server implementation; JSCalendar is a focused Event/Task adapter with explicit loss warnings |
| **`contact-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect, edit, convert and reconcile sensitive contacts | vCard 2.1/3/4 parsing, core editing and canonical export; bounded quoted-printable/Base64 decoding plus grouped and unmapped-property preservation; inferred reviewable CSV mapping; jCard and focused JSContact import/export with loss evidence; duplicate candidates, conflict-reporting merge and bounded local vCard QR | 2 MiB, 50,000-line and 2,000-contact caps; encoded properties are capped at 64 KiB and malformed encodings retain raw evidence; JSContact support is focused and unsupported values are surfaced rather than silently lost; no persistence or telemetry |
| **`diagram-tools`** | Current local-first v0.2.0 release; dedicated repository | Author safe deterministic text diagrams and export accessible graphics | Independent script-free flow/graph, sequence, class, state, entity-relationship, mind-map and timeline grammars; line diagnostics, examples, pan/zoom, normalized JSON, deterministic SVG/2× PNG and accessible textual outline/source export | Not full Mermaid or Graphviz; directives, clicks, styles, scripts, HTML and remote assets are rejected; 50,000-character, 2,000-line, 200-node, 500-edge and 20-megapixel caps |
| **`token-tools`** | Current local-first v0.2.0 release; dedicated repository | Edit, validate, theme and export design tokens | DTCG-style nested `$type`/`$value` JSON with inherited groups plus local sets/themes/modes; recursive aliases inside composite values with cycle/missing/type diagnostics; colour, dimension, duration, font, Bézier, stroke, border, transition, gradient, typography, shadow and scalar tokens; W3C-style JSON, CSS, Sass, Android and Swift export with collision and loss reports | The `$sets`/`$themes` wrapper is app-specific; 1 MiB, depth-64, 10,000-token and 50,000-node bounds; malformed wrappers, unknown types and token/group conflicts fail explicitly; Android/Swift composite output requires project conventions and unsupported values are reported as losses |
| **`device-tools`** | Current local-first v0.2.0 release; dedicated repository | Inspect browser capability evidence without creating a device fingerprint | Passive context/display/input/accessibility/media/storage/PWA/API inventory; individually initiated permission, storage, WebGL/WebGPU, media-device, battery, network, camera, microphone and screen probes; pasted Toolbox-manifest compatibility analysis with required/optional/unknown capability evidence; opt-in MediaCapabilities codec lab; deterministic bucketed JSON/CSV reports | No identifier, uniqueness score, high-entropy report, recording, persistence, canvas/audio fingerprint or passive permission prompt; sensitive probes are exact-path and individually initiated, returned tracks stop immediately, and downloaded reports bucket precision; results are capability evidence, not a reliability guarantee |
## Planned tools
No unreleased applications are currently listed here. New ideas should be added
only after their intended local-processing boundary and first honest release
scope are documented; shipped applications remain in the Existing tools table.
## Licensing ## Licensing
+4
View File
@@ -8,6 +8,10 @@ Runtime components used by the portal include:
- React and React DOM — MIT - React and React DOM — MIT
- `@add-ideas/toolbox-contract` — Apache-2.0 - `@add-ideas/toolbox-contract` — Apache-2.0
- `@add-ideas/toolbox-shell-react` — Apache-2.0
- `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities`, and
`@dnd-kit/accessibility` — MIT
- `tslib` — 0BSD
Their complete installed license and notice texts are generated into Their complete installed license and notice texts are generated into
`THIRD_PARTY_LICENSES.txt` for every production build. `THIRD_PARTY_LICENSES.txt` for every production build.
+1 -1
View File
@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Containerfile dockerfile: Containerfile
image: git.add-ideas.de/zemion/toolbox:0.3.0 image: git.add-ideas.de/lotobo/toolbox:0.20.0
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
ports: ports:
+24 -3
View File
@@ -6,9 +6,9 @@ services:
context: . context: .
dockerfile: Containerfile.release dockerfile: Containerfile.release
args: args:
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.3.0}" TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.20.0}"
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-718a3c8b891e66f5601115270f25acaec9dbe1b9640a72c17954353bb10db93e}" TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-450618902a1ee87405da463881e6f51f685691c2081259aff9c25a4aa6fe22ed}"
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.3.0}" image: "git.add-ideas.de/lotobo/toolbox:${TOOLBOX_RELEASE_VERSION:-0.20.0}"
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:
@@ -25,11 +25,32 @@ services:
traefik.enable: "true" traefik.enable: "true"
traefik.docker.network: "${TRAEFIK_NETWORK:-internal}" traefik.docker.network: "${TRAEFIK_NETWORK:-internal}"
traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)" traefik.http.routers.addideas-toolbox.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
traefik.http.routers.addideas-toolbox.priority: 900
traefik.http.routers.addideas-toolbox.entrypoints: websecure traefik.http.routers.addideas-toolbox.entrypoints: websecure
traefik.http.routers.addideas-toolbox.tls: "true" traefik.http.routers.addideas-toolbox.tls: "true"
traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}" traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
traefik.http.routers.addideas-toolbox.service: addideas-toolbox traefik.http.routers.addideas-toolbox.service: addideas-toolbox
traefik.http.routers.addideas-toolbox-http.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"
traefik.http.routers.addideas-toolbox-http.priority: 900
traefik.http.routers.addideas-toolbox-http.entrypoints: web
traefik.http.routers.addideas-toolbox-http.middlewares: addideas-toolbox-https-redirect
traefik.http.routers.addideas-toolbox-http.service: addideas-toolbox
traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.scheme: https
traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.permanent: "true"
traefik.http.services.addideas-toolbox.loadbalancer.server.port: "8080" traefik.http.services.addideas-toolbox.loadbalancer.server.port: "8080"
traefik.http.routers.addideas-auth.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"
traefik.http.routers.addideas-auth.priority: 1000
traefik.http.routers.addideas-auth.entrypoints: websecure
traefik.http.routers.addideas-auth.tls: "true"
traefik.http.routers.addideas-auth.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"
traefik.http.routers.addideas-auth.middlewares: addideas-auth-prefix
traefik.http.routers.addideas-auth.service: addideas-toolbox
traefik.http.routers.addideas-auth-http.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"
traefik.http.routers.addideas-auth-http.priority: 1000
traefik.http.routers.addideas-auth-http.entrypoints: web
traefik.http.routers.addideas-auth-http.middlewares: addideas-toolbox-https-redirect
traefik.http.routers.addideas-auth-http.service: addideas-toolbox
traefik.http.middlewares.addideas-auth-prefix.addprefix.prefix: /apps/auth
networks: networks:
internal: internal:
+45 -4
View File
@@ -1,6 +1,25 @@
map $uri $toolbox_cache_control { map $uri $toolbox_cache_control {
default "no-cache"; default "no-cache";
"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable"; "~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\.[^/]+$" "public, max-age=31536000, immutable";
"~^/apps/[a-z0-9][a-z0-9-]*/vendor/ffmpeg/(?:(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)\.(?:0|[1-9][0-9]*)|0\.12\.10-reviewed-stack5m\.1)/(?:version\.json|(?:st|mt)/ffmpeg-core\.(?:js|wasm)|mt/ffmpeg-core\.worker\.js)$" "public, max-age=31536000, immutable";
}
map $uri $toolbox_resource_policy {
default "same-origin";
"/apps/svg/canvas-frame-controller.js" "cross-origin";
}
map $uri $toolbox_access_control_allow_origin {
default "";
"/apps/svg/canvas-frame-controller.js" "*";
}
map "$host:$uri" $toolbox_permissions_policy {
default "camera=(), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
"~^auth\.toolbox\.add-ideas\.de:" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
"~^[^:]+:/apps/barcode(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
"~^[^:]+:/apps/scan(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";
"~^[^:]+:/apps/device(?:/|$)" "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()";
} }
server { server {
@@ -18,9 +37,10 @@ server {
add_header X-Frame-Options "DENY" always; add_header X-Frame-Options "DENY" always;
add_header Cross-Origin-Opener-Policy "same-origin" always; add_header Cross-Origin-Opener-Policy "same-origin" always;
add_header Cross-Origin-Embedder-Policy "require-corp" always; add_header Cross-Origin-Embedder-Policy "require-corp" always;
add_header Cross-Origin-Resource-Policy "same-origin" always; add_header Cross-Origin-Resource-Policy $toolbox_resource_policy always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()" always; add_header Access-Control-Allow-Origin $toolbox_access_control_allow_origin always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always; add_header Permissions-Policy $toolbox_permissions_policy always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; font-src 'self' data: blob:; connect-src 'self'; worker-src 'self' blob:; manifest-src 'self'" always;
add_header Cache-Control $toolbox_cache_control always; add_header Cache-Control $toolbox_cache_control always;
gzip on; gzip on;
@@ -32,7 +52,28 @@ server {
try_files $uri =404; try_files $uri =404;
} }
location ~* \.(?:css|js|mjs|wasm|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ { location ~* \.wasm$ {
types {
application/wasm wasm;
}
try_files $uri =404;
}
location ~* \.mjs$ {
types {
application/javascript mjs;
}
try_files $uri =404;
}
location ~* ^/apps/scan/ocr/lang/[a-z0-9_-]+\.traineddata\.gz$ {
types {
application/gzip gz;
}
try_files $uri =404;
}
location ~* \.(?:css|js|mjs|woff2?|png|jpe?g|gif|webp|svg|ico|webmanifest)$ {
try_files $uri =404; try_files $uri =404;
} }
+153 -552
View File
@@ -1,15 +1,19 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.1.0", "version": "0.2.23",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.1.0", "version": "0.2.23",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.1.0", "@add-ideas/toolbox-contract": "^0.3.0",
"@add-ideas/toolbox-shell-react": "^0.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
@@ -22,7 +26,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"archiver": "^7.0.1", "archiver": "^8.0.0",
"eslint": "^10.4.0", "eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -40,11 +44,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.1.1", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.1.1/toolbox-contract-0.1.1.tgz", "resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.3.0"
},
"peerDependencies": {
"react": ">=18 <20",
"react-dom": ">=18 <20"
}
},
"node_modules/@adobe/css-tools": { "node_modules/@adobe/css-tools": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -506,6 +523,59 @@
"node": ">=20.19.0" "node": ">=20.19.0"
} }
}, },
"node_modules/@dnd-kit/accessibility": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/core": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-dom": ">=16.8.0"
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"react": ">=16.8.0"
}
},
"node_modules/@dnd-kit/utilities": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
"license": "MIT",
"dependencies": {
"tslib": "^2.0.0"
},
"peerDependencies": {
"react": ">=16.8.0"
}
},
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.11.1", "version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -752,24 +822,6 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"dev": true,
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/gen-mapping": { "node_modules/@jridgewell/gen-mapping": {
"version": "0.3.13", "version": "0.3.13",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
@@ -849,17 +901,6 @@
"url": "https://github.com/sponsors/Boshen" "url": "https://github.com/sponsors/Boshen"
} }
}, },
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5", "version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -1768,6 +1809,7 @@
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=8" "node": ">=8"
} }
@@ -1787,41 +1829,24 @@
} }
}, },
"node_modules/archiver": { "node_modules/archiver": {
"version": "7.0.1", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz", "resolved": "https://registry.npmjs.org/archiver/-/archiver-8.0.0.tgz",
"integrity": "sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ==", "integrity": "sha512-fV1orZfsnPn9BaSByR/qE67rJCLJEy2Ox5bq7nJh+jquWaNh6Sfec75kJ2T6PtdGUbPQlrVoSVCEOa5SdiTQ1g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"archiver-utils": "^5.0.2",
"async": "^3.2.4", "async": "^3.2.4",
"buffer-crc32": "^1.0.0", "buffer-crc32": "^1.0.0",
"readable-stream": "^4.0.0", "is-stream": "^4.0.0",
"readdir-glob": "^1.1.2",
"tar-stream": "^3.0.0",
"zip-stream": "^6.0.1"
},
"engines": {
"node": ">= 14"
}
},
"node_modules/archiver-utils": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz",
"integrity": "sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"glob": "^10.0.0",
"graceful-fs": "^4.2.0",
"is-stream": "^2.0.1",
"lazystream": "^1.0.0", "lazystream": "^1.0.0",
"lodash": "^4.17.15",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"readable-stream": "^4.0.0" "readable-stream": "^4.0.0",
"readdir-glob": "^3.0.0",
"tar-stream": "^3.0.0",
"zip-stream": "^7.0.2"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">=18"
} }
}, },
"node_modules/aria-query": { "node_modules/aria-query": {
@@ -1983,9 +2008,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/baseline-browser-mapping": { "node_modules/baseline-browser-mapping": {
"version": "2.10.43", "version": "2.11.20",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz",
"integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==", "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -2006,22 +2031,22 @@
} }
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.6", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
"integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==", "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2039,11 +2064,11 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.10.42", "baseline-browser-mapping": "^2.11.12",
"caniuse-lite": "^1.0.30001803", "caniuse-lite": "^1.0.30001809",
"electron-to-chromium": "^1.5.389", "electron-to-chromium": "^1.5.402",
"node-releases": "^2.0.51", "node-releases": "^2.0.53",
"update-browserslist-db": "^1.2.3" "update-browserslist-db": "^1.3.0"
}, },
"bin": { "bin": {
"browserslist": "cli.js" "browserslist": "cli.js"
@@ -2088,9 +2113,9 @@
} }
}, },
"node_modules/caniuse-lite": { "node_modules/caniuse-lite": {
"version": "1.0.30001806", "version": "1.0.30001810",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz",
"integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -2118,41 +2143,21 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"dev": true,
"license": "MIT"
},
"node_modules/compress-commons": { "node_modules/compress-commons": {
"version": "6.0.2", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz", "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-7.0.1.tgz",
"integrity": "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg==", "integrity": "sha512-g0S8KAD8qf4+V//pr3BfB1aBnARLXNz2Gx+jmHU0LEriUuoQUOPOulVquHKTJ8+EAIIO7fhseNDr9wK5Q9FKBQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"crc32-stream": "^6.0.0", "crc32-stream": "^7.0.1",
"is-stream": "^2.0.1", "is-stream": "^4.0.0",
"normalize-path": "^3.0.0", "normalize-path": "^3.0.0",
"readable-stream": "^4.0.0" "readable-stream": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">=18"
} }
}, },
"node_modules/convert-source-map": { "node_modules/convert-source-map": {
@@ -2183,9 +2188,9 @@
} }
}, },
"node_modules/crc32-stream": { "node_modules/crc32-stream": {
"version": "6.0.0", "version": "7.0.1",
"resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz", "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-7.0.1.tgz",
"integrity": "sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g==", "integrity": "sha512-IBWsY8xznyQrcHn8h4bC8/4ErNke5elzgG8GcqF4RFPw6aHkWWRc7Tgw6upjaTX/CT/yQgqYENkxYsTYN+hW2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -2193,7 +2198,7 @@
"readable-stream": "^4.0.0" "readable-stream": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">=18"
} }
}, },
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
@@ -2313,27 +2318,13 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"dev": true,
"license": "MIT"
},
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.393", "version": "1.5.420",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz",
"integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==", "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"dev": true,
"license": "MIT"
},
"node_modules/entities": { "node_modules/entities": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
@@ -2709,23 +2700,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"dev": true,
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fsevents": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -2751,28 +2725,6 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
"integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"dev": true,
"license": "ISC",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": { "node_modules/glob-parent": {
"version": "6.0.2", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz",
@@ -2786,39 +2738,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz",
"integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.2"
},
"engines": {
"node": ">=16 || 14 >=14.17"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/globals": { "node_modules/globals": {
"version": "17.7.0", "version": "17.7.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz", "resolved": "https://registry.npmjs.org/globals/-/globals-17.7.0.tgz",
@@ -2832,13 +2751,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"license": "ISC"
},
"node_modules/hermes-estree": { "node_modules/hermes-estree": {
"version": "0.25.1", "version": "0.25.1",
"resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz",
@@ -2937,16 +2849,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-glob": { "node_modules/is-glob": {
"version": "4.0.3", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
@@ -2968,13 +2870,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/is-stream": { "node_modules/is-stream": {
"version": "2.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-4.0.1.tgz",
"integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "integrity": "sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=8" "node": ">=18"
}, },
"funding": { "funding": {
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
@@ -2994,22 +2896,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3474,13 +3360,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/lodash": {
"version": "4.18.1",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "5.1.1", "version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -3545,16 +3424,6 @@
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
} }
}, },
"node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -3563,9 +3432,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.16", "version": "3.3.18",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3589,9 +3458,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/node-releases": { "node_modules/node-releases": {
"version": "2.0.51", "version": "2.0.54",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz",
"integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -3672,13 +3541,6 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/parse5": { "node_modules/parse5": {
"version": "8.0.1", "version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
@@ -3712,30 +3574,6 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.18"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC"
},
"node_modules/pathe": { "node_modules/pathe": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
@@ -3771,9 +3609,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.20", "version": "8.5.26",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.20.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
"integrity": "sha512-lW616l85ucIQL+FocMmL7pQFPqBmwejrCMg+iPxyImlrANNJG9NHq/RkyCZopDhd8C3LA03PHRJDjkbGu8vvug==", "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -3791,7 +3629,7 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"nanoid": "^3.3.16", "nanoid": "^3.3.17",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
"source-map-js": "^1.2.1" "source-map-js": "^1.2.1"
}, },
@@ -3915,43 +3753,19 @@
} }
}, },
"node_modules/readdir-glob": { "node_modules/readdir-glob": {
"version": "1.1.3", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-3.0.0.tgz",
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "integrity": "sha512-AhNB2KgKeVJr16nK9LLZbJNWnYoT23ZrumNKFDebHBdkC8KHSqWo871JAUhoWC/RtjEVdqNMFpM6qrwRbaUqpw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^10.2.2"
}
},
"node_modules/readdir-glob/node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"dev": true,
"license": "MIT"
},
"node_modules/readdir-glob/node_modules/brace-expansion": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/readdir-glob/node_modules/minimatch": {
"version": "5.1.9",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz",
"integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">=10" "node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/yqnn"
} }
}, },
"node_modules/redent": { "node_modules/redent": {
@@ -4092,19 +3906,6 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"dev": true,
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -4151,103 +3952,6 @@
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
}, },
"node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"dev": true,
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/string-width-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.2.2"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/strip-indent": { "node_modules/strip-indent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
@@ -4408,9 +4112,7 @@
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true, "license": "0BSD"
"license": "0BSD",
"optional": true
}, },
"node_modules/type-check": { "node_modules/type-check": {
"version": "0.4.0", "version": "0.4.0",
@@ -4464,9 +4166,9 @@
} }
}, },
"node_modules/undici": { "node_modules/undici": {
"version": "7.28.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -4481,9 +4183,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz",
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@@ -4787,107 +4489,6 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs/node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"dev": true,
"license": "MIT"
},
"node_modules/wrap-ansi-cjs/node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dev": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi-cjs/node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dev": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/xml-name-validator": { "node_modules/xml-name-validator": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
@@ -4939,18 +4540,18 @@
} }
}, },
"node_modules/zip-stream": { "node_modules/zip-stream": {
"version": "6.0.1", "version": "7.0.5",
"resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-7.0.5.tgz",
"integrity": "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==", "integrity": "sha512-dSvYKdvLsAHCDqPOhIwk/q5CvuWtTB3Dgpoe0uVEFjTzIOAmsQpprX25InCvrvJsirEbu1OHyy67n/kAj1Sw/w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"archiver-utils": "^5.0.0", "compress-commons": "^7.0.0",
"compress-commons": "^6.0.2", "normalize-path": "^3.0.0",
"readable-stream": "^4.0.0" "readable-stream": "^4.0.0"
}, },
"engines": { "engines": {
"node": ">= 14" "node": ">=18"
} }
}, },
"node_modules/zod": { "node_modules/zod": {
+17 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.1.0", "version": "0.2.23",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -21,7 +21,11 @@
"package:static": "node scripts/package-static.mjs" "package:static": "node scripts/package-static.mjs"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.1.0", "@add-ideas/toolbox-contract": "^0.3.0",
"@add-ideas/toolbox-shell-react": "^0.3.0",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6" "react-dom": "^19.2.6"
}, },
@@ -34,7 +38,7 @@
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2", "@vitejs/plugin-react": "^6.0.2",
"archiver": "^7.0.1", "archiver": "^8.0.0",
"eslint": "^10.4.0", "eslint": "^10.4.0",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
@@ -49,5 +53,14 @@
}, },
"engines": { "engines": {
"node": ">=22" "node": ">=22"
} },
"repository": {
"type": "git",
"url": "git+https://git.add-ideas.de/lotobo/toolbox-portal.git"
},
"homepage": "https://git.add-ideas.de/lotobo/toolbox-portal",
"bugs": {
"url": "https://git.add-ideas.de/lotobo/toolbox-portal/issues"
},
"packageManager": "npm@11.17.0"
} }
+2 -2
View File
@@ -1,8 +1,8 @@
# Corresponding source # Corresponding source
The source code corresponding to add·ideas Toolbox Portal 0.1.0 is available at: The source code corresponding to add·ideas Toolbox Portal 0.2.23 is available at:
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0 https://git.add-ideas.de/lotobo/toolbox-portal/src/tag/v0.20.0
Each bundled application contains its own `SOURCE.md`, license, and third-party Each bundled application contains its own `SOURCE.md`, license, and third-party
license materials. The application sources are also linked from the portal. license materials. The application sources are also linked from the portal.
+5 -4
View File
@@ -4,8 +4,9 @@ The add·ideas Toolbox portal is licensed under `AGPL-3.0-only`. An assembled
toolbox is a collection of independently licensed works, not a relicensing of toolbox is a collection of independently licensed works, not a relicensing of
every included component under one licence. every included component under one licence.
Portal runtime components include React and React DOM (MIT) and Portal runtime components include React and React DOM (MIT),
`@add-ideas/toolbox-contract` (Apache-2.0). Their complete installed license and `@add-ideas/toolbox-contract` and `@add-ideas/toolbox-shell-react`
notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application remains (Apache-2.0), dnd-kit (MIT), and tslib (0BSD). Their complete installed license
under its own licence. Application notices are shipped inside its and notice texts are shipped in `THIRD_PARTY_LICENSES.txt`. Each application
remains under its own licence. Application notices are shipped inside its
`apps/<slug>/LICENSES/` directory. `apps/<slug>/LICENSES/` directory.
+189 -1
View File
@@ -1,5 +1,5 @@
{ {
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json", "$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox", "name": "add·ideas Toolbox",
@@ -20,6 +20,194 @@
{ {
"manifest": "./apps/onenote/toolbox-app.json", "manifest": "./apps/onenote/toolbox-app.json",
"enabled": true "enabled": true
},
{
"manifest": "./apps/av/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/regex/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/svg/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/auth/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/sudoku/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/colour/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/office/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/image/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/file/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/epub/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/archive/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/privacy/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/crypto/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/barcode/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/network/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/geo/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/helper/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/rand/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/data/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/diff/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/time/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/text/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/unicode/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/flow/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/subtitle/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/midi/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/3d/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/query/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/scan/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/package/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/label/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/font/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/fixture/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/minimize/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/format-lab/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/repro/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/schema/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/log/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/binary/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/git/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/api/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/mail/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/calendar/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/contact/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/diagram/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/token/toolbox-app.json",
"enabled": true
},
{
"manifest": "./apps/device/toolbox-app.json",
"enabled": true
} }
] ]
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://git.add-ideas.de/zemion/toolbox-portal/raw/branch/main/release/toolbox-release-lock.schema.json", "$id": "https://git.add-ideas.de/lotobo/toolbox-portal/raw/branch/main/release/toolbox-release-lock.schema.json",
"title": "add·ideas Toolbox release lock v1", "title": "add·ideas Toolbox release lock v1",
"description": "Pins the exact portal and checksummed application archives used to assemble one static toolbox release.", "description": "Pins the exact portal and checksummed application archives used to assemble one static toolbox release.",
"type": "object", "type": "object",
+340 -11
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "./toolbox-release-lock.schema.json", "$schema": "./toolbox-release-lock.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"releaseVersion": "0.3.0", "releaseVersion": "0.20.0",
"portalVersion": "0.1.0", "portalVersion": "0.2.23",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox", "name": "add·ideas Toolbox",
@@ -15,24 +15,353 @@
"apps": [ "apps": [
{ {
"id": "de.add-ideas.pdf-tools", "id": "de.add-ideas.pdf-tools",
"version": "0.3.4", "version": "0.4.4",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip", "artifact": "https://git.add-ideas.de/lotobo/pdf-tools/releases/download/v0.4.4/pdf-tools-0.4.4.zip",
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", "sha256": "fa93c4c004be74d6a1f68f62a0f64f44c99218f387441ca3630a7f5af3c09d7d",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.3.2", "version": "0.4.3",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip", "artifact": "https://git.add-ideas.de/lotobo/xslt-tools/releases/download/v0.4.3/xslt-tools-0.4.3.zip",
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", "sha256": "e44012af214d24e3d97d10a6017fa3d9dbf063de36673ab04dd15a0a8643afcc",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.2.0", "version": "0.3.4",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip", "artifact": "https://git.add-ideas.de/lotobo/onenote-tools/releases/download/v0.3.4/onenote-tools-0.3.4.zip",
"sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b", "sha256": "c0ed791d1ae64fdf6a90bceebf97a7a3252989f2434204d009f87f5a2d1cc341",
"target": "onenote" "target": "onenote"
},
{
"id": "de.add-ideas.av-tools",
"version": "0.3.0",
"artifact": "https://git.add-ideas.de/lotobo/av-tools/releases/download/v0.3.0/av-tools-0.3.0.zip",
"sha256": "3476c5d43ae43ce66b5aeebe8c11334f7cd4d153586348b72f10bfbdbada9be7",
"target": "av"
},
{
"id": "de.add-ideas.regex-tools",
"version": "0.4.2",
"artifact": "https://git.add-ideas.de/lotobo/regex-tools/releases/download/v0.4.2/regex-tools-0.4.2.zip",
"sha256": "a3e7342d1f0746aa9c88e6568e7fbc04ab429c94604ba5fb1094ffdb096a54fe",
"target": "regex"
},
{
"id": "de.add-ideas.svg-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/svg-tools/releases/download/v0.2.0/svg-tools-0.2.0.zip",
"sha256": "2e9ed259a784a474012fb2df558bf40e97fa76e02f4336ad62ba97e1d1d15ed3",
"target": "svg"
},
{
"id": "de.add-ideas.auth-tools",
"version": "0.3.1",
"artifact": "https://git.add-ideas.de/lotobo/auth-tools/releases/download/v0.3.1/auth-tools-0.3.1.zip",
"sha256": "60e90492fb50d2ce23298b3a3af84b1e3be02bbf5dea01d478f889d65eb66f3a",
"target": "auth"
},
{
"id": "de.add-ideas.sudoku-tools",
"version": "0.2.1",
"artifact": "https://git.add-ideas.de/lotobo/sudoku-tools/releases/download/v0.2.1/sudoku-tools-0.2.1.zip",
"sha256": "3987813a3bcd24056b9ca9607ba5d74a094d54c15b0e8a7966df7373e87df67a",
"target": "sudoku"
},
{
"id": "de.add-ideas.colour-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/colour-tools/releases/download/v0.2.0/colour-tools-0.2.0.zip",
"sha256": "4eca3968f6b93b20dbd8878addb916bd7acf0f5941e168c012ee2a87d3e16e03",
"target": "colour"
},
{
"id": "de.add-ideas.office-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/office-tools/releases/download/v0.2.0/office-tools-0.2.0.zip",
"sha256": "95f288b9b1aebdf446a358aa0e72035a2c9bd344b012bdc0facd62bb41c6df72",
"target": "office"
},
{
"id": "de.add-ideas.image-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/image-tools/releases/download/v0.2.0/image-tools-0.2.0.zip",
"sha256": "997922a0ea9151f3f9de13f6f1b1f5abd2b2b0bf47faf411573d915d28a066b8",
"target": "image"
},
{
"id": "de.add-ideas.file-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/file-tools/releases/download/v0.2.0/file-tools-0.2.0.zip",
"sha256": "c6b90e14fe8c37e799ee461eb28489f91e01e116d28fdb323fa3616b834915c4",
"target": "file"
},
{
"id": "de.add-ideas.epub-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/epub-tools/releases/download/v0.2.0/epub-tools-0.2.0.zip",
"sha256": "ac3f3758b11f243dc0b785d25e6b839a12f0d9f90ec6abdfb1ad5de1730cbd27",
"target": "epub"
},
{
"id": "de.add-ideas.archive-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/archive-tools/releases/download/v0.2.0/archive-tools-0.2.0.zip",
"sha256": "dd9016c550c01fa07591636dadfa8be02f4dba8e6592e02a0941e70f73fc10b7",
"target": "archive"
},
{
"id": "de.add-ideas.privacy-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/privacy-tools/releases/download/v0.2.0/privacy-tools-0.2.0.zip",
"sha256": "88af2ae6172651de0be4bf45d90e7fddbaac51c55ef34dfeaa66f5913ca43c85",
"target": "privacy"
},
{
"id": "de.add-ideas.crypto-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/crypto-tools/releases/download/v0.2.0/crypto-tools-0.2.0.zip",
"sha256": "8611dec2937765b0fe59a9f457ed81231856484c7c0a06da34b1f24b66f0b53a",
"target": "crypto"
},
{
"id": "de.add-ideas.barcode-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/barcode-tools/releases/download/v0.2.0/barcode-tools-0.2.0.zip",
"sha256": "9180f6a9a38786018ef27db70301e7e8b92f5ad2132601297e4e98e291a977d7",
"target": "barcode"
},
{
"id": "de.add-ideas.network-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/network-tools/releases/download/v0.2.0/network-tools-0.2.0.zip",
"sha256": "9b00eac98c2263a1fc6b34291c1d00bee0b5ac172f449da4bf44de395b754608",
"target": "network"
},
{
"id": "de.add-ideas.geo-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/geo-tools/releases/download/v0.2.0/geo-tools-0.2.0.zip",
"sha256": "d028a4bcc1b2189bedc395bf67dcb07de9f19ee175a7443868385e082721b5ef",
"target": "geo"
},
{
"id": "de.add-ideas.helper-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/helper-tools/releases/download/v0.2.0/helper-tools-0.2.0.zip",
"sha256": "a6197310da3a4d471fe975e0ee18da3bc7f9b4c8264399cff6eb609cf9e6d12d",
"target": "helper"
},
{
"id": "de.add-ideas.rand-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/rand-tools/releases/download/v0.2.0/rand-tools-0.2.0.zip",
"sha256": "6d31426ae5d55b3cc2ee7a71684818e00cdbecc33c6dae1a9bdfadbcfeec188d",
"target": "rand"
},
{
"id": "de.add-ideas.data-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/data-tools/releases/download/v0.2.0/data-tools-0.2.0.zip",
"sha256": "bb3fe1e60b462ed82780cb806e030ab000cfc108998b86b7f247150ed7ba889f",
"target": "data"
},
{
"id": "de.add-ideas.diff-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/diff-tools/releases/download/v0.2.0/diff-tools-0.2.0.zip",
"sha256": "be2efe547c13521dc67f5101a7669ee3aa0c83bb6637300832f866da7bca6de1",
"target": "diff"
},
{
"id": "de.add-ideas.time-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/time-tools/releases/download/v0.2.0/time-tools-0.2.0.zip",
"sha256": "31ec1e460a1c1a566a73cbe04933df308908b013e973352b27b543e05050dc14",
"target": "time"
},
{
"id": "de.add-ideas.text-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/text-tools/releases/download/v0.2.0/text-tools-0.2.0.zip",
"sha256": "a1b635278da1baf844051fb0bbfc94022596bb674091c4c39ec164cf34bd5f62",
"target": "text"
},
{
"id": "de.add-ideas.unicode-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/unicode-tools/releases/download/v0.2.0/unicode-tools-0.2.0.zip",
"sha256": "35ca2c7dcbcc651fdd6910db67b72f2f440b8e5f0b6cdf434925a46efe1f10b6",
"target": "unicode"
},
{
"id": "de.add-ideas.flow-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/flow-tools/releases/download/v0.2.0/flow-tools-0.2.0.zip",
"sha256": "0172681d0d4361450a63981ac6b9cf3ccdb535695631874fb5d4ddf83e33e297",
"target": "flow"
},
{
"id": "de.add-ideas.subtitle-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/subtitle-tools/releases/download/v0.2.0/subtitle-tools-0.2.0.zip",
"sha256": "60a0367eb7d87bb5934d59020b6cf0eb71595efcf564e99248bdfd993ec465ca",
"target": "subtitle"
},
{
"id": "de.add-ideas.midi-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/midi-tools/releases/download/v0.2.0/midi-tools-0.2.0.zip",
"sha256": "2286ad471fd1e946e089797ef0bf56ddddfef833261ea52965c1a5bd6475fd81",
"target": "midi"
},
{
"id": "de.add-ideas.3d-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/3d-tools/releases/download/v0.2.0/3d-tools-0.2.0.zip",
"sha256": "912a2a398c5f88099638fba8ca2687b41eb5f4e5a66d1ee5bb5fa9a078c23012",
"target": "3d"
},
{
"id": "de.add-ideas.query-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/query-tools/releases/download/v0.2.0/query-tools-0.2.0.zip",
"sha256": "398e0954cded4f20200d8b0d9349b61b86d46b6f4080e13ae469c0352ad96a90",
"target": "query"
},
{
"id": "de.add-ideas.scan-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/scan-tools/releases/download/v0.2.0/scan-tools-0.2.0.zip",
"sha256": "836f93d248ceb052088826dde2fccaa31f17b8852039ac8ebdaf4e7d9c1927c0",
"target": "scan"
},
{
"id": "de.add-ideas.package-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/package-tools/releases/download/v0.2.0/package-tools-0.2.0.zip",
"sha256": "2f31ac3937901ab6a50d002cafbd294229cb5d6e1a18b9021b4ba345c28ee892",
"target": "package"
},
{
"id": "de.add-ideas.label-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/label-tools/releases/download/v0.2.0/label-tools-0.2.0.zip",
"sha256": "706d4bf3e4d802d06e5dcb089f67fdae56bbb250aedbe1b9f8e5b0c2fd997869",
"target": "label"
},
{
"id": "de.add-ideas.font-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/font-tools/releases/download/v0.2.0/font-tools-0.2.0.zip",
"sha256": "3257605518cbdfbe46b5ee4b8f3da6245ef97fc17b884cf97817f24ef0e765ea",
"target": "font"
},
{
"id": "de.add-ideas.fixture-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/fixture-tools/releases/download/v0.2.0/fixture-tools-0.2.0.zip",
"sha256": "918f322e2607bc1b5bcf4ddf33de88ef3cf1611d8aa3b218f2a70a7b967b3c00",
"target": "fixture"
},
{
"id": "de.add-ideas.minimize-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/minimize-tools/releases/download/v0.2.0/minimize-tools-0.2.0.zip",
"sha256": "b0fa1783acdb431ae56aeab9d5d8364492b833caa69ad557f48c26ee7e915c4f",
"target": "minimize"
},
{
"id": "de.add-ideas.format-lab",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/format-lab/releases/download/v0.2.0/format-lab-0.2.0.zip",
"sha256": "05a796584318a8bbfd651af80066136da46a533e19b37c18fb9f7fe3b00fe0f9",
"target": "format-lab"
},
{
"id": "de.add-ideas.repro-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/repro-tools/releases/download/v0.2.0/repro-tools-0.2.0.zip",
"sha256": "6d3062fbf9bd686f7b320239559b996f64ff0ad29740bfe37eaad02e307bbda8",
"target": "repro"
},
{
"id": "de.add-ideas.schema-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/schema-tools/releases/download/v0.2.0/schema-tools-0.2.0.zip",
"sha256": "ce0b72851d71e9b8b1ae451220f79a91c23c7c8ce621d021c6b7da50741b917a",
"target": "schema"
},
{
"id": "de.add-ideas.log-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/log-tools/releases/download/v0.2.0/log-tools-0.2.0.zip",
"sha256": "92e1e038efdfea5b605f69d84061489365d8e72f6a9cbe7800624325dc091d1d",
"target": "log"
},
{
"id": "de.add-ideas.binary-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/binary-tools/releases/download/v0.2.0/binary-tools-0.2.0.zip",
"sha256": "64b1c4c3ea8a05b3c193325d405180505b645b14ddb7e80d27af3cb8ef400711",
"target": "binary"
},
{
"id": "de.add-ideas.git-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/git-tools/releases/download/v0.2.0/git-tools-0.2.0.zip",
"sha256": "d5f2bd66f5aabdbbbdc853f460580ce276c3a51ef671297cbb6a88e3ad7d9035",
"target": "git"
},
{
"id": "de.add-ideas.api-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/api-tools/releases/download/v0.2.0/api-tools-0.2.0.zip",
"sha256": "81ad271a211fbaca3484b82b0a33aa145fd5863f6a0a53b544a6854016fbc114",
"target": "api"
},
{
"id": "de.add-ideas.mail-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/mail-tools/releases/download/v0.2.0/mail-tools-0.2.0.zip",
"sha256": "9701a3a1e55f0dd832fa78c808757fb1dd3ba9582a65c3692b376f15f3c62b7c",
"target": "mail"
},
{
"id": "de.add-ideas.calendar-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/calendar-tools/releases/download/v0.2.0/calendar-tools-0.2.0.zip",
"sha256": "89f9813ee2cbb9f89e9e6c60084cc497eb3211f76bb9d8bb4fd4bdfc85d118a9",
"target": "calendar"
},
{
"id": "de.add-ideas.contact-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/contact-tools/releases/download/v0.2.0/contact-tools-0.2.0.zip",
"sha256": "bacd7d0c18e3250226f7f171919a0de11860bca3a558fab73bb8790ee254ec0b",
"target": "contact"
},
{
"id": "de.add-ideas.diagram-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/diagram-tools/releases/download/v0.2.0/diagram-tools-0.2.0.zip",
"sha256": "971c530fca8f310ea0ebd06a9e10ecf6a194631c9a843df1c1f407f4d6213541",
"target": "diagram"
},
{
"id": "de.add-ideas.token-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/token-tools/releases/download/v0.2.0/token-tools-0.2.0.zip",
"sha256": "8c78980f2869fb2e7ac3f16f8a23694e526ae1649a14163e482636a364d45ab8",
"target": "token"
},
{
"id": "de.add-ideas.device-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/device-tools/releases/download/v0.2.0/device-tools-0.2.0.zip",
"sha256": "f1dbcbd51bdbc07b3d3860fb5a3dab081db87e74f21a1240d82584dbb70d101c",
"target": "device"
} }
] ]
} }
+347 -11
View File
@@ -1,8 +1,8 @@
{ {
"$schema": "./toolbox-release-lock.schema.json", "$schema": "./toolbox-release-lock.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"releaseVersion": "0.3.0", "releaseVersion": "0.20.1",
"portalVersion": "0.1.0", "portalVersion": "0.2.23",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox", "name": "add·ideas Toolbox",
@@ -15,24 +15,360 @@
"apps": [ "apps": [
{ {
"id": "de.add-ideas.pdf-tools", "id": "de.add-ideas.pdf-tools",
"version": "0.3.4", "version": "0.4.4",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.3.4/pdf-tools-0.3.4.zip", "artifact": "https://git.add-ideas.de/lotobo/pdf-tools/releases/download/v0.4.4/pdf-tools-0.4.4.zip",
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", "sha256": "fa93c4c004be74d6a1f68f62a0f64f44c99218f387441ca3630a7f5af3c09d7d",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.3.2", "version": "0.4.3",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.3.2/xslt-tools-0.3.2.zip", "artifact": "https://git.add-ideas.de/lotobo/xslt-tools/releases/download/v0.4.3/xslt-tools-0.4.3.zip",
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", "sha256": "e44012af214d24e3d97d10a6017fa3d9dbf063de36673ab04dd15a0a8643afcc",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.2.0", "version": "0.3.4",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.0/onenote-tools-0.2.0.zip", "artifact": "https://git.add-ideas.de/lotobo/onenote-tools/releases/download/v0.3.4/onenote-tools-0.3.4.zip",
"sha256": "660c8c53bfd2f21875d41910d208d6638995f8dc31ba150ffa57d5591f56b76b", "sha256": "c0ed791d1ae64fdf6a90bceebf97a7a3252989f2434204d009f87f5a2d1cc341",
"target": "onenote" "target": "onenote"
},
{
"id": "de.add-ideas.av-tools",
"version": "0.3.0",
"artifact": "https://git.add-ideas.de/lotobo/av-tools/releases/download/v0.3.0/av-tools-0.3.0.zip",
"sha256": "3476c5d43ae43ce66b5aeebe8c11334f7cd4d153586348b72f10bfbdbada9be7",
"target": "av"
},
{
"id": "de.add-ideas.regex-tools",
"version": "0.4.2",
"artifact": "https://git.add-ideas.de/lotobo/regex-tools/releases/download/v0.4.2/regex-tools-0.4.2.zip",
"sha256": "a3e7342d1f0746aa9c88e6568e7fbc04ab429c94604ba5fb1094ffdb096a54fe",
"target": "regex"
},
{
"id": "de.add-ideas.svg-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/svg-tools/releases/download/v0.2.0/svg-tools-0.2.0.zip",
"sha256": "2e9ed259a784a474012fb2df558bf40e97fa76e02f4336ad62ba97e1d1d15ed3",
"target": "svg"
},
{
"id": "de.add-ideas.auth-tools",
"version": "0.3.1",
"artifact": "https://git.add-ideas.de/lotobo/auth-tools/releases/download/v0.3.1/auth-tools-0.3.1.zip",
"sha256": "60e90492fb50d2ce23298b3a3af84b1e3be02bbf5dea01d478f889d65eb66f3a",
"target": "auth"
},
{
"id": "de.add-ideas.sudoku-tools",
"version": "0.2.1",
"artifact": "https://git.add-ideas.de/lotobo/sudoku-tools/releases/download/v0.2.1/sudoku-tools-0.2.1.zip",
"sha256": "3987813a3bcd24056b9ca9607ba5d74a094d54c15b0e8a7966df7373e87df67a",
"target": "sudoku"
},
{
"id": "de.add-ideas.colour-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/colour-tools/releases/download/v0.2.0/colour-tools-0.2.0.zip",
"sha256": "4eca3968f6b93b20dbd8878addb916bd7acf0f5941e168c012ee2a87d3e16e03",
"target": "colour"
},
{
"id": "de.add-ideas.office-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/office-tools/releases/download/v0.2.0/office-tools-0.2.0.zip",
"sha256": "95f288b9b1aebdf446a358aa0e72035a2c9bd344b012bdc0facd62bb41c6df72",
"target": "office"
},
{
"id": "de.add-ideas.image-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/image-tools/releases/download/v0.2.0/image-tools-0.2.0.zip",
"sha256": "997922a0ea9151f3f9de13f6f1b1f5abd2b2b0bf47faf411573d915d28a066b8",
"target": "image"
},
{
"id": "de.add-ideas.file-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/file-tools/releases/download/v0.2.0/file-tools-0.2.0.zip",
"sha256": "c6b90e14fe8c37e799ee461eb28489f91e01e116d28fdb323fa3616b834915c4",
"target": "file"
},
{
"id": "de.add-ideas.epub-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/epub-tools/releases/download/v0.2.0/epub-tools-0.2.0.zip",
"sha256": "ac3f3758b11f243dc0b785d25e6b839a12f0d9f90ec6abdfb1ad5de1730cbd27",
"target": "epub"
},
{
"id": "de.add-ideas.archive-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/archive-tools/releases/download/v0.2.0/archive-tools-0.2.0.zip",
"sha256": "dd9016c550c01fa07591636dadfa8be02f4dba8e6592e02a0941e70f73fc10b7",
"target": "archive"
},
{
"id": "de.add-ideas.privacy-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/privacy-tools/releases/download/v0.2.0/privacy-tools-0.2.0.zip",
"sha256": "88af2ae6172651de0be4bf45d90e7fddbaac51c55ef34dfeaa66f5913ca43c85",
"target": "privacy"
},
{
"id": "de.add-ideas.crypto-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/crypto-tools/releases/download/v0.2.0/crypto-tools-0.2.0.zip",
"sha256": "8611dec2937765b0fe59a9f457ed81231856484c7c0a06da34b1f24b66f0b53a",
"target": "crypto"
},
{
"id": "de.add-ideas.barcode-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/barcode-tools/releases/download/v0.2.0/barcode-tools-0.2.0.zip",
"sha256": "9180f6a9a38786018ef27db70301e7e8b92f5ad2132601297e4e98e291a977d7",
"target": "barcode"
},
{
"id": "de.add-ideas.network-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/network-tools/releases/download/v0.2.0/network-tools-0.2.0.zip",
"sha256": "9b00eac98c2263a1fc6b34291c1d00bee0b5ac172f449da4bf44de395b754608",
"target": "network"
},
{
"id": "de.add-ideas.geo-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/geo-tools/releases/download/v0.2.0/geo-tools-0.2.0.zip",
"sha256": "d028a4bcc1b2189bedc395bf67dcb07de9f19ee175a7443868385e082721b5ef",
"target": "geo"
},
{
"id": "de.add-ideas.helper-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/helper-tools/releases/download/v0.2.0/helper-tools-0.2.0.zip",
"sha256": "a6197310da3a4d471fe975e0ee18da3bc7f9b4c8264399cff6eb609cf9e6d12d",
"target": "helper"
},
{
"id": "de.add-ideas.rand-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/rand-tools/releases/download/v0.2.0/rand-tools-0.2.0.zip",
"sha256": "6d31426ae5d55b3cc2ee7a71684818e00cdbecc33c6dae1a9bdfadbcfeec188d",
"target": "rand"
},
{
"id": "de.add-ideas.data-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/data-tools/releases/download/v0.2.0/data-tools-0.2.0.zip",
"sha256": "bb3fe1e60b462ed82780cb806e030ab000cfc108998b86b7f247150ed7ba889f",
"target": "data"
},
{
"id": "de.add-ideas.diff-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/diff-tools/releases/download/v0.2.0/diff-tools-0.2.0.zip",
"sha256": "be2efe547c13521dc67f5101a7669ee3aa0c83bb6637300832f866da7bca6de1",
"target": "diff"
},
{
"id": "de.add-ideas.time-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/time-tools/releases/download/v0.2.0/time-tools-0.2.0.zip",
"sha256": "31ec1e460a1c1a566a73cbe04933df308908b013e973352b27b543e05050dc14",
"target": "time"
},
{
"id": "de.add-ideas.text-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/text-tools/releases/download/v0.2.0/text-tools-0.2.0.zip",
"sha256": "a1b635278da1baf844051fb0bbfc94022596bb674091c4c39ec164cf34bd5f62",
"target": "text"
},
{
"id": "de.add-ideas.translator-tools",
"version": "0.1.0",
"artifact": "https://git.add-ideas.de/lotobo/translator-tools/releases/download/v0.1.0/translator-tools-0.1.0.zip",
"sha256": "8f18e7c629a51ed628e642945374f731b14e97168098318a2331bb419eceee8d",
"target": "translator"
},
{
"id": "de.add-ideas.unicode-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/unicode-tools/releases/download/v0.2.0/unicode-tools-0.2.0.zip",
"sha256": "35ca2c7dcbcc651fdd6910db67b72f2f440b8e5f0b6cdf434925a46efe1f10b6",
"target": "unicode"
},
{
"id": "de.add-ideas.flow-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/flow-tools/releases/download/v0.2.0/flow-tools-0.2.0.zip",
"sha256": "0172681d0d4361450a63981ac6b9cf3ccdb535695631874fb5d4ddf83e33e297",
"target": "flow"
},
{
"id": "de.add-ideas.subtitle-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/subtitle-tools/releases/download/v0.2.0/subtitle-tools-0.2.0.zip",
"sha256": "60a0367eb7d87bb5934d59020b6cf0eb71595efcf564e99248bdfd993ec465ca",
"target": "subtitle"
},
{
"id": "de.add-ideas.midi-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/midi-tools/releases/download/v0.2.0/midi-tools-0.2.0.zip",
"sha256": "2286ad471fd1e946e089797ef0bf56ddddfef833261ea52965c1a5bd6475fd81",
"target": "midi"
},
{
"id": "de.add-ideas.3d-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/3d-tools/releases/download/v0.2.0/3d-tools-0.2.0.zip",
"sha256": "912a2a398c5f88099638fba8ca2687b41eb5f4e5a66d1ee5bb5fa9a078c23012",
"target": "3d"
},
{
"id": "de.add-ideas.query-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/query-tools/releases/download/v0.2.0/query-tools-0.2.0.zip",
"sha256": "398e0954cded4f20200d8b0d9349b61b86d46b6f4080e13ae469c0352ad96a90",
"target": "query"
},
{
"id": "de.add-ideas.scan-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/scan-tools/releases/download/v0.2.0/scan-tools-0.2.0.zip",
"sha256": "836f93d248ceb052088826dde2fccaa31f17b8852039ac8ebdaf4e7d9c1927c0",
"target": "scan"
},
{
"id": "de.add-ideas.package-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/package-tools/releases/download/v0.2.0/package-tools-0.2.0.zip",
"sha256": "2f31ac3937901ab6a50d002cafbd294229cb5d6e1a18b9021b4ba345c28ee892",
"target": "package"
},
{
"id": "de.add-ideas.label-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/label-tools/releases/download/v0.2.0/label-tools-0.2.0.zip",
"sha256": "706d4bf3e4d802d06e5dcb089f67fdae56bbb250aedbe1b9f8e5b0c2fd997869",
"target": "label"
},
{
"id": "de.add-ideas.font-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/font-tools/releases/download/v0.2.0/font-tools-0.2.0.zip",
"sha256": "3257605518cbdfbe46b5ee4b8f3da6245ef97fc17b884cf97817f24ef0e765ea",
"target": "font"
},
{
"id": "de.add-ideas.fixture-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/fixture-tools/releases/download/v0.2.0/fixture-tools-0.2.0.zip",
"sha256": "918f322e2607bc1b5bcf4ddf33de88ef3cf1611d8aa3b218f2a70a7b967b3c00",
"target": "fixture"
},
{
"id": "de.add-ideas.minimize-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/minimize-tools/releases/download/v0.2.0/minimize-tools-0.2.0.zip",
"sha256": "b0fa1783acdb431ae56aeab9d5d8364492b833caa69ad557f48c26ee7e915c4f",
"target": "minimize"
},
{
"id": "de.add-ideas.format-lab",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/format-lab/releases/download/v0.2.0/format-lab-0.2.0.zip",
"sha256": "05a796584318a8bbfd651af80066136da46a533e19b37c18fb9f7fe3b00fe0f9",
"target": "format-lab"
},
{
"id": "de.add-ideas.repro-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/repro-tools/releases/download/v0.2.0/repro-tools-0.2.0.zip",
"sha256": "6d3062fbf9bd686f7b320239559b996f64ff0ad29740bfe37eaad02e307bbda8",
"target": "repro"
},
{
"id": "de.add-ideas.schema-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/schema-tools/releases/download/v0.2.0/schema-tools-0.2.0.zip",
"sha256": "ce0b72851d71e9b8b1ae451220f79a91c23c7c8ce621d021c6b7da50741b917a",
"target": "schema"
},
{
"id": "de.add-ideas.log-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/log-tools/releases/download/v0.2.0/log-tools-0.2.0.zip",
"sha256": "92e1e038efdfea5b605f69d84061489365d8e72f6a9cbe7800624325dc091d1d",
"target": "log"
},
{
"id": "de.add-ideas.binary-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/binary-tools/releases/download/v0.2.0/binary-tools-0.2.0.zip",
"sha256": "64b1c4c3ea8a05b3c193325d405180505b645b14ddb7e80d27af3cb8ef400711",
"target": "binary"
},
{
"id": "de.add-ideas.git-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/git-tools/releases/download/v0.2.0/git-tools-0.2.0.zip",
"sha256": "d5f2bd66f5aabdbbbdc853f460580ce276c3a51ef671297cbb6a88e3ad7d9035",
"target": "git"
},
{
"id": "de.add-ideas.api-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/api-tools/releases/download/v0.2.0/api-tools-0.2.0.zip",
"sha256": "81ad271a211fbaca3484b82b0a33aa145fd5863f6a0a53b544a6854016fbc114",
"target": "api"
},
{
"id": "de.add-ideas.mail-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/mail-tools/releases/download/v0.2.0/mail-tools-0.2.0.zip",
"sha256": "9701a3a1e55f0dd832fa78c808757fb1dd3ba9582a65c3692b376f15f3c62b7c",
"target": "mail"
},
{
"id": "de.add-ideas.calendar-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/calendar-tools/releases/download/v0.2.0/calendar-tools-0.2.0.zip",
"sha256": "89f9813ee2cbb9f89e9e6c60084cc497eb3211f76bb9d8bb4fd4bdfc85d118a9",
"target": "calendar"
},
{
"id": "de.add-ideas.contact-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/contact-tools/releases/download/v0.2.0/contact-tools-0.2.0.zip",
"sha256": "bacd7d0c18e3250226f7f171919a0de11860bca3a558fab73bb8790ee254ec0b",
"target": "contact"
},
{
"id": "de.add-ideas.diagram-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/diagram-tools/releases/download/v0.2.0/diagram-tools-0.2.0.zip",
"sha256": "971c530fca8f310ea0ebd06a9e10ecf6a194631c9a843df1c1f407f4d6213541",
"target": "diagram"
},
{
"id": "de.add-ideas.token-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/token-tools/releases/download/v0.2.0/token-tools-0.2.0.zip",
"sha256": "8c78980f2869fb2e7ac3f16f8a23694e526ae1649a14163e482636a364d45ab8",
"target": "token"
},
{
"id": "de.add-ideas.device-tools",
"version": "0.2.0",
"artifact": "https://git.add-ideas.de/lotobo/device-tools/releases/download/v0.2.0/device-tools-0.2.0.zip",
"sha256": "f1dbcbd51bdbc07b3d3860fb5a3dab081db87e74f21a1240d82584dbb70d101c",
"target": "device"
} }
] ]
} }
+2 -2
View File
@@ -2,7 +2,7 @@ import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, readdir, stat } from 'node:fs/promises'; import { mkdir, readdir, stat } from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import archiver from 'archiver'; import { ZipArchive } from 'archiver';
import { import {
canonicalExistingPath, canonicalExistingPath,
canonicalPathsOverlap, canonicalPathsOverlap,
@@ -56,7 +56,7 @@ export async function createStaticArchive(inputDirectory, outputFile) {
throw new Error(`Archive input is not a directory: ${source}`); throw new Error(`Archive input is not a directory: ${source}`);
await mkdir(path.dirname(output), { recursive: true }); await mkdir(path.dirname(output), { recursive: true });
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = new ZipArchive({ zlib: { level: 9 } });
const destination = createWriteStream(output, { flags: 'wx' }); const destination = createWriteStream(output, { flags: 'wx' });
archive.on('warning', (error) => { archive.on('warning', (error) => {
if (error.code !== 'ENOENT') destination.destroy(error); if (error.code !== 'ENOENT') destination.destroy(error);
+5 -5
View File
@@ -14,7 +14,7 @@ import os from 'node:os';
import path from 'node:path'; import path from 'node:path';
import { pipeline } from 'node:stream/promises'; import { pipeline } from 'node:stream/promises';
import { pathToFileURL } from 'node:url'; import { pathToFileURL } from 'node:url';
import archiver from 'archiver'; import { ZipArchive } from 'archiver';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { assembleRelease } from './assemble.mjs'; import { assembleRelease } from './assemble.mjs';
import { createStaticArchive } from './archive.mjs'; import { createStaticArchive } from './archive.mjs';
@@ -46,7 +46,7 @@ async function zipEntries(
output: string, output: string,
entries: Array<{ name: string; content: string }> entries: Array<{ name: string; content: string }>
) { ) {
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = new ZipArchive({ zlib: { level: 9 } });
const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' })); const writing = pipeline(archive, createWriteStream(output, { flags: 'wx' }));
for (const entry of entries) for (const entry of entries)
archive.append(entry.content, { name: entry.name }); archive.append(entry.content, { name: entry.name });
@@ -83,7 +83,7 @@ function makeLock(artifact: string, sha256: string) {
return { return {
schemaVersion: 1, schemaVersion: 1,
releaseVersion: '0.1.0', releaseVersion: '0.1.0',
portalVersion: '0.1.0', portalVersion: '0.2.23',
catalogue: { catalogue: {
id: 'de.add-ideas.toolbox', id: 'de.add-ideas.toolbox',
name: 'Test Toolbox', name: 'Test Toolbox',
@@ -238,7 +238,7 @@ describe('release assembly integrity', () => {
it('blocks symbolic links before extraction', async () => { it('blocks symbolic links before extraction', async () => {
const directory = await temporaryDirectory(); const directory = await temporaryDirectory();
const artifact = path.join(directory, 'symlink.zip'); const artifact = path.join(directory, 'symlink.zip');
const archive = archiver('zip', { zlib: { level: 9 } }); const archive = new ZipArchive({ zlib: { level: 9 } });
const writing = pipeline( const writing = pipeline(
archive, archive,
createWriteStream(artifact, { flags: 'wx' }) createWriteStream(artifact, { flags: 'wx' })
@@ -290,7 +290,7 @@ describe('release assembly integrity', () => {
expect(await sha256File(utcArchive)).toBe( expect(await sha256File(utcArchive)).toBe(
await sha256File(honoluluArchive) await sha256File(honoluluArchive)
); );
}); }, 30_000);
it('rejects a valid archive whose manifest identity is not the lock identity', async () => { it('rejects a valid archive whose manifest identity is not the lock identity', async () => {
const directory = await temporaryDirectory(); const directory = await temporaryDirectory();
+302 -2
View File
@@ -4,17 +4,98 @@ import { describe, expect, it } from 'vitest';
const projectRoot = process.cwd(); const projectRoot = process.cwd();
const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8'); const compose = readFileSync(path.join(projectRoot, 'compose.yaml'), 'utf8');
const composeExample = readFileSync(
path.join(projectRoot, 'compose.example.yaml'),
'utf8'
);
const containerfile = readFileSync( const containerfile = readFileSync(
path.join(projectRoot, 'Containerfile.release'), path.join(projectRoot, 'Containerfile.release'),
'utf8' 'utf8'
); );
const environmentExample = readFileSync(
path.join(projectRoot, '.env.example'),
'utf8'
);
const nginxConfig = readFileSync( const nginxConfig = readFileSync(
path.join(projectRoot, 'deploy', 'nginx.conf'), path.join(projectRoot, 'deploy', 'nginx.conf'),
'utf8' 'utf8'
); );
const releaseLock = JSON.parse( const releaseLock = JSON.parse(
readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8') readFileSync(path.join(projectRoot, 'release', 'toolbox.lock.json'), 'utf8')
) as { releaseVersion: string }; ) as { releaseVersion: string; apps: Array<{ target: string }> };
const developmentCatalogue = JSON.parse(
readFileSync(path.join(projectRoot, 'public', 'toolbox.catalog.json'), 'utf8')
) as { apps: Array<{ manifest: string; enabled: boolean }> };
const expectedAppTargets = [
'pdf',
'xslt',
'onenote',
'av',
'regex',
'svg',
'auth',
'sudoku',
'colour',
'office',
'image',
'file',
'epub',
'archive',
'privacy',
'crypto',
'barcode',
'network',
'geo',
'helper',
'rand',
'data',
'diff',
'time',
'text',
'unicode',
'flow',
'subtitle',
'midi',
'3d',
'query',
'scan',
'package',
'label',
'font',
'fixture',
'minimize',
'format-lab',
'repro',
'schema',
'log',
'binary',
'git',
'api',
'mail',
'calendar',
'contact',
'diagram',
'token',
'device',
] as const;
const immutableCacheControl = 'public, max-age=31536000, immutable';
const immutableUriPatterns = Array.from(
nginxConfig.matchAll(
new RegExp(
`^\\s*"~([^"]+)"\\s+"${immutableCacheControl.replaceAll(
', ',
',\\s+'
)}";$`,
'gmu'
)
),
(match) => new RegExp(match[1], 'u')
);
const isImmutable = (uri: string): boolean =>
immutableUriPatterns.some((pattern) => pattern.test(uri));
describe('production deployment', () => { describe('production deployment', () => {
it('keeps the release version and checksum pins synchronized', () => { it('keeps the release version and checksum pins synchronized', () => {
@@ -30,10 +111,25 @@ describe('production deployment', () => {
const composeChecksum = compose.match( const composeChecksum = compose.match(
/\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u /\$\{TOOLBOX_RELEASE_SHA256:-([a-f0-9]{64})\}/u
)?.[1]; )?.[1];
const environmentVersion = environmentExample.match(
/^TOOLBOX_RELEASE_VERSION=(\S+)$/mu
)?.[1];
const environmentChecksum = environmentExample.match(
/^TOOLBOX_RELEASE_SHA256=([a-f0-9]{64})$/mu
)?.[1];
const localImageVersion = composeExample.match(
/^\s+image: git\.add-ideas\.de\/lotobo\/toolbox:(\S+)$/mu
)?.[1];
expect(containerVersion).toBe(releaseLock.releaseVersion); expect(containerVersion).toBe(releaseLock.releaseVersion);
expect(composeVersion).toBe(containerVersion); expect(composeVersion).toBe(containerVersion);
expect(containerChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(composeChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(environmentChecksum).toMatch(/^[a-f0-9]{64}$/u);
expect(composeChecksum).toBe(containerChecksum); expect(composeChecksum).toBe(containerChecksum);
expect(environmentVersion).toBe(containerVersion);
expect(environmentChecksum).toBe(containerChecksum);
expect(localImageVersion).toBe(containerVersion);
}); });
it('verifies the release before extraction and retains a rootless final image', () => { it('verifies the release before extraction and retains a rootless final image', () => {
@@ -45,10 +141,27 @@ describe('production deployment', () => {
expect(containerfile).toContain( expect(containerfile).toContain(
'COPY --from=release --chown=101:101 /tmp/toolbox/' 'COPY --from=release --chown=101:101 /tmp/toolbox/'
); );
for (const app of expectedAppTargets) {
expect(containerfile).toContain(
`test -f /tmp/toolbox/apps/${app}/toolbox-app.json`
);
}
expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u); expect(containerfile).toMatch(/\nUSER 101:101\nEXPOSE 8080\n/u);
}); });
it('uses only the requested external Traefik network and HTTPS router', () => { it('keeps the development catalogue aligned with all release targets', () => {
expect(releaseLock.apps.map(({ target }) => target)).toEqual(
expectedAppTargets
);
expect(developmentCatalogue.apps).toEqual(
expectedAppTargets.map((target) => ({
manifest: `./apps/${target}/toolbox-app.json`,
enabled: true,
}))
);
});
it('uses only the requested external Traefik network and redirects HTTP to HTTPS', () => {
expect(compose).toContain('dockerfile: Containerfile.release'); expect(compose).toContain('dockerfile: Containerfile.release');
expect(compose).toContain('traefik.enable: "true"'); expect(compose).toContain('traefik.enable: "true"');
expect(compose).toContain( expect(compose).toContain(
@@ -63,14 +176,201 @@ describe('production deployment', () => {
expect(compose).toContain( expect(compose).toContain(
'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"' 'traefik.http.routers.addideas-toolbox.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
); );
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.rule: "Host(`${TOOLBOX_HOST:-toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.entrypoints: web'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.priority: 900'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.middlewares: addideas-toolbox-https-redirect'
);
expect(compose).toContain(
'traefik.http.routers.addideas-toolbox-http.service: addideas-toolbox'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.scheme: https'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-toolbox-https-redirect.redirectscheme.permanent: "true"'
);
expect(compose).toContain('external: true'); expect(compose).toContain('external: true');
expect(compose).not.toMatch(/^\s+ports:/mu); expect(compose).not.toMatch(/^\s+ports:/mu);
}); });
it('isolates live WebAuthn behind a dedicated hostname and path prefix', () => {
expect(environmentExample).toContain(
'AUTH_TOOLS_HOST=auth.toolbox.add-ideas.de'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.rule: "Host(`${AUTH_TOOLS_HOST:-auth.toolbox.add-ideas.de}`)"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.entrypoints: websecure'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.tls.certresolver: "${TRAEFIK_CERT_RESOLVER:-netcup}"'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth.middlewares: addideas-auth-prefix'
);
expect(compose).toContain(
'traefik.http.middlewares.addideas-auth-prefix.addprefix.prefix: /apps/auth'
);
expect(compose).toContain(
'traefik.http.routers.addideas-auth-http.middlewares: addideas-toolbox-https-redirect'
);
expect(compose).not.toContain('addideas-auth-stripprefix');
});
it('scopes camera and device probes while denying sensitive features by default', () => {
expect(nginxConfig).toContain(
'default "camera=(), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
);
expect(nginxConfig).toContain(
'"~^auth\\.toolbox\\.add-ideas\\.de:" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
);
expect(nginxConfig).toContain(
'"~^[^:]+:/apps/barcode(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
);
expect(nginxConfig).toContain(
'"~^[^:]+:/apps/scan(?:/|$)" "camera=(self), microphone=(), display-capture=(), geolocation=(), payment=(), usb=()";'
);
expect(nginxConfig).toContain(
'"~^[^:]+:/apps/device(?:/|$)" "camera=(self), microphone=(self), display-capture=(self), geolocation=(), payment=(), usb=()";'
);
expect(nginxConfig).toContain(
'add_header Permissions-Policy $toolbox_permissions_policy always;'
);
expect(
nginxConfig.match(/^\s*add_header Permissions-Policy /gmu)
).toHaveLength(1);
});
it('keeps application network connections same-origin', () => {
expect(nginxConfig).toMatch(
/^\s*add_header Content-Security-Policy "[^"]*connect-src 'self';[^"]*" always;$/mu
);
expect(nginxConfig).not.toContain('$toolbox_connect_sources');
expect(nginxConfig).not.toMatch(/https?:\/\//u);
});
it('quotes nginx regular expressions that contain brace quantifiers', () => { it('quotes nginx regular expressions that contain brace quantifiers', () => {
expect(nginxConfig).toContain( expect(nginxConfig).toContain(
'"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\\.[^/]+$"' '"~^/(?:.*/)?assets/.*-[A-Za-z0-9_-]{8,}\\.[^/]+$"'
); );
expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu); expect(nginxConfig).not.toMatch(/^\s*~[^\n]*\{\d/mu);
}); });
it('caches only hashed assets and the versioned FFmpeg runtime immutably', () => {
expect(immutableUriPatterns).toHaveLength(2);
for (const uri of [
'/assets/index-Bp9nK_3a.js',
'/apps/av/vendor/ffmpeg/0.12.10/version.json',
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/version.json',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.js',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.1/mt/ffmpeg-core.worker.js',
]) {
expect(isImmutable(uri), uri).toBe(true);
}
for (const uri of [
'/',
'/index.html',
'/SOURCE.md',
'/LICENSE',
'/toolbox.catalog.json',
'/toolbox.release.json',
'/apps/av/toolbox-app.json',
'/apps/av/THIRD_PARTY_NOTICES.md',
'/apps/av/vendor/ffmpeg/version.json',
'/apps/av/vendor/ffmpeg/latest/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/01.2.3/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/st/other.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/mt/ffmpeg-core.worker.wasm',
'/apps/av/vendor/ffmpeg/0.12.10/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed_/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-latest/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-SNAPSHOT/st/ffmpeg-core.wasm',
'/apps/av/vendor/ffmpeg/0.12.10-reviewed-stack5m.2/st/ffmpeg-core.wasm',
]) {
expect(isImmutable(uri), uri).toBe(false);
}
});
it('serves WebAssembly through an explicit MIME-mapped location', () => {
expect(nginxConfig).toMatch(
/location ~\* \\\.wasm\$\s*\{\s*types\s*\{\s*application\/wasm wasm;\s*\}\s*try_files \$uri =404;\s*\}/mu
);
expect(nginxConfig).not.toMatch(
/location ~\* [^\n]*\(\?:[^\n|)]*\bwasm\b[^\n)]*\)/u
);
});
it('serves ES modules as JavaScript before the broad static location', () => {
const moduleLocation =
/location ~\* \\\.mjs\$\s*\{\s*types\s*\{\s*application\/javascript mjs;\s*\}\s*try_files \$uri =404;\s*\}/mu;
const moduleLocationIndex = nginxConfig.search(moduleLocation);
const staticLocationIndex = nginxConfig.search(
/location ~\* \\\.\(\?:css\|js\|mjs\|/u
);
expect(moduleLocationIndex).toBeGreaterThanOrEqual(0);
expect(staticLocationIndex).toBeGreaterThan(moduleLocationIndex);
});
it('serves bundled Scan OCR language data as gzip without widening the static route', () => {
const languageLocation =
/location ~\* \^\/apps\/scan\/ocr\/lang\/\[a-z0-9_-\]\+\\\.traineddata\\\.gz\$\s*\{\s*types\s*\{\s*application\/gzip gz;\s*\}\s*try_files \$uri =404;\s*\}/mu;
expect(nginxConfig).toMatch(languageLocation);
expect(nginxConfig).not.toMatch(
/location ~\* \\.\(\?:[^\n)]*\bgz\b[^\n)]*\)/u
);
});
it('allows same-origin WebAssembly workers and local blob media previews', () => {
expect(nginxConfig).toContain("script-src 'self' 'wasm-unsafe-eval'");
expect(nginxConfig).toContain("worker-src 'self' blob:");
expect(nginxConfig).toContain("media-src 'self' blob:");
expect(nginxConfig).toContain("font-src 'self' data: blob:");
expect(nginxConfig).not.toContain("'unsafe-eval'");
});
it('grants only the sandboxed SVG canvas controller cross-origin loading headers', () => {
expect(nginxConfig).toMatch(
/map \$uri \$toolbox_resource_policy\s*\{\s*default "same-origin";\s*"\/apps\/svg\/canvas-frame-controller\.js" "cross-origin";\s*\}/mu
);
expect(nginxConfig).toMatch(
/map \$uri \$toolbox_access_control_allow_origin\s*\{\s*default "";\s*"\/apps\/svg\/canvas-frame-controller\.js" "\*";\s*\}/mu
);
expect(nginxConfig).toContain(
'add_header Cross-Origin-Resource-Policy $toolbox_resource_policy always;'
);
expect(nginxConfig).toContain(
'add_header Access-Control-Allow-Origin $toolbox_access_control_allow_origin always;'
);
expect(nginxConfig).not.toMatch(
/location[^\n{]*canvas-frame-controller[^\n{]*\{/u
);
expect(
nginxConfig.match(/^\s*add_header X-Content-Type-Options /gmu)
).toHaveLength(1);
expect(
nginxConfig.match(/^\s*add_header Content-Security-Policy /gmu)
).toHaveLength(1);
});
}); });
+6
View File
@@ -5,9 +5,15 @@ import { fileURLToPath } from 'node:url';
const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu; const LICENSE_FILE = /^(?:licen[cs]e|copying|notice)(?:[._-].*)?$/iu;
const RUNTIME_PACKAGES = [ const RUNTIME_PACKAGES = [
'@add-ideas/toolbox-contract', '@add-ideas/toolbox-contract',
'@add-ideas/toolbox-shell-react',
'@dnd-kit/accessibility',
'@dnd-kit/core',
'@dnd-kit/sortable',
'@dnd-kit/utilities',
'react', 'react',
'react-dom', 'react-dom',
'scheduler', 'scheduler',
'tslib',
]; ];
function compareCodePoints(left, right) { function compareCodePoints(left, right) {
+1 -1
View File
@@ -131,7 +131,7 @@ export function buildCatalogue(lock) {
}; };
return { return {
$schema: $schema:
'https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json', 'https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json',
...parseToolboxCatalog(catalogue), ...parseToolboxCatalog(catalogue),
}; };
} }
+545 -43
View File
@@ -1,4 +1,10 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import {
fireEvent,
render,
screen,
waitFor,
within,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import App from './App'; import App from './App';
@@ -7,39 +13,82 @@ import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
function toolLink(name: string) {
return screen.getByRole('link', { name: `Open ${name}` });
}
function queryToolLink(name: string) {
return screen.queryByRole('link', { name: `Open ${name}` });
}
function findToolLink(name = 'PDF Workbench') {
return screen.findByRole('link', { name: `Open ${name}` });
}
describe('portal UI', () => { describe('portal UI', () => {
it('renders app cards and persists accessible pin/hide controls', async () => { it('renders compact actionable tiles and persists pin/hide controls', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const launch = await findToolLink();
const card = screen.getByTestId('app-card-de.add-ideas.pdf-tools');
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
'PDF'
);
expect(within(card).getByText('Arrange PDF pages locally.')).toBeVisible();
expect( expect(
await screen.findByRole('heading', { name: 'PDF Workbench' }) screen.getByTestId('app-card-de.add-ideas.xslt-tools')
).toBeInTheDocument(); ).toHaveTextContent('XSLT');
expect( expect(
screen.getByText( screen.getByTestId('app-card-de.add-ideas.onenote-tools')
).toHaveTextContent('OneNote');
expect(
screen.getByTestId('app-card-de.add-ideas.svg-tools')
).toHaveTextContent('SVG');
expect(
screen.getByTestId('app-card-de.add-ideas.auth-tools')
).toHaveTextContent('OTP & Passkeys');
expect(
screen.getByTestId('app-card-de.add-ideas.colour-tools')
).toHaveTextContent('Colour');
expect(
screen.getByTestId('app-card-de.add-ideas.office-tools')
).toHaveTextContent('Office');
expect(
screen.queryByText(
'Page-level PDF operations performed locally in the browser.'
)
).not.toBeInTheDocument();
expect(
screen.queryByText(
'Local processing · no file uploads declared · no telemetry declared' 'Local processing · no file uploads declared · no telemetry declared'
) )
).toBeInTheDocument(); ).not.toBeInTheDocument();
const sourceLinks = screen.getAllByRole('link', { expect(
name: 'Source and license', screen.getAllByRole('link', { name: 'Source and license' })
).toHaveLength(1);
expect(
screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument();
const showHidden = screen.getByRole('checkbox', {
name: 'Show hidden (0)',
}); });
expect(sourceLinks).toHaveLength(2); expect(showHidden).toBeDisabled();
expect( expect(showHidden).not.toBeChecked();
sourceLinks.find( expect(launch).toHaveClass('app-card__launch-link');
(link) =>
link.getAttribute('href') ===
'https://git.add-ideas.de/zemion/pdf-tools'
)
).toHaveAttribute('rel', 'noopener noreferrer');
expect(
sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0')
)
).toBeDefined();
const launch = screen.getByRole('link', { name: /open tool/i });
expect( expect(
new URL(launch.getAttribute('href')!).searchParams.get('toolbox') new URL(launch.getAttribute('href')!).searchParams.get('toolbox')
).toContain('toolbox.catalog.json'); ).toContain('toolbox.catalog.json');
const actions = within(card).getByRole('group', {
name: 'Actions for PDF Workbench',
});
const info = within(actions).getByRole('button', {
name: 'More information about PDF Workbench',
});
expect(info).toHaveAttribute('aria-haspopup', 'dialog');
for (const control of within(actions).getAllByRole('button'))
expect(control.closest('a')).toBeNull();
expect(launch).not.toContainElement(info);
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' })); await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
await waitFor(() => await waitFor(() =>
@@ -47,18 +96,462 @@ describe('portal UI', () => {
'de.add-ideas.pdf-tools' 'de.add-ideas.pdf-tools'
) )
); );
expect(screen.getByText('Pinned')).toBeInTheDocument(); expect(screen.getByRole('region', { name: 'Pinned' })).toContainElement(
screen.getByTestId('app-card-de.add-ideas.pdf-tools')
);
expect(
screen.queryByRole('button', { name: /Move PDF Workbench/i })
).not.toBeInTheDocument();
const unpin = screen.getByRole('button', {
name: 'Unpin PDF Workbench',
});
expect(unpin).toHaveAttribute('aria-pressed', 'true');
expect(unpin).toHaveClass('is-active');
await waitFor(() => expect(unpin).toHaveFocus());
expect(
screen.getByRole('button', { name: 'Reorder PDF Workbench' })
).toBeInTheDocument();
await user.click( await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' }) screen.getByRole('button', { name: 'Hide PDF Workbench' })
); );
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
expect(showHidden).toBeEnabled();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
await waitFor(() => expect(showHidden).toHaveFocus());
fireEvent.click(showHidden);
expect(toolLink('PDF Workbench')).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' })
);
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
).toHaveFocus()
);
});
it('uses the compact Regex presentation for the released tool', async () => {
vi.stubGlobal(
'fetch',
vi.fn(
catalogueFetch({
'/toolbox.catalog.json': Response.json({
...catalogue,
apps: [
...catalogue.apps,
{ manifest: './apps/regex/toolbox-app.json', enabled: true },
],
}),
})
)
);
render(<App />);
await findToolLink();
const card = screen.getByTestId('app-card-de.add-ideas.regex-tools');
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
'Regex'
);
expect(within(card).getByText('Explain and test locally.')).toBeVisible();
});
it('moves the full disclosure into an accessible modal', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
await findToolLink();
const info = screen.getByRole('button', {
name: 'More information about PDF Workbench',
});
await user.click(info);
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveTextContent(
'Page-level PDF operations performed locally in the browser.'
);
expect(dialog).toHaveTextContent(
'Local processing · no file uploads declared · no telemetry declared'
);
expect(dialog).toHaveTextContent(
'Needs Secure context · Web workers · IndexedDB'
);
expect(dialog).toHaveTextContent('v0.4.3');
expect( expect(
screen.queryByRole('heading', { name: 'PDF Workbench' }) within(dialog).getByRole('button', { name: 'documents' })
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument(); ).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: '#merge' })
).toBeInTheDocument();
const sourceLink = within(dialog).getByRole('link', {
name: 'Source and license',
});
expect(sourceLink).toHaveAttribute(
'href',
'https://git.add-ideas.de/lotobo/pdf-tools'
);
expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer');
expect(
within(dialog).getByRole('link', { name: 'Open PDF' })
).toBeInTheDocument();
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
expect(document.documentElement).toHaveStyle({ overflow: 'hidden' });
const close = within(dialog).getByRole('button', {
name: 'Close information for PDF Workbench',
});
expect(close).toHaveFocus();
within(dialog).getByRole('link', { name: 'Open PDF' }).focus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(
screen.queryByRole('dialog', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(info).toHaveFocus();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' });
});
it('keeps search and category controls together and filters with each', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
const search = screen.getByRole('searchbox', { name: 'Search tools' });
const category = screen.getByRole('combobox', {
name: 'Filter by category',
});
expect(search.closest('.tool-controls')).toBe(
category.closest('.tool-controls')
);
await user.type(search, 'xslt');
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
await user.clear(search);
await user.selectOptions(category, 'notes');
expect(toolLink('OneNote Reader')).toBeInTheDocument();
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
await user.selectOptions(category, '');
await user.click(
screen.getByRole('button', {
name: 'More information about PDF Workbench',
})
);
await user.click(
within(screen.getByRole('dialog', { name: 'PDF Workbench' })).getByRole(
'button',
{ name: 'pdf' }
)
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(category).toHaveValue('pdf');
expect(toolLink('PDF Workbench')).toBeInTheDocument();
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
});
it('keeps remaining hidden tools visible while editing the hidden set', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
const showHidden = screen.getByRole('checkbox', {
name: 'Show hidden (1)',
});
await user.click(showHidden);
await user.click(
screen.getByRole('button', { name: 'Hide XSLT Workbench' })
);
expect(showHidden).toHaveAccessibleName('Show hidden (2)');
await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' })
);
expect(showHidden).toBeChecked();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
});
it('clears search filters without revealing hidden tools', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
);
await user.type(
screen.getByRole('searchbox', { name: 'Search tools' }),
'no tool has this phrase'
);
await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
expect(toolLink('XSLT Workbench')).toBeInTheDocument();
expect(
screen.getByRole('checkbox', { name: 'Show hidden (1)' })
).not.toBeChecked();
});
it('lists contextual destinations in the Apps menu', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
await user.click(screen.getByRole('button', { name: 'Apps' }));
const navigation = screen.getByRole('navigation', {
name: 'Toolbox applications',
});
const destination = within(navigation).getByRole('link', {
name: 'XSLT Workbench',
});
expect(
new URL(destination.getAttribute('href')!).searchParams.get('toolbox')
).toContain('toolbox.catalog.json');
});
it('uses the shared accessible header identity and control order', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />);
await findToolLink();
const header = document.querySelector<HTMLElement>('.portal-header')!;
expect(
within(header).getByRole('heading', { level: 1, name: 'Toolbox' })
).toBeInTheDocument();
expect(
within(header).getByText('Local-first browser tools')
).toBeInTheDocument();
expect(
screen.getByRole('heading', {
level: 2,
name: 'Your local tools, in one calm place.',
})
).toBeInTheDocument();
expect(
screen.getAllByRole('heading', {
name: 'Your local tools, in one calm place.',
})
).toHaveLength(1);
const personalize = within(header).getByRole('button', {
name: 'Personalize',
});
const apps = within(header).getByRole('button', { name: 'Apps' });
const source = within(header).getByRole('link', {
name: 'Toolbox source on Gitea',
});
const help = within(header).getByRole('button', { name: 'Help' });
const brand = within(header).getByRole('link', {
name: 'add·ideas Toolbox',
});
const controls = within(header).getByRole('group', {
name: 'Toolbox controls',
});
expect(
Array.from(controls.children, (element) =>
element.getAttribute('aria-label')
)
).toEqual(['Help', 'Toolbox source on Gitea', 'Apps', 'Personalize']);
expect(help.compareDocumentPosition(source)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(source.compareDocumentPosition(apps)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(apps.compareDocumentPosition(personalize)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(
Array.from(
header.querySelector('.toolbox-shell__bar')?.children ?? [],
(element) => element.className
)
).toEqual([
'toolbox-shell__brand',
'toolbox-shell__identity',
'toolbox-shell__controls',
]);
expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg');
});
it('persists an explicit appearance and accepts cross-tab updates', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize);
expect(
screen.getByRole('heading', { name: 'Personalize your toolbox' })
).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
await waitFor(() =>
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"')
);
await user.click(personalize);
fireEvent(
window,
new StorageEvent('storage', {
key: PREFERENCES_KEY,
newValue: JSON.stringify({
version: 1,
pinned: [],
order: [
'de.add-ideas.pdf-tools',
'de.add-ideas.xslt-tools',
'de.add-ideas.onenote-tools',
'de.add-ideas.svg-tools',
'de.add-ideas.auth-tools',
'de.add-ideas.sudoku-tools',
'de.add-ideas.colour-tools',
'de.add-ideas.office-tools',
],
hidden: [],
theme: 'light',
}),
})
);
expect(document.documentElement).toHaveAttribute('data-theme', 'light');
});
it('supports keyboard drag ordering from each tile grip', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />);
await findToolLink();
const cards = [
screen.getByTestId('app-card-de.add-ideas.pdf-tools'),
screen.getByTestId('app-card-de.add-ideas.xslt-tools'),
screen.getByTestId('app-card-de.add-ideas.onenote-tools'),
screen.getByTestId('app-card-de.add-ideas.svg-tools'),
screen.getByTestId('app-card-de.add-ideas.auth-tools'),
screen.getByTestId('app-card-de.add-ideas.sudoku-tools'),
screen.getByTestId('app-card-de.add-ideas.colour-tools'),
screen.getByTestId('app-card-de.add-ideas.office-tools'),
];
cards.forEach((card, index) => {
const rect = {
x: index * 220,
y: 0,
width: 188,
height: 188,
top: 0,
right: index * 220 + 188,
bottom: 188,
left: index * 220,
toJSON: () => ({}),
} as DOMRect;
card.getBoundingClientRect = () => rect;
card.getClientRects = () => [rect] as unknown as DOMRectList;
});
const grip = screen.getByRole('button', { name: 'Reorder PDF Workbench' });
grip.focus();
fireEvent.keyDown(grip, { key: ' ', code: 'Space' });
await waitFor(() => expect(grip).toHaveAttribute('aria-pressed', 'true'));
await new Promise((resolve) => setTimeout(resolve, 0));
fireEvent.keyDown(document, { key: 'ArrowRight', code: 'ArrowRight' });
fireEvent.keyDown(document, { key: ' ', code: 'Space' });
await waitFor(() => {
const tools = screen.getByRole('region', { name: 'Tools' });
expect(
within(tools)
.getAllByRole('heading', { level: 3 })
.map((heading) => heading.textContent)
).toEqual([
'XSLT',
'PDF',
'OneNote',
'SVG',
'OTP & Passkeys',
'Sudoku',
'Colour',
'Office',
]);
});
expect(
JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order
).toEqual([
'de.add-ideas.xslt-tools',
'de.add-ideas.pdf-tools',
'de.add-ideas.onenote-tools',
'de.add-ideas.svg-tools',
'de.add-ideas.auth-tools',
'de.add-ideas.sudoku-tools',
'de.add-ideas.colour-tools',
'de.add-ideas.office-tools',
]);
});
it('filters by clickable tags and clears an active tag', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
await findToolLink();
await user.click(
screen.getByRole('button', {
name: 'More information about PDF Workbench',
})
);
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
await user.click(within(dialog).getByRole('button', { name: '#merge' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(toolLink('PDF Workbench')).toBeInTheDocument();
expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
const activeTag = screen.getByRole('button', {
name: /#merge.*Clear tag filter/i,
});
await user.click(activeTag);
expect(await findToolLink('XSLT Workbench')).toBeInTheDocument();
expect(toolLink('OneNote Reader')).toBeInTheDocument();
});
it('opens the standard help control and explains tile interaction', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
await findToolLink();
const help = screen.getByRole('button', { name: 'Help' });
await user.click(help);
const dialog = screen.getByRole('dialog', {
name: 'Choose and arrange your tools',
});
expect(dialog).toHaveTextContent('click anywhere on a tool tile');
expect(dialog).toHaveTextContent('use the light bulb');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
const close = within(dialog).getByRole('button', { name: 'Close help' });
expect(close).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(help).toHaveFocus();
}); });
it('shows a recoverable catalogue error', async () => { it('shows a recoverable catalogue error', async () => {
@@ -72,9 +565,7 @@ describe('portal UI', () => {
await screen.findByRole('heading', { name: 'Toolbox unavailable' }) await screen.findByRole('heading', { name: 'Toolbox unavailable' })
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try again' })); fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect( expect(await findToolLink()).toBeInTheDocument();
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
}); });
it('shows a dedicated empty state for a valid catalogue', async () => { it('shows a dedicated empty state for a valid catalogue', async () => {
@@ -92,31 +583,42 @@ describe('portal UI', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('contains preference focus and restores it to Personalize', async () => { it('hosts compact preferences in the shared Personalize popover', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
const { container } = render(<App />); const { container } = render(<App />);
const personalize = screen.getByRole('button', { name: 'Personalize' }); const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize); await user.click(personalize);
const dialog = screen.getByRole('dialog', { const heading = screen.getByRole('heading', {
name: 'Personalize your toolbox', name: 'Personalize your toolbox',
}); });
const close = screen.getByRole('button', { name: 'Close preferences' }); const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
expect(close).toHaveFocus(); expect(popover).toContainElement(heading);
expect(container.querySelector('.site-shell')).toHaveAttribute('inert'); expect(popover).toContainElement(screen.getByText('On this device'));
expect(dialog).toBeInTheDocument(); expect(
within(popover).getByRole('group', { name: 'Appearance' })
).toBeInTheDocument();
expect(
popover.querySelector('.toolbox-shell__preferences-content')
).toBeInTheDocument();
expect(popover).not.toHaveAttribute('aria-modal');
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent( expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
/Trusted code in another Toolbox app on this site can access same-origin storage/i /Trusted code in another Toolbox app on this site can access same-origin storage/i
); );
expect(
const reset = screen.getByRole('button', { name: 'Reset' }); screen.getByRole('button', { name: 'Export JSON' })
reset.focus(); ).toBeInTheDocument();
await user.tab(); expect(
expect(close).toHaveFocus(); screen.getByRole('button', { name: 'Import JSON' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
await user.keyboard('{Escape}'); await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(
screen.queryByRole('dialog', { name: 'Personalize Toolbox' })
).not.toBeInTheDocument();
expect(personalize).toHaveFocus(); expect(personalize).toHaveFocus();
}); });
}); });
+312 -142
View File
@@ -1,25 +1,51 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import {
DndContext,
KeyboardSensor,
PointerSensor,
closestCenter,
useSensor,
useSensors,
type DragEndEvent,
} from '@dnd-kit/core';
import {
SortableContext,
rectSortingStrategy,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import {
ToolboxHeader,
type ToolboxHeaderApp,
} from '@add-ideas/toolbox-shell-react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppCard } from './components/AppCard'; import { AppCard } from './components/AppCard';
import { AppDetailsPanel } from './components/AppDetailsPanel';
import { HelpPanel } from './components/HelpPanel';
import { Logo } from './components/Logo'; import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel'; import { PreferencesPanel } from './components/PreferencesPanel';
import { contextualLaunchUrl, loadCatalogue } from './catalogue'; import { contextualLaunchUrl, loadCatalogue } from './catalogue';
import { cleanPreferences, sortAppIds } from './preferences'; import { cleanPreferences, reorderWithinIds, sortAppIds } from './preferences';
import type { LoadedCatalogue, ResolvedApp } from './types'; import type { LoadedCatalogue, ResolvedApp } from './types';
import { usePreferences } from './usePreferences'; import { usePreferences } from './usePreferences';
const CATALOGUE_HREF = './toolbox.catalog.json'; const CATALOGUE_HREF = './toolbox.catalog.json';
const PORTAL_SOURCE = 'https://git.add-ideas.de/lotobo/toolbox-portal';
type LoadState = type LoadState =
| { status: 'loading' } | { status: 'loading' }
| { status: 'error'; message: string } | { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue }; | { status: 'ready'; value: LoadedCatalogue };
type PortalOverlay =
{ kind: 'help' } | { kind: 'details'; appId: string } | null;
function appMatches( function appMatches(
app: ResolvedApp, app: ResolvedApp,
query: string, query: string,
category: string category: string,
tag: string
): boolean { ): boolean {
if (category && !app.categories.includes(category)) return false; if (category && !app.categories.includes(category)) return false;
if (tag && !app.tags.includes(tag)) return false;
if (!query) return true; if (!query) return true;
const haystack = [app.name, app.description, ...app.categories, ...app.tags] const haystack = [app.name, app.description, ...app.categories, ...app.tags]
.join(' ') .join(' ')
@@ -27,15 +53,81 @@ function appMatches(
return haystack.includes(query.toLocaleLowerCase()); return haystack.includes(query.toLocaleLowerCase());
} }
interface AppSectionProps {
apps: ResolvedApp[];
catalogueUrl: string;
hiddenIds: readonly string[];
id: string;
onDetails: (id: string) => void;
onHide: (id: string) => void;
onPin: (id: string) => void;
pinned: boolean;
title: string;
}
function AppSection({
apps,
catalogueUrl,
hiddenIds,
id,
onDetails,
onHide,
onPin,
pinned,
title,
}: AppSectionProps) {
if (apps.length === 0) return null;
return (
<section className="app-section" aria-labelledby={`${id}-title`}>
<header className="app-section__header">
<h2 id={`${id}-title`}>{title}</h2>
<span>{apps.length}</span>
</header>
<SortableContext
id={id}
items={apps.map((app) => app.id)}
strategy={rectSortingStrategy}
>
<div className="app-grid">
{apps.map((app) => (
<AppCard
key={app.id}
app={app}
catalogueUrl={catalogueUrl}
contextualUrl={contextualLaunchUrl}
pinned={pinned}
hidden={hiddenIds.includes(app.id)}
onDetails={() => onDetails(app.id)}
onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)}
/>
))}
</div>
</SortableContext>
</section>
);
}
export default function App() { export default function App() {
const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' }); const [loadState, setLoadState] = useState<LoadState>({ status: 'loading' });
const [reloadKey, setReloadKey] = useState(0); const [reloadKey, setReloadKey] = useState(0);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [showHidden, setShowHidden] = useState(false); const [activeTag, setActiveTag] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false); const [shownHiddenIds, setShownHiddenIds] = useState<
readonly string[] | null
>(null);
const [overlay, setOverlay] = useState<PortalOverlay>(null);
const hiddenToggle = useRef<HTMLInputElement>(null);
const [preferences, setPreferences, storageAvailable] = usePreferences(); const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeSettings = useCallback(() => setSettingsOpen(false), []); const closeOverlay = useCallback(() => setOverlay(null), []);
const showHidden =
preferences.hidden.length > 0 &&
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
);
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
@@ -57,6 +149,11 @@ export default function App() {
const loaded = loadState.status === 'ready' ? loadState.value : undefined; const loaded = loadState.status === 'ready' ? loadState.value : undefined;
const apps = useMemo(() => loaded?.apps ?? [], [loaded]); const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
const detailsApp =
overlay?.kind === 'details'
? apps.find((app) => app.id === overlay.appId)
: undefined;
const overlayOpen = overlay?.kind === 'help' || detailsApp !== undefined;
useEffect(() => { useEffect(() => {
if (!loaded) return; if (!loaded) return;
@@ -91,91 +188,184 @@ export default function App() {
}, [apps, preferences]); }, [apps, preferences]);
const visibleApps = orderedApps.filter((app) => { const visibleApps = orderedApps.filter((app) => {
const hidden = preferences.hidden.includes(app.id); const hidden = preferences.hidden.includes(app.id);
return (showHidden || !hidden) && appMatches(app, query.trim(), category); return (
(showHidden || !hidden) &&
appMatches(app, query.trim(), category, activeTag)
);
}); });
const pinnedApps = visibleApps.filter((app) =>
preferences.pinned.includes(app.id)
);
const regularApps = visibleApps.filter(
(app) => !preferences.pinned.includes(app.id)
);
const headerApps: ToolboxHeaderApp[] = apps.map((app) => ({
id: app.id,
name: app.name,
href: app.isExternal
? app.entryUrl
: contextualLaunchUrl(app.entryUrl, loaded?.catalogueUrl.href ?? ''),
newTab: !app.launchModes.includes('navigate'),
}));
function toggleListItem(field: 'pinned' | 'hidden', id: string) { function toggleListItem(field: 'pinned' | 'hidden', id: string) {
const keepCardVisible = field === 'pinned' || showHidden;
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
}
setPreferences((current) => ({ setPreferences((current) => ({
...current, ...current,
[field]: current[field].includes(id) [field]: current[field].includes(id)
? current[field].filter((item) => item !== id) ? current[field].filter((item) => item !== id)
: [...current[field], id], : [...current[field], id],
})); }));
} window.requestAnimationFrame(() => {
if (!keepCardVisible) {
function moveApp(id: string, direction: -1 | 1) { hiddenToggle.current?.focus();
const pinned = preferences.pinned.includes(id); return;
const peers = visibleApps.filter( }
(app) => preferences.pinned.includes(app.id) === pinned const action = field === 'pinned' ? 'pin' : 'visibility';
); [
const currentIndex = peers.findIndex((app) => app.id === id); ...document.querySelectorAll<HTMLButtonElement>(
const target = peers[currentIndex + direction]; `[data-card-action="${action}"]`
if (!target) return; ),
setPreferences((current) => { ]
const currentPosition = current.order.indexOf(id); .find((button) => button.dataset.appId === id)
const targetPosition = current.order.indexOf(target.id); ?.focus();
if (currentPosition < 0 || targetPosition < 0) return current;
const order = [...current.order];
[order[currentPosition], order[targetPosition]] = [
order[targetPosition]!,
order[currentPosition]!,
];
return { ...current, order };
}); });
} }
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox'; function handleDragEnd(event: DragEndEvent) {
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas'; if (!event.over) return;
const activeId = String(event.active.id);
const overId = String(event.over.id);
const activePinned = preferences.pinned.includes(activeId);
if (activePinned !== preferences.pinned.includes(overId)) return;
const peers = (activePinned ? pinnedApps : regularApps).map(
(app) => app.id
);
setPreferences((current) => ({
...current,
order: reorderWithinIds(current.order, activeId, overId, peers),
}));
}
function clearFilters() {
setQuery('');
setCategory('');
setActiveTag('');
setShownHiddenIds(null);
}
return ( return (
<> <>
<div <div
className="site-shell" className="site-shell"
inert={settingsOpen} inert={overlayOpen}
aria-hidden={settingsOpen || undefined} aria-hidden={overlayOpen || undefined}
> >
<a className="skip-link" href="#main-content"> <a className="skip-link" href="#main-content">
Skip to tools Skip to tools
</a> </a>
<header className="site-header"> <ToolboxHeader
<div className="header-inner"> className="portal-header"
<a title="Toolbox"
className="brand" subtitle="Local-first browser tools"
href={loaded ? loaded.homeUrl : './'} theme={preferences.theme}
aria-label={`${title} home`} homeHref={loaded?.homeUrl ?? './'}
> homeLabel="add·ideas Toolbox"
<Logo /> brandIconUrl="./favicon.svg"
<span> helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }}
<strong>{brand}</strong> sourceHref={PORTAL_SOURCE}
<small>Toolbox</small> sourceLabel="Toolbox source on Gitea"
</span> personalizeContent={
</a> <PreferencesPanel
<button preferences={preferences}
className="settings-button" storageAvailable={storageAvailable}
type="button" onChange={setPreferences}
onClick={() => setSettingsOpen(true)} />
> }
<span aria-hidden="true"></span> Personalize apps={headerApps}
</button> allAppsHref="#main-content"
</div> />
</header>
<main id="main-content"> <main id="main-content">
<section className="hero" aria-labelledby="page-title"> <section className="hero" aria-labelledby="page-title">
<p className="eyebrow"> <div className="hero-intro">
Privacy details up front · useful by default <p className="eyebrow">
</p> Privacy details up front · useful by default
<h1 id="page-title"> </p>
Your document tools, <h2 id="page-title">
<br /> Your local tools,
<span>in one calm place.</span> <br /> <span>in one calm place.</span>
</h1> </h2>
<p className="hero-copy"> <p className="hero-copy">
Focused browser utilities with a privacy disclosure for each tool. Focused browser utilities with a privacy disclosure for each
Choose one and keep your work moving. tool. Choose one and keep your work moving.
</p> </p>
</div>
</section> </section>
{loaded && (
<section className="tool-controls" aria-label="Find a tool">
<label className="search-field">
<span className="visually-hidden">Search tools</span>
<span aria-hidden="true"></span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tools"
type="search"
/>
</label>
<label className="category-field">
<span className="visually-hidden">Filter by category</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{categories.map((item) => (
<option value={item} key={item}>
{item}
</option>
))}
</select>
</label>
{activeTag && (
<button
className="active-filter"
type="button"
onClick={() => setActiveTag('')}
>
#{activeTag} <span aria-hidden="true">×</span>
<span className="visually-hidden">Clear tag filter</span>
</button>
)}
<label
className={`hidden-toggle${
preferences.hidden.length === 0
? ' hidden-toggle--disabled'
: ''
}`}
>
<input
ref={hiddenToggle}
type="checkbox"
checked={showHidden}
disabled={preferences.hidden.length === 0}
onChange={(event) =>
setShownHiddenIds(
event.target.checked ? [...preferences.hidden] : null
)
}
/>
Show hidden ({preferences.hidden.length})
</label>
</section>
)}
{loadState.status === 'loading' && ( {loadState.status === 'loading' && (
<section className="state-card" aria-live="polite"> <section className="state-card" aria-live="polite">
<div className="loader" aria-hidden="true" /> <div className="loader" aria-hidden="true" />
@@ -205,43 +395,6 @@ export default function App() {
{loaded && ( {loaded && (
<> <>
<section className="tool-controls" aria-label="Find a tool">
<label className="search-field">
<span className="visually-hidden">Search tools</span>
<span aria-hidden="true"></span>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search tools"
type="search"
/>
</label>
<label className="category-field">
<span className="visually-hidden">Filter by category</span>
<select
value={category}
onChange={(event) => setCategory(event.target.value)}
>
<option value="">All categories</option>
{categories.map((item) => (
<option value={item} key={item}>
{item}
</option>
))}
</select>
</label>
{preferences.hidden.length > 0 && (
<label className="hidden-toggle">
<input
type="checkbox"
checked={showHidden}
onChange={(event) => setShowHidden(event.target.checked)}
/>
Show hidden ({preferences.hidden.length})
</label>
)}
</section>
{apps.length === 0 && loaded.unavailable.length === 0 && ( {apps.length === 0 && loaded.unavailable.length === 0 && (
<section className="state-card"> <section className="state-card">
<span className="state-icon" aria-hidden="true"> <span className="state-icon" aria-hidden="true">
@@ -264,43 +417,47 @@ export default function App() {
<p> <p>
Try another search or category, or show your hidden tools. Try another search or category, or show your hidden tools.
</p> </p>
<button <button type="button" onClick={clearFilters}>
type="button"
onClick={() => {
setQuery('');
setCategory('');
setShowHidden(true);
}}
>
Clear filters Clear filters
</button> </button>
</section> </section>
)} )}
{visibleApps.length > 0 && ( {visibleApps.length > 0 && (
<section className="app-grid" aria-label="Available tools"> <DndContext
{visibleApps.map((app) => { sensors={sensors}
const pinned = preferences.pinned.includes(app.id); collisionDetection={closestCenter}
const peers = visibleApps.filter( onDragEnd={handleDragEnd}
(peer) => preferences.pinned.includes(peer.id) === pinned >
); <div className="app-sections" aria-label="Available tools">
return ( <AppSection
<AppCard id="pinned-apps"
key={app.id} title="Pinned"
app={app} apps={pinnedApps}
catalogueUrl={loaded.catalogueUrl.href} pinned
contextualUrl={contextualLaunchUrl} catalogueUrl={loaded.catalogueUrl.href}
pinned={pinned} hiddenIds={preferences.hidden}
hidden={preferences.hidden.includes(app.id)} onDetails={(id) =>
first={peers[0]?.id === app.id} setOverlay({ kind: 'details', appId: id })
last={peers.at(-1)?.id === app.id} }
onPin={() => toggleListItem('pinned', app.id)} onPin={(id) => toggleListItem('pinned', id)}
onHide={() => toggleListItem('hidden', app.id)} onHide={(id) => toggleListItem('hidden', id)}
onMove={(direction) => moveApp(app.id, direction)} />
/> <AppSection
); id="regular-apps"
})} title="Tools"
</section> apps={regularApps}
pinned={false}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
/>
</div>
</DndContext>
)} )}
{loaded.unavailable.length > 0 && ( {loaded.unavailable.length > 0 && (
@@ -332,9 +489,9 @@ export default function App() {
</span> </span>
</p> </p>
<span> <span>
Portal v0.1.0 ·{' '} Portal v0.2.23 ·{' '}
<a <a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0" href="https://git.add-ideas.de/lotobo/toolbox-portal/src/tag/v0.20.0"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -344,13 +501,26 @@ export default function App() {
</footer> </footer>
</div> </div>
<PreferencesPanel <HelpPanel open={overlay?.kind === 'help'} onClose={closeOverlay} />
open={settingsOpen} {detailsApp && loaded ? (
preferences={preferences} <AppDetailsPanel
storageAvailable={storageAvailable} app={detailsApp}
onChange={setPreferences} catalogueUrl={loaded.catalogueUrl.href}
onClose={closeSettings} contextualUrl={contextualLaunchUrl}
/> pinned={preferences.pinned.includes(detailsApp.id)}
hidden={preferences.hidden.includes(detailsApp.id)}
activeTag={activeTag}
onCategory={(nextCategory) => {
setCategory(nextCategory);
closeOverlay();
}}
onTag={(tag) => {
setActiveTag((current) => (current === tag ? '' : tag));
closeOverlay();
}}
onClose={closeOverlay}
/>
) : null}
</> </>
); );
} }
+10 -3
View File
@@ -5,17 +5,17 @@ import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
describe('catalogue loading', () => { describe('catalogue loading', () => {
it('loads and resolves an internal app using the shared contract', async () => { it('loads and resolves internal apps using the shared contract', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const loaded = await loadCatalogue('/toolbox.catalog.json'); const loaded = await loadCatalogue('/toolbox.catalog.json');
expect(loaded.catalogue.name).toBe('add·ideas Toolbox'); expect(loaded.catalogue.name).toBe('add·ideas Toolbox');
expect(loaded.homeUrl).toBe('http://localhost:3000/'); expect(loaded.homeUrl).toBe('http://localhost:3000/');
expect(loaded.apps).toHaveLength(1); expect(loaded.apps).toHaveLength(8);
expect(loaded.apps[0]).toMatchObject({ expect(loaded.apps[0]).toMatchObject({
id: 'de.add-ideas.pdf-tools', id: 'de.add-ideas.pdf-tools',
name: 'PDF Workbench', name: 'PDF Workbench',
entryUrl: 'http://localhost:3000/apps/pdf/', entryUrl: 'http://localhost:3000/apps/pdf/',
sourceUrl: 'https://git.add-ideas.de/zemion/pdf-tools', sourceUrl: 'https://git.add-ideas.de/lotobo/pdf-tools',
}); });
}); });
@@ -38,6 +38,13 @@ describe('catalogue loading', () => {
const loaded = await loadCatalogue('/toolbox.catalog.json'); const loaded = await loadCatalogue('/toolbox.catalog.json');
expect(loaded.apps.map((app) => app.id)).toEqual([ expect(loaded.apps.map((app) => app.id)).toEqual([
'de.add-ideas.pdf-tools', 'de.add-ideas.pdf-tools',
'de.add-ideas.xslt-tools',
'de.add-ideas.onenote-tools',
'de.add-ideas.svg-tools',
'de.add-ideas.auth-tools',
'de.add-ideas.sudoku-tools',
'de.add-ideas.colour-tools',
'de.add-ideas.office-tools',
]); ]);
expect(loaded.unavailable).toHaveLength(1); expect(loaded.unavailable).toHaveLength(1);
expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/); expect(loaded.unavailable[0]?.reason).toMatch(/HTTP 404/);
+146 -124
View File
@@ -1,3 +1,7 @@
import { useId } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { shortToolDescription, shortToolTitle } from '../toolPresentation';
import type { ResolvedApp } from '../types'; import type { ResolvedApp } from '../types';
interface AppCardProps { interface AppCardProps {
@@ -5,48 +9,74 @@ interface AppCardProps {
catalogueUrl: string; catalogueUrl: string;
hidden: boolean; hidden: boolean;
pinned: boolean; pinned: boolean;
first: boolean; onDetails: () => void;
last: boolean;
onHide: () => void; onHide: () => void;
onMove: (direction: -1 | 1) => void;
onPin: () => void; onPin: () => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string; contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
} }
function RequirementSummary({ app }: { app: ResolvedApp }) { function MoveIcon() {
if (!app.requirements) return null; return (
const requirements = [ <svg viewBox="0 0 20 80" aria-hidden="true">
app.requirements.secureContext && 'Secure context', <circle cx="7" cy="5" r="1.2" />
app.requirements.workers && 'Web workers', <circle cx="13" cy="5" r="1.2" />
app.requirements.indexedDb && 'IndexedDB', <circle cx="7" cy="10" r="1.2" />
app.requirements.crossOriginIsolated && 'Cross-origin isolation', <circle cx="13" cy="10" r="1.2" />
].filter(Boolean); <circle cx="7" cy="15" r="1.2" />
if (requirements.length === 0) return null; <circle cx="13" cy="15" r="1.2" />
return <p className="requirements">Needs {requirements.join(' · ')}</p>; <circle cx="7" cy="20" r="1.2" />
<circle cx="13" cy="20" r="1.2" />
<circle cx="7" cy="25" r="1.2" />
<circle cx="13" cy="25" r="1.2" />
<circle cx="7" cy="30" r="1.2" />
<circle cx="13" cy="30" r="1.2" />
<circle cx="7" cy="35" r="1.2" />
<circle cx="13" cy="35" r="1.2" />
<circle cx="7" cy="40" r="1.2" />
<circle cx="13" cy="40" r="1.2" />
<circle cx="7" cy="45" r="1.2" />
<circle cx="13" cy="45" r="1.2" />
<circle cx="7" cy="50" r="1.2" />
<circle cx="13" cy="50" r="1.2" />
<circle cx="7" cy="55" r="1.2" />
<circle cx="13" cy="55" r="1.2" />
<circle cx="7" cy="60" r="1.2" />
<circle cx="13" cy="60" r="1.2" />
<circle cx="7" cy="65" r="1.2" />
<circle cx="13" cy="65" r="1.2" />
<circle cx="7" cy="70" r="1.2" />
<circle cx="13" cy="70" r="1.2" />
<circle cx="7" cy="75" r="1.2" />
<circle cx="13" cy="75" r="1.2" />
</svg>
);
} }
function PrivacySummary({ app }: { app: ResolvedApp }) { function LightBulbIcon() {
if (!app.privacy)
return (
<p className="privacy-summary">
External destination · review its privacy terms
</p>
);
if (app.privacy.label)
return <p className="privacy-summary">{app.privacy.label}</p>;
const processing = {
local: 'Local processing',
remote: 'Remote processing',
mixed: 'Local and remote capabilities',
}[app.privacy.processing];
return ( return (
<p className="privacy-summary"> <svg viewBox="0 0 24 24" aria-hidden="true">
{processing} ·{' '} <path d="M9 18h6M10 21h4" />
{app.privacy.fileUploads <path d="M8.4 14.8A6 6 0 1 1 15.6 14.8c-.9.7-1.4 1.4-1.6 2.2h-4c-.2-.8-.7-1.5-1.6-2.2Z" />
? 'file/data transmission declared' </svg>
: 'no file uploads declared'}{' '} );
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'} }
</p>
function PinIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3h8l-1 6 3 3v2H6v-2l3-3-1-6Z" />
<path d="M12 14v7" />
</svg>
);
}
function VisibilityIcon({ hidden }: { hidden: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12s3.2-5 9-5 9 5 9 5-3.2 5-9 5-9-5-9-5Z" />
<circle cx="12" cy="12" r="2.3" />
{!hidden && <path d="m4 4 16 16" />}
</svg>
); );
} }
@@ -55,23 +85,49 @@ export function AppCard({
catalogueUrl, catalogueUrl,
hidden, hidden,
pinned, pinned,
first, onDetails,
last,
onHide, onHide,
onMove,
onPin, onPin,
contextualUrl, contextualUrl,
}: AppCardProps) { }: AppCardProps) {
const titleId = useId();
const {
attributes,
isDragging,
listeners,
setActivatorNodeRef,
setNodeRef,
transform,
transition,
} = useSortable({ id: app.id });
const launchUrl = app.isExternal const launchUrl = app.isExternal
? app.entryUrl ? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl); : contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank'; const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return ( return (
<article <article
className={`app-card${hidden ? ' app-card--hidden' : ''}`} ref={setNodeRef}
className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : ''
}`}
aria-labelledby={titleId}
data-testid={`app-card-${app.id}`} data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
> >
<div className="app-card__top"> <button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<MoveIcon />
</button>
<div className="app-card__face">
<div className="app-card__icon" aria-hidden="true"> <div className="app-card__icon" aria-hidden="true">
{app.iconUrl ? ( {app.iconUrl ? (
<img src={app.iconUrl} alt="" loading="lazy" /> <img src={app.iconUrl} alt="" loading="lazy" />
@@ -79,94 +135,60 @@ export function AppCard({
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span> <span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)} )}
</div> </div>
<div <h3 id={titleId}>
className="app-card__actions" <a
aria-label={`Personalize ${app.name}`} className="app-card__launch-link"
> href={launchUrl}
<button target={target}
className={`icon-button${pinned ? ' is-active' : ''}`} rel={target ? 'noopener noreferrer' : undefined}
type="button" aria-label={`Open ${app.name}`}
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
aria-pressed={pinned}
title={pinned ? 'Unpin' : 'Pin'}
onClick={onPin}
> >
<span aria-hidden="true"></span> {shortToolTitle(app)}
</button> </a>
<button </h3>
className="icon-button" <p>{shortToolDescription(app)}</p>
type="button"
aria-label={`Move ${app.name} earlier`}
disabled={first}
onClick={() => onMove(-1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={`Move ${app.name} later`}
disabled={last}
onClick={() => onMove(1)}
>
<span aria-hidden="true"></span>
</button>
<button
className="icon-button"
type="button"
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
onClick={onHide}
>
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
</button>
</div>
</div> </div>
<div className="app-card__body">
<div className="title-line"> <div
<h2>{app.name}</h2> className="app-card__actions"
{pinned && <span className="status-chip">Pinned</span>} role="group"
{hidden && ( aria-label={`Actions for ${app.name}`}
<span className="status-chip status-chip--muted">Hidden</span> >
)} <button
{app.isExternal && ( className="icon-button info-button"
<span className="status-chip status-chip--muted">External</span> type="button"
)} aria-label={`More information about ${app.name}`}
</div> aria-haspopup="dialog"
<p>{app.description}</p> title="More information"
<PrivacySummary app={app} /> data-details-trigger={app.id}
<RequirementSummary app={app} /> onClick={onDetails}
<div className="tag-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<span key={category}>{category}</span>
))}
</div>
</div>
<footer className="app-card__footer">
<div className="app-card__meta">
<span className="version">
{app.version ? `v${app.version}` : 'External service'}
</span>
{app.sourceUrl && (
<a
className="source-link"
href={app.sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
)}
</div>
<a
className="launch-button"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
> >
{target ? 'Open in new tab' : 'Open tool'}{' '} <LightBulbIcon />
<span aria-hidden="true"></span> </button>
</a> <button
</footer> className={`icon-button pin-button${pinned ? ' is-active' : ''}`}
type="button"
data-app-id={app.id}
data-card-action="pin"
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
aria-pressed={pinned}
title={pinned ? 'Unpin' : 'Pin'}
onClick={onPin}
>
<PinIcon />
</button>
<button
className="icon-button visibility-button"
type="button"
data-app-id={app.id}
data-card-action="visibility"
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
title={hidden ? 'Show' : 'Hide'}
onClick={onHide}
>
<VisibilityIcon hidden={hidden} />
</button>
</div>
</article> </article>
); );
} }
+172
View File
@@ -0,0 +1,172 @@
import { useId } from 'react';
import type { ResolvedApp } from '../types';
import { shortToolTitle } from '../toolPresentation';
import { ModalPanel } from './ModalPanel';
interface AppDetailsPanelProps {
activeTag: string;
app: ResolvedApp;
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
onCategory: (category: string) => void;
onClose: () => void;
onTag: (tag: string) => void;
}
function RequirementSummary({ app }: { app: ResolvedApp }) {
if (!app.requirements) return null;
const requirements = [
app.requirements.secureContext && 'Secure context',
app.requirements.workers && 'Web workers',
app.requirements.indexedDb && 'IndexedDB',
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
].filter(Boolean);
if (requirements.length === 0) return null;
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
}
function PrivacySummary({ app }: { app: ResolvedApp }) {
if (!app.privacy)
return (
<p className="privacy-summary">
External destination · review its privacy terms
</p>
);
if (app.privacy.label)
return <p className="privacy-summary">{app.privacy.label}</p>;
const processing = {
local: 'Local processing',
remote: 'Remote processing',
mixed: 'Local and remote capabilities',
}[app.privacy.processing];
return (
<p className="privacy-summary">
{processing} ·{' '}
{app.privacy.fileUploads
? 'file/data transmission declared'
: 'no file uploads declared'}{' '}
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
</p>
);
}
export function AppDetailsPanel({
activeTag,
app,
catalogueUrl,
hidden,
pinned,
contextualUrl,
onCategory,
onClose,
onTag,
}: AppDetailsPanelProps) {
const titleId = useId();
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<ModalPanel
className="app-info-panel"
labelledBy={titleId}
onClose={onClose}
>
<header className="app-info-panel__header">
<div className="app-info-panel__identity">
<div className="app-info-panel__icon" aria-hidden="true">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" />
) : (
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<div>
<p className="eyebrow">Tool information</p>
<div className="title-line">
<h2 id={titleId}>{app.name}</h2>
{pinned && <span className="status-chip">Pinned</span>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
)}
{app.isExternal && (
<span className="status-chip status-chip--muted">External</span>
)}
</div>
</div>
</div>
<button
className="close-button"
type="button"
aria-label={`Close information for ${app.name}`}
onClick={onClose}
>
×
</button>
</header>
<div className="app-info-panel__body">
<p className="app-info-panel__description">{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
{app.categories.length > 0 && (
<div className="category-row" role="group" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))}
</div>
)}
{app.tags.length > 0 && (
<div className="tag-row" role="group" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div>
<footer className="app-info-panel__footer">
<div className="app-info-panel__meta">
<span className="version">
{app.version ? `v${app.version}` : 'External service'}
</span>
{app.sourceUrl && (
<a
className="source-link"
href={app.sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
)}
</div>
<a
className="app-info-panel__launch"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
Open {shortToolTitle(app)} <span aria-hidden="true"></span>
</a>
</footer>
</ModalPanel>
);
}
+51
View File
@@ -0,0 +1,51 @@
import { ModalPanel } from './ModalPanel';
interface HelpPanelProps {
open: boolean;
onClose: () => void;
}
export function HelpPanel({ open, onClose }: HelpPanelProps) {
if (!open) return null;
return (
<ModalPanel
className="help-panel"
labelledBy="help-title"
onClose={onClose}
>
<header>
<div>
<p className="eyebrow">Toolbox guide</p>
<h2 id="help-title">Choose and arrange your tools</h2>
</div>
<button
className="close-button"
type="button"
aria-label="Close help"
onClick={onClose}
>
×
</button>
</header>
<div className="help-steps">
<p>
<strong>Open:</strong> click anywhere on a tool tile that is not a
control.
</p>
<p>
<strong>Details:</strong> use the light bulb to review privacy,
requirements, categories, tags, version, and source before opening.
</p>
<p>
<strong>Filter:</strong> search, choose a category, or select a tag
from a tool's information panel. Select the tag again to clear it.
</p>
<p>
<strong>Arrange:</strong> pin frequently used tools into their own
section, then drag the grip to reorder. The grip also supports
keyboard dragging with Space, arrow keys, and Escape.
</p>
</div>
</ModalPanel>
);
}
+102
View File
@@ -0,0 +1,102 @@
import { useEffect, useRef, type ReactNode } from 'react';
interface ModalPanelProps {
children: ReactNode;
className: string;
labelledBy: string;
onClose: () => void;
}
export function ModalPanel({
children,
className,
labelledBy,
onClose,
}: ModalPanelProps) {
const panel = useRef<HTMLElement>(null);
useEffect(() => {
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusable = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => !element.hasAttribute('disabled')
)
: [];
(focusable()[0] ?? dialog)?.focus();
const previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusable();
const first = elements[0];
const last = elements.at(-1);
if (!first || !last) {
event.preventDefault();
dialog?.focus();
return;
}
const active = document.activeElement;
if (!dialog?.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.documentElement.style.overflow = previousOverflow;
if (returnFocus?.isConnected) returnFocus.focus();
};
}, [onClose]);
return (
<div
className="dialog-backdrop"
role="presentation"
onPointerDown={(event) =>
event.target === event.currentTarget && onClose()
}
>
<section
ref={panel}
className={className}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
tabIndex={-1}
>
{children}
</section>
</div>
);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
+56 -181
View File
@@ -1,93 +1,25 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useRef, useState, type ChangeEvent } from 'react';
import { ToolboxPersonalizePanel } from '@add-ideas/toolbox-shell-react';
import { import {
defaultPreferences, defaultPreferences,
parsePreferences, parsePreferences,
type Preferences, type Preferences,
} from '../preferences'; } from '../preferences';
import type { ThemeMode } from '../types';
interface PreferencesPanelProps { interface PreferencesPanelProps {
open: boolean;
preferences: Preferences; preferences: Preferences;
storageAvailable: boolean; storageAvailable: boolean;
onChange: (preferences: Preferences) => void; onChange: (preferences: Preferences) => void;
onClose: () => void;
} }
export function PreferencesPanel({ export function PreferencesPanel({
open,
preferences, preferences,
storageAvailable, storageAvailable,
onChange, onChange,
onClose,
}: PreferencesPanelProps) { }: PreferencesPanelProps) {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const panel = useRef<HTMLElement>(null);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
useEffect(() => {
if (!open) return;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusableElements = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => !element.hasAttribute('disabled')
)
: [];
focusableElements()[0]?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) {
event.preventDefault();
dialog?.focus();
return;
}
const first = elements[0]!;
const last = elements.at(-1)!;
const activeElement = document.activeElement;
if (
event.shiftKey &&
(activeElement === first || !dialog?.contains(activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(activeElement === last || !dialog?.contains(activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
returnFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
function setTheme(theme: ThemeMode) {
onChange({ ...preferences, theme });
}
function exportPreferences() { function exportPreferences() {
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], { const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
type: 'application/json', type: 'application/json',
@@ -119,119 +51,62 @@ export function PreferencesPanel({
} }
return ( return (
<div <ToolboxPersonalizePanel
className="dialog-backdrop" theme={preferences.theme}
role="presentation" onThemeChange={(theme) => onChange({ ...preferences, theme })}
onMouseDown={(event) => event.target === event.currentTarget && onClose()} storageAvailable={storageAvailable}
> >
<section <div className="preference-summary">
ref={panel} <div>
className="preferences-panel" <strong>{preferences.pinned.length}</strong>
role="dialog" <span>Pinned</span>
aria-modal="true"
aria-labelledby="preferences-title"
tabIndex={-1}
>
<header>
<div>
<p className="eyebrow">On this device</p>
<h2 id="preferences-title">Personalize your toolbox</h2>
</div>
<button
className="close-button"
type="button"
aria-label="Close preferences"
onClick={onClose}
>
×
</button>
</header>
{!storageAvailable && (
<p className="inline-warning" role="status">
Browser storage is unavailable. Changes will last only until this
page closes.
</p>
)}
<fieldset className="theme-choice">
<legend>Appearance</legend>
<div>
{(['system', 'light', 'dark'] as const).map((theme) => (
<button
key={theme}
type="button"
aria-pressed={preferences.theme === theme}
className={preferences.theme === theme ? 'is-selected' : ''}
onClick={() => setTheme(theme)}
>
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
</button>
))}
</div>
</fieldset>
<div className="preference-summary">
<div>
<strong>{preferences.pinned.length}</strong>
<span>Pinned</span>
</div>
<div>
<strong>{preferences.hidden.length}</strong>
<span>Hidden</span>
</div>
<div>
<strong>{preferences.order.length}</strong>
<span>Ordered</span>
</div>
</div> </div>
<div>
<p className="preferences-copy"> <strong>{preferences.hidden.length}</strong>
The portal stores pins, visibility, order, and appearance in this <span>Hidden</span>
browser and does not transmit them. Trusted code in another Toolbox
app on this site can access same-origin storage. Export a JSON backup
to move them to another device.
</p>
<div className="preferences-actions">
<button type="button" onClick={exportPreferences}>
Export JSON
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Import JSON
</button>
<input
ref={fileInput}
className="visually-hidden"
type="file"
tabIndex={-1}
accept="application/json,.json"
onChange={importPreferences}
/>
<button
className="danger-button"
type="button"
onClick={() => {
onChange({ ...defaultPreferences });
setMessage('Preferences reset.');
}}
>
Reset
</button>
</div> </div>
<p className="action-message" role="status" aria-live="polite"> <div>
{message} <strong>{preferences.order.length}</strong>
</p> <span>Ordered</span>
</section> </div>
</div> </div>
<p className="preferences-copy">
The portal stores pins, visibility, order, and appearance in this
browser and does not transmit them. Trusted code in another Toolbox app
on this site can access same-origin storage. Export a JSON backup to
move them to another device.
</p>
<div className="preferences-actions">
<button type="button" onClick={exportPreferences}>
Export JSON
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Import JSON
</button>
<input
ref={fileInput}
className="visually-hidden"
type="file"
tabIndex={-1}
accept="application/json,.json"
onChange={importPreferences}
/>
<button
className="danger-button"
type="button"
onClick={() => {
onChange({ ...defaultPreferences });
setMessage('Preferences reset.');
}}
>
Reset
</button>
</div>
<p className="action-message" role="status" aria-live="polite">
{message}
</p>
</ToolboxPersonalizePanel>
); );
} }
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'; import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client'; import { createRoot } from 'react-dom/client';
import '@add-ideas/toolbox-shell-react/styles.css';
import App from './App'; import App from './App';
import './styles.css'; import './styles.css';
+29 -7
View File
@@ -3,9 +3,9 @@ import {
PREFERENCES_KEY, PREFERENCES_KEY,
cleanPreferences, cleanPreferences,
defaultPreferences, defaultPreferences,
moveId,
parsePreferences, parsePreferences,
readPreferences, readPreferences,
reorderWithinIds,
sortAppIds, sortAppIds,
writePreferences, writePreferences,
} from './preferences'; } from './preferences';
@@ -51,7 +51,7 @@ describe('preferences', () => {
).toThrow(/pinned/i); ).toThrow(/pinned/i);
}); });
it('cleans stale ids, pins first, and supports personal ordering', () => { it('cleans stale ids and applies personal ordering without mixing sections', () => {
const cleaned = cleanPreferences( const cleaned = cleanPreferences(
{ {
version: 1, version: 1,
@@ -65,14 +65,36 @@ describe('preferences', () => {
expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']); expect(cleaned.order).toEqual(['app.two', 'app.one', 'app.three']);
expect(cleaned.hidden).toEqual([]); expect(cleaned.hidden).toEqual([]);
expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([ expect(sortAppIds(['app.one', 'app.two', 'app.three'], cleaned)).toEqual([
'app.three',
'app.two', 'app.two',
'app.one', 'app.one',
]);
expect(moveId(cleaned.order, 'app.one', -1)).toEqual([
'app.one',
'app.two',
'app.three', 'app.three',
]); ]);
expect(
reorderWithinIds(
['pin.one', 'tool.one', 'pin.two', 'tool.two'],
'pin.two',
'pin.one',
['pin.one', 'pin.two']
)
).toEqual(['pin.two', 'tool.one', 'pin.one', 'tool.two']);
expect(
reorderWithinIds(cleaned.order, 'app.one', 'app.two', [
'app.two',
'app.one',
])
).toEqual(['app.one', 'app.two', 'app.three']);
});
it('does not reorder across peer groups or for an unknown drop target', () => {
const order = ['pin.one', 'tool.one', 'pin.two'];
expect(
reorderWithinIds(order, 'pin.one', 'tool.one', ['pin.one', 'pin.two'])
).toBe(order);
expect(
reorderWithinIds(order, 'missing', 'pin.one', ['pin.one', 'pin.two'])
).toBe(order);
expect(reorderWithinIds(order, 'pin.one', 'pin.one', ['pin.one'])).toBe(
order
);
}); });
}); });
+40 -66
View File
@@ -1,66 +1,31 @@
import type { ThemeMode } from './types'; import {
TOOLBOX_PREFERENCES_KEY,
export const PREFERENCES_KEY = '@add-ideas/toolbox-portal:v1:preferences'; defaultToolboxPreferences,
parseToolboxPreferences,
export interface Preferences { readToolboxPreferences,
version: 1; writeToolboxPreferences,
pinned: string[]; type ToolboxPreferences,
order: string[]; } from '@add-ideas/toolbox-contract';
hidden: string[];
theme: ThemeMode;
}
export const PREFERENCES_KEY = TOOLBOX_PREFERENCES_KEY;
export type Preferences = ToolboxPreferences;
export const defaultPreferences: Preferences = { export const defaultPreferences: Preferences = {
version: 1, ...defaultToolboxPreferences,
pinned: [], pinned: [],
order: [], order: [],
hidden: [], hidden: [],
theme: 'system',
}; };
export const parsePreferences = parseToolboxPreferences;
function uniqueStrings(value: unknown, field: string): string[] {
if (
!Array.isArray(value) ||
value.some((item) => typeof item !== 'string' || item.length === 0)
)
throw new Error(`${field} must be an array of non-empty strings.`);
return [...new Set(value)];
}
export function parsePreferences(value: unknown): Preferences {
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('Preferences must be a JSON object.');
}
const candidate = value as Record<string, unknown>;
if (candidate.version !== 1)
throw new Error('Unsupported preferences version.');
const theme = candidate.theme;
if (!['light', 'dark', 'system'].includes(String(theme)))
throw new Error('Invalid theme preference.');
return {
version: 1,
pinned: uniqueStrings(candidate.pinned, 'pinned'),
order: uniqueStrings(candidate.order, 'order'),
hidden: uniqueStrings(candidate.hidden, 'hidden'),
theme: theme as ThemeMode,
};
}
export function readPreferences(storage: Storage = localStorage): Preferences { export function readPreferences(storage: Storage = localStorage): Preferences {
const value = storage.getItem(PREFERENCES_KEY); return readToolboxPreferences(storage);
if (!value) return { ...defaultPreferences };
try {
return parsePreferences(JSON.parse(value));
} catch {
return { ...defaultPreferences };
}
} }
export function writePreferences( export function writePreferences(
preferences: Preferences, preferences: Preferences,
storage: Storage = localStorage storage: Storage = localStorage
): void { ): void {
storage.setItem(PREFERENCES_KEY, JSON.stringify(preferences)); writeToolboxPreferences(preferences, storage);
} }
export function cleanPreferences( export function cleanPreferences(
@@ -84,27 +49,36 @@ export function sortAppIds(
preferences: Preferences preferences: Preferences
): string[] { ): string[] {
const order = new Map(preferences.order.map((id, index) => [id, index])); const order = new Map(preferences.order.map((id, index) => [id, index]));
const pinned = new Set(preferences.pinned); return [...appIds].sort(
return [...appIds].sort((left, right) => { (left, right) =>
const pinDifference = Number(pinned.has(right)) - Number(pinned.has(left));
if (pinDifference) return pinDifference;
return (
(order.get(left) ?? Number.MAX_SAFE_INTEGER) - (order.get(left) ?? Number.MAX_SAFE_INTEGER) -
(order.get(right) ?? Number.MAX_SAFE_INTEGER) (order.get(right) ?? Number.MAX_SAFE_INTEGER)
); );
});
} }
export function moveId( export function reorderWithinIds(
order: string[], order: string[],
id: string, activeId: string,
direction: -1 | 1 overId: string,
peerIds: readonly string[]
): string[] { ): string[] {
const current = order.indexOf(id); if (activeId === overId) return order;
if (current < 0) return order; const peers = new Set(peerIds);
const target = current + direction; if (!peers.has(activeId) || !peers.has(overId)) return order;
if (target < 0 || target >= order.length) return order;
const next = [...order]; const orderedPeers = order.filter((id) => peers.has(id));
[next[current], next[target]] = [next[target]!, next[current]!]; for (const id of peerIds) {
return next; if (!orderedPeers.includes(id)) orderedPeers.push(id);
}
const from = orderedPeers.indexOf(activeId);
const to = orderedPeers.indexOf(overId);
if (from < 0 || to < 0) return order;
const [moved] = orderedPeers.splice(from, 1);
if (!moved) return order;
orderedPeers.splice(to, 0, moved);
let peerIndex = 0;
return order.map((id) =>
peers.has(id) ? (orderedPeers[peerIndex++] ?? id) : id
);
} }
+452 -224
View File
@@ -17,6 +17,10 @@
--shadow: 0 16px 44px rgba(24, 34, 68, 0.08); --shadow: 0 16px 44px rgba(24, 34, 68, 0.08);
} }
:root[data-theme='light'] {
color-scheme: light;
}
:root[data-theme='dark'] { :root[data-theme='dark'] {
color-scheme: dark; color-scheme: dark;
--bg: #0f1422; --bg: #0f1422;
@@ -119,63 +123,13 @@ summary:focus-visible {
transform: translateY(0); transform: translateY(0);
} }
.site-header {
position: sticky;
z-index: 20;
top: 0;
border-bottom: 1px solid color-mix(in srgb, var(--line) 70%, transparent);
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(16px);
}
.header-inner,
main, main,
.site-footer { .site-footer {
width: min(1160px, calc(100% - 2.5rem)); width: min(100%, 90rem);
margin-inline: auto; margin-inline: auto;
padding-inline: 1rem;
} }
.header-inner {
min-height: 74px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: 0.65rem;
color: var(--text);
text-decoration: none;
}
.brand .logo {
color: var(--brand);
}
.brand > span {
display: grid;
line-height: 1.05;
}
.brand strong {
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
font-size: 1.03rem;
letter-spacing: -0.025em;
}
.brand small {
margin-top: 0.22rem;
color: var(--muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.17em;
text-transform: uppercase;
}
.settings-button,
.state-card button, .state-card button,
.preferences-actions button { .preferences-actions button {
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -185,11 +139,6 @@ main,
font-weight: 600; font-weight: 600;
} }
.settings-button {
padding: 0.62rem 0.82rem;
}
.settings-button:hover,
.state-card button:hover, .state-card button:hover,
.preferences-actions button:hover { .preferences-actions button:hover {
border-color: var(--brand); border-color: var(--brand);
@@ -201,8 +150,15 @@ main {
} }
.hero { .hero {
max-width: 750px; display: grid;
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem); grid-template-columns: 1fr;
align-items: end;
gap: clamp(2rem, 6vw, 5rem);
padding: clamp(3.5rem, 7vw, 6.8rem) 0 clamp(2.5rem, 5vw, 4.2rem);
}
.hero-intro {
max-width: 100%;
} }
.eyebrow { .eyebrow {
@@ -214,7 +170,7 @@ main {
text-transform: uppercase; text-transform: uppercase;
} }
.hero h1 { .hero h2 {
margin: 0; margin: 0;
font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif; font-family: Inter, Avenir, 'Segoe UI', ui-sans-serif, sans-serif;
font-size: clamp(2.65rem, 7vw, 5.65rem); font-size: clamp(2.65rem, 7vw, 5.65rem);
@@ -222,7 +178,7 @@ main {
letter-spacing: -0.065em; letter-spacing: -0.065em;
} }
.hero h1 span { .hero h2 span {
color: var(--brand); color: var(--brand);
} }
@@ -235,10 +191,10 @@ main {
} }
.tool-controls { .tool-controls {
display: flex; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(8.75rem, 10.5rem);
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
margin-bottom: 1.5rem;
} }
.search-field, .search-field,
@@ -252,7 +208,7 @@ main {
} }
.search-field { .search-field {
width: min(360px, 100%); min-width: 0;
padding: 0 0.85rem; padding: 0 0.85rem;
gap: 0.55rem; gap: 0.55rem;
} }
@@ -275,7 +231,7 @@ main {
} }
.category-field { .category-field {
min-width: 175px; min-width: 0;
padding: 0 0.65rem; padding: 0 0.65rem;
} }
@@ -284,72 +240,137 @@ main {
} }
.hidden-toggle { .hidden-toggle {
grid-column: 2;
display: flex; display: flex;
align-items: center; align-items: center;
justify-self: end;
gap: 0.45rem; gap: 0.45rem;
margin-left: auto;
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
font-size: 0.88rem; font-size: 0.88rem;
} }
.hidden-toggle--disabled {
cursor: default;
opacity: 0.58;
}
.hidden-toggle input { .hidden-toggle input {
width: 1.05rem; width: 1.05rem;
height: 1.05rem; height: 1.05rem;
accent-color: var(--brand); accent-color: var(--brand);
} }
.active-filter {
grid-column: 1;
justify-self: start;
padding: 0.4rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--line));
border-radius: 999px;
background: var(--brand-soft);
color: var(--brand);
font-size: 0.78rem;
font-weight: 700;
cursor: pointer;
}
.app-sections {
display: grid;
gap: 2.4rem;
}
.app-section__header {
display: flex;
align-items: baseline;
gap: 0.65rem;
margin-bottom: 0.85rem;
}
.app-section__header h2 {
margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.app-section__header span {
min-width: 1.55rem;
padding: 0.16rem 0.4rem;
border-radius: 999px;
background: var(--surface-soft);
color: var(--muted);
font-size: 0.72rem;
font-weight: 700;
text-align: center;
}
.app-grid { .app-grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 330px), 1fr)); grid-template-columns: repeat(
gap: 1.1rem; auto-fill,
padding-bottom: 2rem; minmax(min(10.5rem, 100%), 11.75rem)
);
align-items: start;
justify-content: start;
gap: 0.9rem;
} }
.app-card { .app-card {
min-height: 340px; position: relative;
aspect-ratio: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: space-between;
overflow: hidden; overflow: hidden;
padding: 1rem 0.85rem 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1.2rem; border-radius: 1rem;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transition:
border-color 180ms ease, border-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease; box-shadow 180ms ease;
} }
.app-card--dragging {
z-index: 5;
opacity: 0.82;
box-shadow: 0 24px 65px rgba(24, 34, 68, 0.22);
}
.app-card:hover { .app-card:hover {
border-color: color-mix(in srgb, var(--brand) 35%, var(--line)); border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
transform: translateY(-2px);
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13); box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
} }
.app-card--hidden { .app-card--hidden {
border-style: dashed; border-style: dashed;
opacity: 0.7; background: color-mix(in srgb, var(--surface-soft) 55%, var(--surface));
} }
.app-card__top { .app-card__face {
display: flex; min-height: 0;
align-items: flex-start; display: grid;
justify-content: space-between; flex: 1;
padding: 1.35rem 1.35rem 0; align-content: center;
justify-items: center;
padding: 0.2rem 0.25rem 0;
text-align: center;
} }
.app-card__icon { .app-card__icon {
width: 52px; width: 3.6rem;
height: 52px; height: 3.6rem;
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border-radius: 0.95rem; border-radius: 0.9rem;
background: var(--brand-soft); background: var(--brand-soft);
color: var(--brand); color: var(--brand);
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.45rem; font-size: 1.35rem;
font-weight: 700; font-weight: 700;
} }
@@ -359,40 +380,130 @@ main {
object-fit: contain; object-fit: contain;
} }
.app-card__face h3 {
margin: 0.7rem 0 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.05rem;
letter-spacing: -0.025em;
}
.app-card__face p {
display: -webkit-box;
margin: 0.35rem 0 0;
overflow: hidden;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.drag-handle {
position: absolute;
z-index: 2;
top: 50%;
left: 0.35rem;
width: 1rem;
height: 5rem;
display: grid;
place-items: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--surface);
color: var(--muted);
opacity: 0;
cursor: grab;
pointer-events: none;
touch-action: none;
transform: translate(-0.2rem, -50%);
transition:
opacity 150ms ease,
color 150ms ease,
transform 150ms ease;
}
.app-card:hover .drag-handle,
.app-card:focus-within .drag-handle,
.drag-handle:focus-visible {
opacity: 1;
pointer-events: auto;
transform: translate(0, -50%);
}
.drag-handle:hover {
color: var(--brand);
}
.drag-handle:active {
cursor: grabbing;
}
.drag-handle svg {
width: calc(1rem - 2px);
height: 4rem;
fill: currentColor;
}
.app-card__actions { .app-card__actions {
position: relative;
z-index: 2;
display: flex; display: flex;
gap: 0.15rem; align-items: center;
justify-content: center;
gap: 0.35rem;
pointer-events: none;
} }
.icon-button, .icon-button,
.close-button { .close-button {
width: 32px; width: 2.15rem;
height: 32px; height: 2.15rem;
display: grid; display: grid;
place-items: center; place-items: center;
border: 0; padding: 0;
border: 1px solid var(--line);
border-radius: 50%; border-radius: 50%;
background: transparent; background: var(--surface-soft);
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
} }
.app-card__actions .icon-button {
pointer-events: auto;
}
.icon-button:hover:not(:disabled), .icon-button:hover:not(:disabled),
.icon-button.is-active { .icon-button.is-active {
border-color: color-mix(in srgb, var(--brand) 30%, var(--line));
background: var(--brand-soft); background: var(--brand-soft);
color: var(--brand); color: var(--brand);
} }
.icon-button svg {
width: 1.05rem;
height: 1.05rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.pin-button svg {
transform: rotate(0);
transition: transform 180ms ease;
}
.pin-button.is-active svg {
transform: rotate(45deg);
}
.icon-button:disabled { .icon-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.28; opacity: 0.28;
} }
.app-card__body {
flex: 1;
padding: 1.25rem 1.35rem 1rem;
}
.title-line { .title-line {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -403,12 +514,36 @@ main {
.title-line h2 { .title-line h2 {
margin: 0; margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.3rem; font-size: 1.35rem;
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.app-card__launch-link {
color: var(--text);
text-decoration: none;
}
.app-card__launch-link::after {
position: absolute;
z-index: 1;
inset: 0;
content: '';
}
.app-card__launch-link:focus-visible {
outline: none;
}
.app-card:has(.app-card__launch-link:focus-visible) {
outline: 3px solid color-mix(in srgb, var(--accent) 70%, var(--brand));
outline-offset: 3px;
}
.status-chip, .status-chip,
.tag-row span { .tag-row button,
.category-row button {
position: relative;
z-index: 2;
border-radius: 999px; border-radius: 999px;
background: var(--brand-soft); background: var(--brand-soft);
color: var(--brand); color: var(--brand);
@@ -419,6 +554,7 @@ main {
.status-chip { .status-chip {
padding: 0.22rem 0.48rem; padding: 0.22rem 0.48rem;
text-transform: uppercase; text-transform: uppercase;
pointer-events: none;
} }
.status-chip--muted { .status-chip--muted {
@@ -426,43 +562,37 @@ main {
color: var(--muted); color: var(--muted);
} }
.app-card__body > p { .tag-row,
margin: 0.75rem 0 0; .category-row {
color: var(--muted);
line-height: 1.6;
}
.app-card__body .requirements {
font-size: 0.72rem;
}
.app-card__body .privacy-summary {
color: var(--text);
font-size: 0.76rem;
font-weight: 650;
}
.tag-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 0.4rem; gap: 0.4rem;
margin-top: 1rem; margin-top: 1rem;
} }
.tag-row span { .tag-row button,
.category-row button {
padding: 0.28rem 0.52rem; padding: 0.28rem 0.52rem;
border: 0;
background: var(--surface-soft); background: var(--surface-soft);
color: var(--muted); color: var(--muted);
font: inherit;
cursor: pointer;
} }
.app-card__footer { .category-row {
min-height: 68px; margin-top: 1rem;
display: flex; }
align-items: center;
justify-content: space-between; .tag-row {
gap: 1rem; margin-top: 0.55rem;
padding: 0.85rem 1.35rem; }
border-top: 1px solid var(--line);
.tag-row button:hover,
.category-row button:hover,
.tag-row button[aria-pressed='true'] {
background: var(--brand-soft);
color: var(--brand);
} }
.version { .version {
@@ -470,37 +600,13 @@ main {
font-size: 0.78rem; font-size: 0.78rem;
} }
.app-card__meta {
display: grid;
gap: 0.25rem;
}
.source-link, .source-link,
.site-footer a { .site-footer a {
position: relative;
z-index: 2;
color: var(--brand); color: var(--brand);
} }
.launch-button {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.62rem 0.82rem;
border-radius: 0.65rem;
background: var(--brand);
color: var(--surface);
font-size: 0.84rem;
font-weight: 700;
text-decoration: none;
}
:root[data-theme='dark'] .launch-button {
color: #11182a;
}
.launch-button:hover {
background: var(--brand-hover);
}
.state-card { .state-card {
max-width: 600px; max-width: 600px;
margin: 0 auto 5rem; margin: 0 auto 5rem;
@@ -604,14 +710,16 @@ main {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
overflow-y: auto;
padding: 1rem; padding: 1rem;
background: rgba(5, 9, 22, 0.56); background: rgba(5, 9, 22, 0.56);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.preferences-panel { .help-panel,
width: min(560px, 100%); .app-info-panel {
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
overflow: auto; overflow: auto;
padding: 1.5rem; padding: 1.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -620,23 +728,152 @@ main {
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28); box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
} }
.preferences-panel > header { .help-panel {
width: min(560px, 100%);
}
.app-info-panel {
position: relative;
width: min(680px, 100%);
}
.help-panel > header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
} }
.preferences-panel h2 { .help-panel h2 {
margin: 0; margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.preferences-panel .eyebrow { .app-info-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.app-info-panel__identity {
min-width: 0;
display: flex;
align-items: center;
gap: 1rem;
}
.app-info-panel__icon {
width: 4rem;
height: 4rem;
display: grid;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 1rem;
background: var(--brand-soft);
color: var(--brand);
font-size: 1.45rem;
font-weight: 700;
}
.app-info-panel__icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.app-info-panel .eyebrow {
margin-bottom: 0.4rem;
}
.app-info-panel__body {
margin-top: 1.35rem;
padding: 1.1rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--surface);
}
.app-info-panel__description {
margin: 0;
color: var(--muted);
font-size: 1rem;
line-height: 1.6;
}
.app-info-panel__body .privacy-summary {
margin: 1rem 0 0;
color: var(--text);
font-size: 0.8rem;
font-weight: 650;
line-height: 1.5;
}
.app-info-panel__body .requirements {
margin: 0.55rem 0 0;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.5;
}
.app-info-panel__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
}
.app-info-panel__meta {
display: grid;
gap: 0.25rem;
}
.app-info-panel__launch {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.7rem 0.9rem;
border-radius: 0.7rem;
background: var(--brand);
color: var(--surface) !important;
font-size: 0.85rem;
font-weight: 700;
}
.app-info-panel__launch:hover {
background: var(--brand-hover);
text-decoration: none;
}
.help-panel .eyebrow {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.help-steps {
display: grid;
gap: 0.85rem;
margin-top: 1.4rem;
}
.help-steps p {
margin: 0;
padding: 0.9rem 1rem;
border: 1px solid var(--line);
border-radius: 0.75rem;
background: var(--surface-soft);
color: var(--muted);
font-size: 0.9rem;
line-height: 1.55;
}
.help-steps strong {
color: var(--text);
}
.close-button { .close-button {
flex: 0 0 auto; flex: 0 0 auto;
background: var(--surface-soft); background: var(--surface-soft);
@@ -644,44 +881,6 @@ main {
font-size: 1.3rem; font-size: 1.3rem;
} }
.theme-choice {
margin: 1.5rem 0;
padding: 0;
border: 0;
}
.theme-choice legend {
margin-bottom: 0.6rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.theme-choice > div {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.45rem;
padding: 0.35rem;
border-radius: 0.75rem;
background: var(--surface-soft);
}
.theme-choice button {
padding: 0.62rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
cursor: pointer;
}
.theme-choice button.is-selected {
background: var(--surface);
color: var(--brand);
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
font-weight: 700;
}
.preference-summary { .preference-summary {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -691,7 +890,7 @@ main {
.preference-summary > div { .preference-summary > div {
display: grid; display: grid;
gap: 0.2rem; gap: 0.2rem;
padding: 0.9rem; padding: 0.65rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 0.7rem; border-radius: 0.7rem;
text-align: center; text-align: center;
@@ -699,7 +898,7 @@ main {
.preference-summary strong { .preference-summary strong {
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.25rem; font-size: 1.05rem;
} }
.preference-summary span, .preference-summary span,
@@ -709,8 +908,11 @@ main {
font-size: 0.8rem; font-size: 0.8rem;
} }
.preferences-copy { .portal-header .preferences-copy {
margin: 1.1rem 0; margin: 0.9rem 0;
padding: 0;
color: var(--toolbox-muted, var(--muted));
font-size: 0.78rem;
line-height: 1.55; line-height: 1.55;
} }
@@ -721,7 +923,7 @@ main {
} }
.preferences-actions button { .preferences-actions button {
padding: 0.62rem 0.78rem; padding: 0.52rem 0.68rem;
} }
.preferences-actions .danger-button { .preferences-actions .danger-button {
@@ -729,17 +931,12 @@ main {
color: var(--danger); color: var(--danger);
} }
.action-message { .portal-header .action-message {
min-height: 1.2em; min-height: 1.2em;
margin: 0.8rem 0 0; margin: 0.8rem 0 0;
} padding: 0;
color: var(--toolbox-muted, var(--muted));
.inline-warning { font-size: 0.78rem;
padding: 0.7rem 0.8rem;
border-radius: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger);
font-size: 0.82rem;
} }
.visually-hidden { .visually-hidden {
@@ -754,31 +951,19 @@ main {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.header-inner,
main, main,
.site-footer { .site-footer {
width: min(100% - 1.4rem, 1160px); width: min(100%, 90rem);
} padding-inline: 0.7rem;
.header-inner {
min-height: 66px;
}
.settings-button {
font-size: 0;
}
.settings-button span {
font-size: 1.15rem;
} }
.hero { .hero {
grid-template-columns: 1fr;
padding-top: 3.8rem; padding-top: 3.8rem;
} }
.tool-controls { .tool-controls {
align-items: stretch; width: 100%;
flex-direction: column;
} }
.search-field, .search-field,
@@ -790,10 +975,6 @@ main {
margin: 0.25rem 0; margin: 0.25rem 0;
} }
.app-card {
min-height: 320px;
}
.site-footer { .site-footer {
min-height: 130px; min-height: 130px;
align-items: flex-start; align-items: flex-start;
@@ -802,6 +983,53 @@ main {
} }
} }
@media (max-width: 520px) {
.tool-controls {
grid-template-columns: 1fr;
}
.search-field,
.category-field,
.active-filter,
.hidden-toggle {
grid-column: 1;
}
.hidden-toggle {
justify-self: start;
}
.app-info-panel__header,
.app-info-panel__footer {
align-items: flex-start;
flex-direction: column;
}
.app-info-panel__header .close-button {
position: absolute;
top: 1.1rem;
right: 1.1rem;
}
.app-info-panel__identity {
padding-right: 2.5rem;
}
}
@media (min-width: 701px) and (max-width: 960px) {
.hero {
grid-template-columns: 1fr;
}
}
@media (hover: none) {
.drag-handle {
opacity: 1;
pointer-events: auto;
transform: translate(0, -50%);
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,
+265 -3
View File
@@ -7,7 +7,7 @@ export const pdfManifest: ToolboxAppManifest = {
schemaVersion: 1, schemaVersion: 1,
id: 'de.add-ideas.pdf-tools', id: 'de.add-ideas.pdf-tools',
name: 'PDF Workbench', name: 'PDF Workbench',
version: '0.3.4', version: '0.4.3',
description: 'Page-level PDF operations performed locally in the browser.', description: 'Page-level PDF operations performed locally in the browser.',
entry: './', entry: './',
icon: './favicon.svg', icon: './favicon.svg',
@@ -30,18 +30,264 @@ export const pdfManifest: ToolboxAppManifest = {
telemetry: false, telemetry: false,
}, },
source: { source: {
repository: 'https://git.add-ideas.de/zemion/pdf-tools', repository: 'https://git.add-ideas.de/lotobo/pdf-tools',
license: 'AGPL-3.0-only', license: 'AGPL-3.0-only',
}, },
}; };
export const xsltManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.xslt-tools',
name: 'XSLT Workbench',
version: '0.4.2',
description: 'Transform XML locally with XSLT in the browser.',
icon: './xslt.svg',
categories: ['documents', 'developer'],
tags: ['transform', 'xml'],
source: {
repository: 'https://git.add-ideas.de/lotobo/xslt-tools',
license: 'AGPL-3.0-only',
},
};
export const onenoteManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.onenote-tools',
name: 'OneNote Reader',
version: '0.3.3',
description: 'Inspect OneNote packages locally in the browser.',
icon: './onenote.svg',
categories: ['documents', 'notes'],
tags: ['onepkg', 'import'],
source: {
repository: 'https://git.add-ideas.de/lotobo/onenote-tools',
license: 'AGPL-3.0-only',
},
};
export const regexManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.regex-tools',
name: 'Regex Tools',
version: '0.4.1',
description:
'Develop, explain, test and apply regular expressions locally in the browser.',
icon: './regex.svg',
categories: ['developer', 'text', 'regex'],
tags: ['regular-expression', 'match', 'replace', 'explain', 'test'],
requirements: {
secureContext: false,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
},
source: {
repository: 'https://git.add-ideas.de/lotobo/regex-tools',
license: 'GPL-3.0-or-later',
},
};
export const svgManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.svg-tools',
name: 'SVG Tools',
version: '0.1.0',
description:
'Inspect, edit, optimize and transform SVG documents locally in the browser.',
icon: './favicon.svg',
categories: ['graphics', 'design', 'developer'],
tags: [
'svg',
'vector',
'path',
'xml',
'transform',
'optimize',
'accessibility',
],
requirements: {
secureContext: false,
workers: true,
indexedDb: false,
crossOriginIsolated: false,
topLevelContext: false,
},
source: {
repository: 'https://git.add-ideas.de/lotobo/svg-tools',
license: 'GPL-3.0-or-later',
},
assets: ['./canvas-frame-controller.js'],
};
export const authManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.auth-tools',
name: 'OTP & Passkey Tools',
version: '0.1.0',
description:
'Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.',
icon: './favicon.svg',
categories: ['security', 'authentication', 'developer'],
tags: [
'otp',
'hotp',
'totp',
'ocra',
'oath',
'webauthn',
'fido2',
'passkey',
'attestation',
'assertion',
],
requirements: {
secureContext: true,
workers: false,
indexedDb: false,
crossOriginIsolated: false,
topLevelContext: true,
},
privacy: {
processing: 'local',
fileUploads: false,
telemetry: false,
label:
'Authentication material stays in this tab; nothing is uploaded or saved unless you explicitly export it.',
},
source: {
repository: 'https://git.add-ideas.de/lotobo/auth-tools',
license: 'GPL-3.0-or-later',
},
};
export const sudokuManifest: ToolboxAppManifest = {
schemaVersion: 1,
id: 'de.add-ideas.sudoku-tools',
name: 'Sudoku Tools',
version: '0.1.0',
description:
'Set, play, solve and analyse Sudoku puzzles locally in the browser.',
entry: './',
icon: './favicon.svg',
categories: ['games', 'puzzles', 'logic'],
tags: ['sudoku', 'killer', 'solver', 'setter', 'generator', 'candidates'],
integration: {
contextVersion: 1,
launchModes: ['navigate', 'new-tab'],
embedding: 'unsupported',
},
requirements: {
secureContext: false,
workers: true,
indexedDb: true,
crossOriginIsolated: false,
topLevelContext: false,
},
privacy: {
processing: 'local',
fileUploads: false,
telemetry: false,
label: 'Puzzles and progress stay in this browser; nothing is uploaded.',
},
source: {
repository: 'https://git.add-ideas.de/lotobo/sudoku-tools',
license: 'GPL-3.0-or-later',
},
actions: [
{
id: 'source',
label: 'Source',
url: 'https://git.add-ideas.de/lotobo/sudoku-tools',
},
],
};
export const colourManifest: ToolboxAppManifest = {
...sudokuManifest,
id: 'de.add-ideas.colour-tools',
name: 'Colour Tools',
version: '0.1.0',
description:
'Convert, composite, compare and build colours locally in the browser.',
categories: ['graphics', 'design', 'developer'],
tags: ['colour', 'rgba', 'oklch', 'palette', 'gradient', 'contrast'],
requirements: {
secureContext: false,
workers: true,
indexedDb: false,
crossOriginIsolated: false,
topLevelContext: false,
},
privacy: {
processing: 'local',
fileUploads: false,
telemetry: false,
label: 'Colours and images stay in this browser; nothing is uploaded.',
},
source: {
repository: 'https://git.add-ideas.de/lotobo/colour-tools',
license: 'GPL-3.0-or-later',
},
actions: [
{
id: 'source',
label: 'Source',
url: 'https://git.add-ideas.de/lotobo/colour-tools',
},
],
};
export const officeManifest: ToolboxAppManifest = {
...sudokuManifest,
id: 'de.add-ideas.office-tools',
name: 'Office Tools',
version: '0.1.0',
description:
'Open and inspect documents, spreadsheets and presentations locally in the browser.',
categories: ['documents', 'office', 'productivity'],
tags: ['document', 'spreadsheet', 'presentation', 'docx', 'xlsx', 'pptx'],
requirements: {
secureContext: false,
workers: true,
indexedDb: false,
crossOriginIsolated: false,
topLevelContext: false,
},
privacy: {
processing: 'local',
fileUploads: false,
telemetry: false,
label: 'Office files stay in this browser; nothing is uploaded.',
},
source: {
repository: 'https://git.add-ideas.de/lotobo/office-tools',
license: 'GPL-3.0-or-later',
},
actions: [
{
id: 'source',
label: 'Source',
url: 'https://git.add-ideas.de/lotobo/office-tools',
},
],
};
export const catalogue: ToolboxCatalog = { export const catalogue: ToolboxCatalog = {
schemaVersion: 1, schemaVersion: 1,
id: 'de.add-ideas.toolbox', id: 'de.add-ideas.toolbox',
name: 'add·ideas Toolbox', name: 'add·ideas Toolbox',
home: './', home: './',
theme: { mode: 'system', brand: 'add·ideas' }, theme: { mode: 'system', brand: 'add·ideas' },
apps: [{ manifest: './apps/pdf/toolbox-app.json', enabled: true }], apps: [
{ manifest: './apps/pdf/toolbox-app.json', enabled: true },
{ manifest: './apps/xslt/toolbox-app.json', enabled: true },
{ manifest: './apps/onenote/toolbox-app.json', enabled: true },
{ manifest: './apps/svg/toolbox-app.json', enabled: true },
{ manifest: './apps/auth/toolbox-app.json', enabled: true },
{ manifest: './apps/sudoku/toolbox-app.json', enabled: true },
{ manifest: './apps/colour/toolbox-app.json', enabled: true },
{ manifest: './apps/office/toolbox-app.json', enabled: true },
],
}; };
export function catalogueFetch(overrides: Record<string, Response> = {}) { export function catalogueFetch(overrides: Record<string, Response> = {}) {
@@ -56,6 +302,22 @@ export function catalogueFetch(overrides: Record<string, Response> = {}) {
return Response.json(catalogue); return Response.json(catalogue);
if (url.pathname.endsWith('/apps/pdf/toolbox-app.json')) if (url.pathname.endsWith('/apps/pdf/toolbox-app.json'))
return Response.json(pdfManifest); return Response.json(pdfManifest);
if (url.pathname.endsWith('/apps/xslt/toolbox-app.json'))
return Response.json(xsltManifest);
if (url.pathname.endsWith('/apps/onenote/toolbox-app.json'))
return Response.json(onenoteManifest);
if (url.pathname.endsWith('/apps/regex/toolbox-app.json'))
return Response.json(regexManifest);
if (url.pathname.endsWith('/apps/svg/toolbox-app.json'))
return Response.json(svgManifest);
if (url.pathname.endsWith('/apps/auth/toolbox-app.json'))
return Response.json(authManifest);
if (url.pathname.endsWith('/apps/sudoku/toolbox-app.json'))
return Response.json(sudokuManifest);
if (url.pathname.endsWith('/apps/colour/toolbox-app.json'))
return Response.json(colourManifest);
if (url.pathname.endsWith('/apps/office/toolbox-app.json'))
return Response.json(officeManifest);
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
}; };
} }
+90
View File
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { shortToolDescription, shortToolTitle } from './toolPresentation';
import type { ResolvedApp } from './types';
function app(id: string, name: string, description: string): ResolvedApp {
return { id, name, description } as ResolvedApp;
}
describe('compact tool presentation', () => {
it('uses the requested Audio & Video tile copy', () => {
const audioVideo = app(
'de.add-ideas.av-tools',
'Audio & Video Tools',
'Convert and lightly edit audio and video locally in the browser.'
);
expect(shortToolTitle(audioVideo)).toBe('Audio & Video');
expect(shortToolDescription(audioVideo)).toBe('Convert and edit locally.');
});
it('uses the requested Regex tile copy', () => {
const regex = app(
'de.add-ideas.regex-tools',
'Regex Tools',
'Develop, explain, test and apply regular expressions locally in the browser.'
);
expect(shortToolTitle(regex)).toBe('Regex');
expect(shortToolDescription(regex)).toBe('Explain and test locally.');
});
it('uses the compact SVG tile copy', () => {
const svg = app(
'de.add-ideas.svg-tools',
'SVG Tools',
'Inspect, edit, optimize and transform SVG documents locally in the browser.'
);
expect(shortToolTitle(svg)).toBe('SVG');
expect(shortToolDescription(svg)).toBe('Inspect and edit vectors locally.');
});
it('uses the compact authentication tile copy', () => {
const auth = app(
'de.add-ideas.auth-tools',
'OTP & Passkey Tools',
'Generate, inspect and verify OTP credentials and test WebAuthn/passkey ceremonies locally in the browser.'
);
expect(shortToolTitle(auth)).toBe('OTP & Passkeys');
expect(shortToolDescription(auth)).toBe(
'Inspect and test authentication locally.'
);
});
it('uses the compact Sudoku tile copy', () => {
const sudoku = app(
'de.add-ideas.sudoku-tools',
'Sudoku Tools',
'Set, play, solve and analyse Sudoku puzzles locally in the browser.'
);
expect(shortToolTitle(sudoku)).toBe('Sudoku');
expect(shortToolDescription(sudoku)).toBe('Set, play and solve locally.');
});
it('uses the compact Colour tile copy', () => {
const colour = app(
'de.add-ideas.colour-tools',
'Colour Tools',
'Convert, composite, compare and build colours locally in the browser.'
);
expect(shortToolTitle(colour)).toBe('Colour');
expect(shortToolDescription(colour)).toBe(
'Convert and build colours locally.'
);
});
it('uses the compact Office tile copy', () => {
const office = app(
'de.add-ideas.office-tools',
'Office Tools',
'Open and inspect documents, spreadsheets and presentations locally in the browser.'
);
expect(shortToolTitle(office)).toBe('Office');
expect(shortToolDescription(office)).toBe('Open office files locally.');
});
});
+55
View File
@@ -0,0 +1,55 @@
import type { ResolvedApp } from './types';
const PRESENTATION: Record<string, { title: string; description: string }> = {
'de.add-ideas.pdf-tools': {
title: 'PDF',
description: 'Arrange PDF pages locally.',
},
'de.add-ideas.xslt-tools': {
title: 'XSLT',
description: 'Transform XML locally.',
},
'de.add-ideas.onenote-tools': {
title: 'OneNote',
description: 'Open OneNote files locally.',
},
'de.add-ideas.av-tools': {
title: 'Audio & Video',
description: 'Convert and edit locally.',
},
'de.add-ideas.regex-tools': {
title: 'Regex',
description: 'Explain and test locally.',
},
'de.add-ideas.svg-tools': {
title: 'SVG',
description: 'Inspect and edit vectors locally.',
},
'de.add-ideas.auth-tools': {
title: 'OTP & Passkeys',
description: 'Inspect and test authentication locally.',
},
'de.add-ideas.sudoku-tools': {
title: 'Sudoku',
description: 'Set, play and solve locally.',
},
'de.add-ideas.colour-tools': {
title: 'Colour',
description: 'Convert and build colours locally.',
},
'de.add-ideas.office-tools': {
title: 'Office',
description: 'Open office files locally.',
},
};
export function shortToolTitle(app: ResolvedApp): string {
return (
PRESENTATION[app.id]?.title ??
app.name.replace(/\s+(?:reader|tools?|workbench)$/iu, '')
);
}
export function shortToolDescription(app: ResolvedApp): string {
return PRESENTATION[app.id]?.description ?? app.description;
}
+18
View File
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { import {
PREFERENCES_KEY, PREFERENCES_KEY,
defaultPreferences, defaultPreferences,
parsePreferences,
readPreferences, readPreferences,
writePreferences, writePreferences,
type Preferences, type Preferences,
@@ -43,5 +44,22 @@ export function usePreferences(): [
} }
}, [preferences]); }, [preferences]);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== PREFERENCES_KEY) return;
if (event.newValue === null) {
setPreferences({ ...defaultPreferences });
return;
}
try {
setPreferences(parsePreferences(JSON.parse(event.newValue)));
} catch {
setPreferences({ ...defaultPreferences });
}
};
globalThis.addEventListener('storage', handleStorage);
return () => globalThis.removeEventListener('storage', handleStorage);
}, []);
return [preferences, setPreferences, initial.storageAvailable]; return [preferences, setPreferences, initial.storageAvailable];
} }
+2
View File
@@ -10,5 +10,7 @@ export default defineConfig({
setupFiles: './src/test/setup.ts', setupFiles: './src/test/setup.ts',
css: true, css: true,
restoreMocks: true, restoreMocks: true,
pool: 'threads',
maxWorkers: 1,
}, },
}); });