3 Commits

26 changed files with 1406 additions and 633 deletions

View File

@@ -4,5 +4,5 @@ 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.1 TOOLBOX_RELEASE_VERSION=0.4.1
TOOLBOX_RELEASE_SHA256=41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465 TOOLBOX_RELEASE_SHA256=7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124

View File

@@ -2,8 +2,8 @@ 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.1 ARG TOOLBOX_RELEASE_VERSION=0.4.1
ARG TOOLBOX_RELEASE_SHA256=41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465 ARG TOOLBOX_RELEASE_SHA256=7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124
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/zemion/toolbox-portal/releases/download
RUN set -eu; \ RUN set -eu; \

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.1` reads one same-origin catalogue, shows each app
as a full-page launch card, and keeps personal pins, ordering, visibility, and as a full-page 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,7 +30,7 @@ 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.2.1` SDK ranges to the published `0.2.1`
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.
@@ -66,7 +68,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.1 ZIP during the image build, container downloads the immutable Toolbox 0.4.1 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.
@@ -117,8 +119,8 @@ 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.1 lock. Its URLs and checksums `release/toolbox.lock.json` is the reviewed v0.4.1 lock. Its URLs and checksums
pin the published PDF Tools 0.3.4, XSLT Tools 0.3.2, and OneNote Tools 0.2.1 pin the published PDF Tools 0.4.1, XSLT Tools 0.4.1, and OneNote Tools 0.3.1
release bytes. Use release bytes. 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 +142,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.1.zip --archive build/add-ideas-toolbox-0.4.1.zip
``` ```
Existing output is refused. Pass `--force` only when replacing those exact Existing output is refused. Pass `--force` only when replacing those exact
@@ -160,8 +162,8 @@ build/toolbox/
├── xslt/ ├── xslt/
└── onenote/ └── onenote/
build/add-ideas-toolbox-0.3.1.zip build/add-ideas-toolbox-0.4.1.zip
build/add-ideas-toolbox-0.3.1.zip.sha256 build/add-ideas-toolbox-0.4.1.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,7 +184,7 @@ 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.1.zip --output artifacts/add-ideas-toolbox-0.4.1.zip
``` ```
This also emits a `.sha256` sidecar. This also emits a `.sha256` sidecar.
@@ -199,9 +201,9 @@ normal local XML/XSLT transformations do not require that permission.
```sh ```sh
podman build -f Containerfile \ podman build -f Containerfile \
-t git.add-ideas.de/zemion/toolbox:0.3.1 . -t git.add-ideas.de/zemion/toolbox:0.4.1 .
podman run --rm -p 8080:8080 \ podman run --rm -p 8080:8080 \
git.add-ideas.de/zemion/toolbox:0.3.1 git.add-ideas.de/zemion/toolbox:0.4.1
``` ```
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 +220,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.1 \ --tag git.add-ideas.de/zemion/toolbox:0.4.1 \
--tag git.add-ideas.de/zemion/toolbox:0.3 \ --tag git.add-ideas.de/zemion/toolbox:0.4 \
--push . --push .
``` ```
@@ -244,8 +246,8 @@ permission to push code, releases, and container packages.
all three apps, switch between them, and confirm the encoded `toolbox` all three 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.1.zip.sha256)`. `(cd build && sha256sum -c add-ideas-toolbox-0.4.1.zip.sha256)`.
6. Commit the reviewed lock, tag the toolbox release (for example `v0.3.1`), and 6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.1`), 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,12 +260,18 @@ 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 ## Existing tools
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
| ------------------- | -------------------------------------------------- | ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`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` |
| **`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` |
| **`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 |
## Planned tools
| Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations | | Tool | State and boundary | Principal workflows | Important concrete scope | Critical considerations and integrations |
| -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`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` |
| **`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` |
| **`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` | | **`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` |
| **`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` | | **`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` |
| **`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 | | **`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 |
@@ -271,7 +279,6 @@ must not re-resolve app versions or substitute a newer release.
| **`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` | | **`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` |
| **`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` | | **`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` |
| **`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 | | **`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 |
| **`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 |
| **`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` | | **`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` |
| **`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 | | **`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 |
| **`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 | | **`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 |

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.

View File

@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Containerfile dockerfile: Containerfile
image: git.add-ideas.de/zemion/toolbox:0.3.1 image: git.add-ideas.de/zemion/toolbox:0.4.1
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
ports: ports:

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.1}" TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.1}"
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-41d0f99ffa1a88a6b9cfd5f6a6ca2823a96c0a53cec5ee2a42a688de30b12465}" TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124}"
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.3.1}" image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.1}"
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:

86
package-lock.json generated
View File

@@ -1,15 +1,19 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.1.0", "version": "0.2.1",
"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.1",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.1.0", "@add-ideas/toolbox-contract": "^0.2.1",
"@add-ideas/toolbox-shell-react": "^0.2.1",
"@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"
}, },
@@ -40,11 +44,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.1.1", "version": "0.2.1",
"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/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.1/toolbox-contract-0.2.1.tgz",
"integrity": "sha512-0q2whA5Y4SqG4PNNleXYR8nGbT3UYsZ1x+6ZWVmxuc/WjMxmujhD7ilIISmFzmnNdbyEyv/Zpr+U0fnHqGODqw==", "integrity": "sha512-NmwV41+ZS0qBWUnI9vflm0PcsiqITmxpSQQ5vMVaToFZuSSIJupgM43tuV9G4OJ1En0eTdYNxbsd3nsYhju2kw==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.1",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.1/toolbox-shell-react-0.2.1.tgz",
"integrity": "sha512-slpz9ODVbk27b+uvG3WeCmNa71Q6guabyVHT+jyGLT6ENLSbwmoEFZ0mKNJDxemltpt9KY8I7z47uNBsQ7yKYg==",
"license": "Apache-2.0",
"dependencies": {
"@add-ideas/toolbox-contract": "0.2.1"
},
"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",
@@ -4408,9 +4478,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",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.1.0", "version": "0.2.1",
"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.2.1",
"@add-ideas/toolbox-shell-react": "^0.2.1",
"@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"
}, },

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.1 is available at:
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0 https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.1
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.

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.

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.1", "releaseVersion": "0.4.1",
"portalVersion": "0.1.0", "portalVersion": "0.2.1",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox", "name": "add·ideas Toolbox",
@@ -15,23 +15,23 @@
"apps": [ "apps": [
{ {
"id": "de.add-ideas.pdf-tools", "id": "de.add-ideas.pdf-tools",
"version": "0.3.4", "version": "0.4.1",
"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/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip",
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", "sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.3.2", "version": "0.4.1",
"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/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip",
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", "sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.2.1", "version": "0.3.1",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.1/onenote-tools-0.2.1.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip",
"sha256": "be76b1086f709e6ede4891698c0bcc4b5a23a5665d37fbb860463b9a61b37989", "sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665",
"target": "onenote" "target": "onenote"
} }
] ]

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.1", "releaseVersion": "0.4.1",
"portalVersion": "0.1.0", "portalVersion": "0.2.1",
"catalogue": { "catalogue": {
"id": "de.add-ideas.toolbox", "id": "de.add-ideas.toolbox",
"name": "add·ideas Toolbox", "name": "add·ideas Toolbox",
@@ -15,23 +15,23 @@
"apps": [ "apps": [
{ {
"id": "de.add-ideas.pdf-tools", "id": "de.add-ideas.pdf-tools",
"version": "0.3.4", "version": "0.4.1",
"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/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip",
"sha256": "090492aaee4bd5cde51917ee3d9d0c971f071c27b7dbe4c918876ffd752dd930", "sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.3.2", "version": "0.4.1",
"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/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip",
"sha256": "e1746ac60f7143037ce77d7f04c62f7da39a92d8cef42e7167acb2bc00786de1", "sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.2.1", "version": "0.3.1",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.2.1/onenote-tools-0.2.1.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip",
"sha256": "be76b1086f709e6ede4891698c0bcc4b5a23a5665d37fbb860463b9a61b37989", "sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665",
"target": "onenote" "target": "onenote"
} }
] ]

View File

@@ -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.1',
catalogue: { catalogue: {
id: 'de.add-ideas.toolbox', id: 'de.add-ideas.toolbox',
name: 'Test Toolbox', name: 'Test Toolbox',

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) {

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';
@@ -16,14 +22,14 @@ describe('portal UI', () => {
await screen.findByRole('heading', { name: 'PDF Workbench' }) await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.getByText( screen.getAllByText(
'Local processing · no file uploads declared · no telemetry declared' 'Local processing · no file uploads declared · no telemetry declared'
) )
).toBeInTheDocument(); ).toHaveLength(3);
const sourceLinks = screen.getAllByRole('link', { const sourceLinks = screen.getAllByRole('link', {
name: 'Source and license', name: 'Source and license',
}); });
expect(sourceLinks).toHaveLength(2); expect(sourceLinks).toHaveLength(4);
expect( expect(
sourceLinks.find( sourceLinks.find(
(link) => (link) =>
@@ -33,10 +39,20 @@ describe('portal UI', () => {
).toHaveAttribute('rel', 'noopener noreferrer'); ).toHaveAttribute('rel', 'noopener noreferrer');
expect( expect(
sourceLinks.find((link) => sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.1.0') link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.1')
) )
).toBeDefined(); ).toBeDefined();
const launch = screen.getByRole('link', { name: /open tool/i }); expect(
screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument();
const showHidden = screen.getByRole('checkbox', {
name: 'Show hidden (0)',
});
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
const launch = screen
.getAllByRole('link', { name: 'PDF Workbench' })
.find((link) => link.classList.contains('app-card__launch-link'))!;
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');
@@ -47,7 +63,15 @@ 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();
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' })
@@ -55,10 +79,294 @@ describe('portal UI', () => {
expect( expect(
screen.queryByRole('heading', { name: 'PDF Workbench' }) screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i })); expect(showHidden).toBeEnabled();
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
fireEvent.click(showHidden);
expect( expect(
screen.getByRole('heading', { name: 'PDF Workbench' }) screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument(); ).toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' })
);
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
});
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 screen.findByRole('heading', { name: 'PDF Workbench' });
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(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
await user.clear(search);
await user.selectOptions(category, 'notes');
expect(
screen.getByRole('heading', { name: 'OneNote Reader' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'XSLT Workbench' })
).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 screen.findByRole('heading', { name: 'PDF Workbench' });
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(
screen.getByRole('heading', { name: '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 screen.findByRole('heading', { name: 'PDF Workbench' });
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(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(
screen.getByRole('heading', { name: '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 screen.findByRole('heading', { name: 'PDF Workbench' });
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 screen.findByRole('heading', { name: 'PDF Workbench' });
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 document tools, in one calm place.',
})
).toBeInTheDocument();
expect(
screen.getAllByRole('heading', {
name: 'Your document 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',
});
expect(
personalize.compareDocumentPosition(apps) &
Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
apps.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
expect(
source.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING
).toBeTruthy();
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 screen.findByRole('heading', { name: 'PDF Workbench' });
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',
],
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 screen.findByRole('heading', { name: 'PDF Workbench' });
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'),
];
cards.forEach((card, index) => {
const rect = {
x: index * 220,
y: 0,
width: 200,
height: 320,
top: 0,
right: index * 220 + 200,
bottom: 320,
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 Workbench', 'PDF Workbench', 'OneNote Reader']);
});
expect(
JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order
).toEqual([
'de.add-ideas.xslt-tools',
'de.add-ideas.pdf-tools',
'de.add-ideas.onenote-tools',
]);
});
it('filters by clickable tags and clears an active tag', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
render(<App />);
const merge = await screen.findByRole('button', { name: '#merge' });
await user.click(merge);
expect(merge).toHaveAttribute('aria-pressed', 'true');
expect(
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'XSLT Workbench' })
).not.toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'OneNote Reader' })
).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '#merge' }));
expect(
await screen.findByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect(
screen.getByRole('heading', { name: '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 screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByRole('button', { name: 'Help' }));
expect(
screen.getByRole('dialog', { name: 'Choose and arrange your tools' })
).toHaveTextContent('click anywhere on a tool tile');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
await user.click(screen.getByRole('button', { name: 'Close help' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
}); });
it('shows a recoverable catalogue error', async () => { it('shows a recoverable catalogue error', async () => {
@@ -92,31 +400,35 @@ 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).not.toHaveAttribute('aria-modal');
expect(dialog).toBeInTheDocument(); 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();
}); });
}); });

View File

@@ -1,13 +1,33 @@
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, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { AppCard } from './components/AppCard'; import { AppCard } from './components/AppCard';
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/zemion/toolbox-portal';
type LoadState = type LoadState =
| { status: 'loading' } | { status: 'loading' }
@@ -17,9 +37,11 @@ type LoadState =
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 +49,86 @@ function appMatches(
return haystack.includes(query.toLocaleLowerCase()); return haystack.includes(query.toLocaleLowerCase());
} }
interface AppSectionProps {
activeTag: string;
apps: ResolvedApp[];
catalogueUrl: string;
hiddenIds: readonly string[];
id: string;
onCategory: (category: string) => void;
onHide: (id: string) => void;
onPin: (id: string) => void;
onTag: (tag: string) => void;
pinned: boolean;
title: string;
}
function AppSection({
activeTag,
apps,
catalogueUrl,
hiddenIds,
id,
onCategory,
onHide,
onPin,
onTag,
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)}
activeTag={activeTag}
onCategory={onCategory}
onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)}
onTag={onTag}
/>
))}
</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 [helpOpen, setHelpOpen] = useState(false);
const [preferences, setPreferences, storageAvailable] = usePreferences(); const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeSettings = useCallback(() => setSettingsOpen(false), []); const closeHelp = useCallback(() => setHelpOpen(false), []);
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();
@@ -91,10 +184,31 @@ 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) {
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)
@@ -103,77 +217,133 @@ export default function App() {
})); }));
} }
function moveApp(id: string, direction: -1 | 1) { function handleDragEnd(event: DragEndEvent) {
const pinned = preferences.pinned.includes(id); if (!event.over) return;
const peers = visibleApps.filter( const activeId = String(event.active.id);
(app) => preferences.pinned.includes(app.id) === pinned 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
); );
const currentIndex = peers.findIndex((app) => app.id === id); setPreferences((current) => ({
const target = peers[currentIndex + direction]; ...current,
if (!target) return; order: reorderWithinIds(current.order, activeId, overId, peers),
setPreferences((current) => { }));
const currentPosition = current.order.indexOf(id);
const targetPosition = current.order.indexOf(target.id);
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 clearFilters() {
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas'; setQuery('');
setCategory('');
setActiveTag('');
setShownHiddenIds(null);
}
return ( return (
<> <>
<div <div
className="site-shell" className="site-shell"
inert={settingsOpen} inert={helpOpen}
aria-hidden={settingsOpen || undefined} aria-hidden={helpOpen || 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: () => setHelpOpen(true) }}
<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">
<div className="hero-intro">
<p className="eyebrow"> <p className="eyebrow">
Privacy details up front · useful by default Privacy details up front · useful by default
</p> </p>
<h1 id="page-title"> <h2 id="page-title">
Your document tools, Your document tools,
<br /> <br /> <span>in one calm place.</span>
<span>in one calm place.</span> </h2>
</h1>
<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>
{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
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>
)}
</section> </section>
{loadState.status === 'loading' && ( {loadState.status === 'loading' && (
@@ -205,43 +375,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 +397,51 @@ 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}
pinned
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href} catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl} hiddenIds={preferences.hidden}
pinned={pinned} onCategory={setCategory}
hidden={preferences.hidden.includes(app.id)} onPin={(id) => toggleListItem('pinned', id)}
first={peers[0]?.id === app.id} onHide={(id) => toggleListItem('hidden', id)}
last={peers.at(-1)?.id === app.id} onTag={(tag) =>
onPin={() => toggleListItem('pinned', app.id)} setActiveTag((current) => (current === tag ? '' : tag))
onHide={() => toggleListItem('hidden', app.id)} }
onMove={(direction) => moveApp(app.id, direction)}
/> />
); <AppSection
})} id="regular-apps"
</section> title="Tools"
apps={regularApps}
pinned={false}
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden}
onCategory={setCategory}
onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/>
</div>
</DndContext>
)} )}
{loaded.unavailable.length > 0 && ( {loaded.unavailable.length > 0 && (
@@ -332,9 +473,9 @@ export default function App() {
</span> </span>
</p> </p>
<span> <span>
Portal v0.1.0 ·{' '} Portal v0.2.1 ·{' '}
<a <a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.1.0" href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.1"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -344,13 +485,7 @@ export default function App() {
</footer> </footer>
</div> </div>
<PreferencesPanel <HelpPanel open={helpOpen} onClose={closeHelp} />
open={settingsOpen}
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
onClose={closeSettings}
/>
</> </>
); );
} }

View File

@@ -5,12 +5,12 @@ 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(3);
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',
@@ -38,6 +38,8 @@ 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',
]); ]);
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/);

View File

@@ -1,3 +1,5 @@
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import type { ResolvedApp } from '../types'; import type { ResolvedApp } from '../types';
interface AppCardProps { interface AppCardProps {
@@ -5,11 +7,11 @@ interface AppCardProps {
catalogueUrl: string; catalogueUrl: string;
hidden: boolean; hidden: boolean;
pinned: boolean; pinned: boolean;
first: boolean; activeTag: string;
last: boolean; onCategory: (category: string) => void;
onHide: () => void; onHide: () => void;
onMove: (direction: -1 | 1) => void;
onPin: () => void; onPin: () => void;
onTag: (tag: string) => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string; contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
} }
@@ -55,21 +57,35 @@ export function AppCard({
catalogueUrl, catalogueUrl,
hidden, hidden,
pinned, pinned,
first, activeTag,
last, onCategory,
onHide, onHide,
onMove,
onPin, onPin,
onTag,
contextualUrl, contextualUrl,
}: AppCardProps) { }: AppCardProps) {
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' : ''
}`}
data-testid={`app-card-${app.id}`} data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
> >
<div className="app-card__top"> <div className="app-card__top">
<div className="app-card__icon" aria-hidden="true"> <div className="app-card__icon" aria-hidden="true">
@@ -83,6 +99,17 @@ export function AppCard({
className="app-card__actions" className="app-card__actions"
aria-label={`Personalize ${app.name}`} aria-label={`Personalize ${app.name}`}
> >
<button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="icon-button drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<span aria-hidden="true"></span>
</button>
<button <button
className={`icon-button${pinned ? ' is-active' : ''}`} className={`icon-button${pinned ? ' is-active' : ''}`}
type="button" type="button"
@@ -93,24 +120,6 @@ export function AppCard({
> >
<span aria-hidden="true"></span> <span aria-hidden="true"></span>
</button> </button>
<button
className="icon-button"
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 <button
className="icon-button" className="icon-button"
type="button" type="button"
@@ -123,7 +132,16 @@ export function AppCard({
</div> </div>
<div className="app-card__body"> <div className="app-card__body">
<div className="title-line"> <div className="title-line">
<h2>{app.name}</h2> <h3>
<a
className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{app.name}
</a>
</h3>
{pinned && <span className="status-chip">Pinned</span>} {pinned && <span className="status-chip">Pinned</span>}
{hidden && ( {hidden && (
<span className="status-chip status-chip--muted">Hidden</span> <span className="status-chip status-chip--muted">Hidden</span>
@@ -135,11 +153,33 @@ export function AppCard({
<p>{app.description}</p> <p>{app.description}</p>
<PrivacySummary app={app} /> <PrivacySummary app={app} />
<RequirementSummary app={app} /> <RequirementSummary app={app} />
<div className="tag-row" aria-label="Categories"> {app.categories.length > 0 && (
<div className="category-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => ( {app.categories.slice(0, 3).map((category) => (
<span key={category}>{category}</span> <button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))} ))}
</div> </div>
)}
{app.tags.length > 0 && (
<div className="tag-row" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div> </div>
<footer className="app-card__footer"> <footer className="app-card__footer">
<div className="app-card__meta"> <div className="app-card__meta">
@@ -157,15 +197,9 @@ export function AppCard({
</a> </a>
)} )}
</div> </div>
<a <span className="open-hint">
className="launch-button" Open tool <span aria-hidden="true"></span>
href={launchUrl} </span>
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
{target ? 'Open in new tab' : 'Open tool'}{' '}
<span aria-hidden="true"></span>
</a>
</footer> </footer>
</article> </article>
); );

View File

@@ -0,0 +1,109 @@
import { useEffect, useRef } from 'react';
interface HelpPanelProps {
open: boolean;
onClose: () => void;
}
export function HelpPanel({ open, onClose }: HelpPanelProps) {
const panel = useRef<HTMLElement>(null);
useEffect(() => {
if (!open) return;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusable = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)]
: [];
focusable()[0]?.focus();
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) return;
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
returnFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
return (
<div
className="dialog-backdrop"
role="presentation"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<section
ref={panel}
className="help-panel"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
tabIndex={-1}
>
<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>Filter:</strong> search, choose a category, or click a tag.
Click the selected 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>
<p>
<strong>Privacy:</strong> each tile reports its declared processing,
uploads, telemetry, and browser requirements before you open it.
</p>
</div>
</section>
</div>
);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useRef, useState, type ChangeEvent } from 'react';
import { import {
defaultPreferences, defaultPreferences,
parsePreferences, parsePreferences,
@@ -7,83 +7,19 @@ import {
import type { ThemeMode } from '../types'; 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) { function setTheme(theme: ThemeMode) {
onChange({ ...preferences, theme }); onChange({ ...preferences, theme });
} }
@@ -119,38 +55,19 @@ export function PreferencesPanel({
} }
return ( return (
<div
className="dialog-backdrop"
role="presentation"
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
>
<section <section
ref={panel} className="preferences-content"
className="preferences-panel"
role="dialog"
aria-modal="true"
aria-labelledby="preferences-title" aria-labelledby="preferences-title"
tabIndex={-1}
> >
<header> <div className="preferences-heading">
<div> <p className="preferences-kicker">On this device</p>
<p className="eyebrow">On this device</p>
<h2 id="preferences-title">Personalize your toolbox</h2> <h2 id="preferences-title">Personalize your toolbox</h2>
</div> </div>
<button
className="close-button"
type="button"
aria-label="Close preferences"
onClick={onClose}
>
×
</button>
</header>
{!storageAvailable && ( {!storageAvailable && (
<p className="inline-warning" role="status"> <p className="inline-warning" role="status">
Browser storage is unavailable. Changes will last only until this Browser storage is unavailable. Changes will last only until this page
page closes. closes.
</p> </p>
)} )}
@@ -188,9 +105,9 @@ export function PreferencesPanel({
<p className="preferences-copy"> <p className="preferences-copy">
The portal stores pins, visibility, order, and appearance in this The portal stores pins, visibility, order, and appearance in this
browser and does not transmit them. Trusted code in another Toolbox browser and does not transmit them. Trusted code in another Toolbox app
app on this site can access same-origin storage. Export a JSON backup on this site can access same-origin storage. Export a JSON backup to
to move them to another device. move them to another device.
</p> </p>
<div className="preferences-actions"> <div className="preferences-actions">
@@ -223,15 +140,5 @@ export function PreferencesPanel({
{message} {message}
</p> </p>
</section> </section>
</div>
); );
} }
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(',');

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';

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
);
}); });
}); });

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
);
} }

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,12 @@ 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(1160px, calc(100% - 2.5rem));
margin-inline: auto; margin-inline: auto;
} }
.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 +138,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 +149,15 @@ main {
} }
.hero { .hero {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(19rem, 26rem);
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: 750px; max-width: 750px;
padding: clamp(4.5rem, 9vw, 8.5rem) 0 clamp(3rem, 6vw, 5.2rem);
} }
.eyebrow { .eyebrow {
@@ -214,7 +169,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 +177,7 @@ main {
letter-spacing: -0.065em; letter-spacing: -0.065em;
} }
.hero h1 span { .hero h2 span {
color: var(--brand); color: var(--brand);
} }
@@ -235,10 +190,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 +207,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 +230,7 @@ main {
} }
.category-field { .category-field {
min-width: 175px; min-width: 0;
padding: 0 0.65rem; padding: 0 0.65rem;
} }
@@ -284,29 +239,78 @@ 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(3, minmax(0, 1fr));
gap: 1.1rem; gap: 1.1rem;
padding-bottom: 2rem;
} }
.app-card { .app-card {
position: relative;
min-height: 340px; min-height: 340px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -321,6 +325,12 @@ main {
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); transform: translateY(-2px);
@@ -360,10 +370,21 @@ main {
} }
.app-card__actions { .app-card__actions {
position: relative;
z-index: 2;
display: flex; display: flex;
gap: 0.15rem; gap: 0.15rem;
} }
.drag-handle {
touch-action: none;
cursor: grab;
}
.drag-handle:active {
cursor: grabbing;
}
.icon-button, .icon-button,
.close-button { .close-button {
width: 32px; width: 32px;
@@ -400,15 +421,39 @@ main {
gap: 0.45rem; gap: 0.45rem;
} }
.title-line h2 { .title-line h3 {
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.3rem;
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 +464,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 {
@@ -442,17 +488,37 @@ main {
font-weight: 650; font-weight: 650;
} }
.tag-row { .tag-row,
.category-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;
}
.category-row {
margin-top: 1rem;
}
.tag-row {
margin-top: 0.55rem;
}
.tag-row button:hover,
.category-row button:hover,
.tag-row button[aria-pressed='true'] {
background: var(--brand-soft);
color: var(--brand);
} }
.app-card__footer { .app-card__footer {
@@ -477,28 +543,15 @@ main {
.source-link, .source-link,
.site-footer a { .site-footer a {
position: relative;
z-index: 2;
color: var(--brand); color: var(--brand);
} }
.launch-button { .open-hint {
display: inline-flex; color: var(--brand);
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-size: 0.84rem;
font-weight: 700; 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 {
@@ -609,7 +662,7 @@ main {
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.preferences-panel { .help-panel {
width: min(560px, 100%); width: min(560px, 100%);
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
overflow: auto; overflow: auto;
@@ -620,23 +673,76 @@ 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 > 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 { .help-panel .eyebrow {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.portal-header .toolbox-shell__personalize-popover {
width: min(26rem, calc(100vw - 1.5rem));
max-height: calc(100vh - 5.5rem);
overflow: auto;
}
.preferences-content {
padding: 0.35rem;
color: var(--toolbox-text, var(--text));
}
.preferences-heading {
margin-bottom: 0.9rem;
}
.preferences-content h2 {
margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1rem;
letter-spacing: -0.025em;
}
.portal-header .preferences-kicker {
margin: 0 0 0.3rem;
padding: 0;
color: var(--toolbox-accent, var(--brand));
font-size: 0.68rem;
font-weight: 750;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.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);
@@ -645,7 +751,7 @@ main {
} }
.theme-choice { .theme-choice {
margin: 1.5rem 0; margin: 1rem 0;
padding: 0; padding: 0;
border: 0; border: 0;
} }
@@ -691,7 +797,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 +805,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 +815,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 +830,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,12 +838,16 @@ 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));
font-size: 0.78rem;
} }
.inline-warning { .portal-header .inline-warning {
margin: 0 0 0.75rem;
padding: 0.7rem 0.8rem; padding: 0.7rem 0.8rem;
border-radius: 0.6rem; border-radius: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface)); background: color-mix(in srgb, var(--danger) 8%, var(--surface));
@@ -754,31 +867,18 @@ 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% - 1.4rem, 1160px);
} }
.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,6 +890,10 @@ main {
margin: 0.25rem 0; margin: 0.25rem 0;
} }
.app-grid {
grid-template-columns: 1fr;
}
.app-card { .app-card {
min-height: 320px; min-height: 320px;
} }
@@ -802,6 +906,33 @@ 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;
}
}
@media (min-width: 701px) and (max-width: 960px) {
.hero {
grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem);
}
.app-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *,
*::before, *::before,

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.1',
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',
@@ -35,13 +35,47 @@ export const pdfManifest: ToolboxAppManifest = {
}, },
}; };
export const xsltManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.xslt-tools',
name: 'XSLT Workbench',
version: '0.4.1',
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/zemion/xslt-tools',
license: 'AGPL-3.0-only',
},
};
export const onenoteManifest: ToolboxAppManifest = {
...pdfManifest,
id: 'de.add-ideas.onenote-tools',
name: 'OneNote Reader',
version: '0.3.1',
description: 'Inspect OneNote packages locally in the browser.',
icon: './onenote.svg',
categories: ['documents', 'notes'],
tags: ['onepkg', 'import'],
source: {
repository: 'https://git.add-ideas.de/zemion/onenote-tools',
license: 'AGPL-3.0-only',
},
};
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 },
],
}; };
export function catalogueFetch(overrides: Record<string, Response> = {}) { export function catalogueFetch(overrides: Record<string, Response> = {}) {
@@ -56,6 +90,10 @@ 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);
return new Response('Not found', { status: 404 }); return new Response('Not found', { status: 404 });
}; };
} }

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];
} }