3 Commits

16 changed files with 403 additions and 365 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.4.0 TOOLBOX_RELEASE_VERSION=0.4.2
TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112 TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389

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.4.0 ARG TOOLBOX_RELEASE_VERSION=0.4.2
ARG TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112 ARG TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389
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,7 +1,7 @@
# 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.2.0` reads one same-origin catalogue, shows each app browser toolbox. Version `0.2.2` reads one same-origin catalogue, shows each app
as a full-page launch tile, and keeps personal pins, drag-and-drop ordering, as a full-page launch tile, and keeps personal pins, drag-and-drop ordering,
visibility, and appearance in the current browser. Pinned tools have their own visibility, and appearance in the current browser. Pinned tools have their own
section; search, category, and clickable tag filters stay close to the tool section; search, category, and clickable tag filters stay close to the tool
@@ -30,7 +30,7 @@ npm run build
npm run dev npm run dev
``` ```
The package lock resolves the `^0.2.0` SDK ranges to the published `0.2.0` The package lock resolves the `^0.2.2` SDK ranges to the published `0.2.2`
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.
@@ -68,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.4.0 ZIP during the image build, container downloads the immutable Toolbox 0.4.2 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.
@@ -119,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.4.0 lock. Its URLs and checksums `release/toolbox.lock.json` is the reviewed v0.4.2 lock. Its URLs and checksums
pin the published PDF Tools 0.4.0, XSLT Tools 0.4.0, and OneNote Tools 0.3.0 pin the published PDF Tools 0.4.2, XSLT Tools 0.4.2, and OneNote Tools 0.3.2
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:
@@ -142,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.4.0.zip --archive build/add-ideas-toolbox-0.4.2.zip
``` ```
Existing output is refused. Pass `--force` only when replacing those exact Existing output is refused. Pass `--force` only when replacing those exact
@@ -162,8 +162,8 @@ build/toolbox/
├── xslt/ ├── xslt/
└── onenote/ └── onenote/
build/add-ideas-toolbox-0.4.0.zip build/add-ideas-toolbox-0.4.2.zip
build/add-ideas-toolbox-0.4.0.zip.sha256 build/add-ideas-toolbox-0.4.2.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
@@ -184,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.4.0.zip --output artifacts/add-ideas-toolbox-0.4.2.zip
``` ```
This also emits a `.sha256` sidecar. This also emits a `.sha256` sidecar.
@@ -201,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.4.0 . -t git.add-ideas.de/zemion/toolbox:0.4.2 .
podman run --rm -p 8080:8080 \ podman run --rm -p 8080:8080 \
git.add-ideas.de/zemion/toolbox:0.4.0 git.add-ideas.de/zemion/toolbox:0.4.2
``` ```
For a direct-port local deployment after assembly, use the separate example: For a direct-port local deployment after assembly, use the separate example:
@@ -220,7 +220,7 @@ 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.4.0 \ --tag git.add-ideas.de/zemion/toolbox:0.4.2 \
--tag git.add-ideas.de/zemion/toolbox:0.4 \ --tag git.add-ideas.de/zemion/toolbox:0.4 \
--push . --push .
``` ```
@@ -246,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.4.0.zip.sha256)`. `(cd build && sha256sum -c add-ideas-toolbox-0.4.2.zip.sha256)`.
6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.0`), and 6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.2`), 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

View File

@@ -3,7 +3,7 @@ services:
build: build:
context: . context: .
dockerfile: Containerfile dockerfile: Containerfile
image: git.add-ideas.de/zemion/toolbox:0.4.0 image: git.add-ideas.de/zemion/toolbox:0.4.2
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.4.0}" TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.2}"
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112}" TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389}"
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.0}" image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.2}"
restart: unless-stopped restart: unless-stopped
read_only: true read_only: true
tmpfs: tmpfs:

22
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.2.0", "version": "0.2.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.2.0", "version": "0.2.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.2.0", "@add-ideas/toolbox-contract": "^0.2.2",
"@add-ideas/toolbox-shell-react": "^0.2.0", "@add-ideas/toolbox-shell-react": "^0.2.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
@@ -44,18 +44,18 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.0", "version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.0/toolbox-contract-0.2.0.tgz", "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.2/toolbox-contract-0.2.2.tgz",
"integrity": "sha512-up+wY+ft1L7TbXrflcTeTOwEdcFwHBWFlGFj8skQvcLy5z2mDBpd6/7PVQKDvDTkiODu2npviHm6AFglFw+17Q==", "integrity": "sha512-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.0", "version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.0/toolbox-shell-react-0.2.0.tgz", "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.2/toolbox-shell-react-0.2.2.tgz",
"integrity": "sha512-qYR+OBXX8rfB+BYCsUbpiCzo2xK12SIPWVNXHre158Da5uT6usr8rRvp5qa5u2oQJ50PNzlXyXMyTeRp0Vz5wg==", "integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.0" "@add-ideas/toolbox-contract": "0.2.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.2.0", "version": "0.2.2",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"private": true, "private": true,
"type": "module", "type": "module",
@@ -21,8 +21,8 @@
"package:static": "node scripts/package-static.mjs" "package:static": "node scripts/package-static.mjs"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.2.0", "@add-ideas/toolbox-contract": "^0.2.2",
"@add-ideas/toolbox-shell-react": "^0.2.0", "@add-ideas/toolbox-shell-react": "^0.2.2",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",

View File

@@ -1,8 +1,8 @@
# Corresponding source # Corresponding source
The source code corresponding to add·ideas Toolbox Portal 0.2.0 is available at: The source code corresponding to add·ideas Toolbox Portal 0.2.2 is available at:
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.0 https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.2
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

@@ -1,8 +1,8 @@
{ {
"$schema": "./toolbox-release-lock.schema.json", "$schema": "./toolbox-release-lock.schema.json",
"schemaVersion": 1, "schemaVersion": 1,
"releaseVersion": "0.4.0", "releaseVersion": "0.4.2",
"portalVersion": "0.2.0", "portalVersion": "0.2.2",
"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.4.0", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip", "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.2/pdf-tools-0.4.2.zip",
"sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca", "sha256": "ebb6711e149a59abf51eddc0927debba9118205bde91faf7dded132478bc23d0",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.4.0", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip", "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
"sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142", "sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.3.0", "version": "0.3.2",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.2/onenote-tools-0.3.2.zip",
"sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3", "sha256": "c07e6909ae57bd7681f26ec29c7a4e927b6919123ef9e28aa2d8c55b842b65d2",
"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.4.0", "releaseVersion": "0.4.2",
"portalVersion": "0.2.0", "portalVersion": "0.2.2",
"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.4.0", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip", "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.2/pdf-tools-0.4.2.zip",
"sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca", "sha256": "ebb6711e149a59abf51eddc0927debba9118205bde91faf7dded132478bc23d0",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.4.0", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip", "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
"sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142", "sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.3.0", "version": "0.3.2",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.2/onenote-tools-0.3.2.zip",
"sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3", "sha256": "c07e6909ae57bd7681f26ec29c7a4e927b6919123ef9e28aa2d8c55b842b65d2",
"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.2.0', portalVersion: '0.2.2',
catalogue: { catalogue: {
id: 'de.add-ideas.toolbox', id: 'de.add-ideas.toolbox',
name: 'Test Toolbox', name: 'Test Toolbox',

View File

@@ -39,12 +39,17 @@ 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.4.0') link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.2')
) )
).toBeDefined(); ).toBeDefined();
expect( expect(
screen.queryByRole('region', { name: 'Pinned' }) screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
const showHidden = screen.getByRole('checkbox', {
name: 'Show hidden (0)',
});
expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked();
const launch = screen const launch = screen
.getAllByRole('link', { name: 'PDF Workbench' }) .getAllByRole('link', { name: 'PDF Workbench' })
.find((link) => link.classList.contains('app-card__launch-link'))!; .find((link) => link.classList.contains('app-card__launch-link'))!;
@@ -74,10 +79,102 @@ 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 () => { it('lists contextual destinations in the Apps menu', async () => {
@@ -85,7 +182,7 @@ describe('portal UI', () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByText('Apps').closest('summary')!); await user.click(screen.getByRole('button', { name: 'Apps' }));
const navigation = screen.getByRole('navigation', { const navigation = screen.getByRole('navigation', {
name: 'Toolbox applications', name: 'Toolbox applications',
}); });
@@ -97,18 +194,87 @@ describe('portal UI', () => {
).toContain('toolbox.catalog.json'); ).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',
});
const controls = within(header).getByRole('group', {
name: 'Toolbox controls',
});
expect(
Array.from(controls.children, (element) =>
element.getAttribute('aria-label')
)
).toEqual(['Help', 'Toolbox source on Gitea', 'Apps', 'Personalize']);
expect(help.compareDocumentPosition(source)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(source.compareDocumentPosition(apps)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(apps.compareDocumentPosition(personalize)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING
);
expect(
Array.from(
header.querySelector('.toolbox-shell__bar')?.children ?? [],
(element) => element.className
)
).toEqual([
'toolbox-shell__brand',
'toolbox-shell__identity',
'toolbox-shell__controls',
]);
expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg');
});
it('persists an explicit appearance and accepts cross-tab updates', async () => { it('persists an explicit appearance and accepts cross-tab updates', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByRole('button', { name: 'Personalize' })); 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' })); await user.click(screen.getByRole('button', { name: 'Dark' }));
expect(document.documentElement).toHaveAttribute('data-theme', 'dark'); expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
await waitFor(() => await waitFor(() =>
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"') expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"')
); );
await user.click(screen.getByRole('button', { name: 'Close preferences' })); await user.click(personalize);
fireEvent( fireEvent(
window, window,
@@ -208,14 +374,16 @@ describe('portal UI', () => {
it('opens the standard help control and explains tile interaction', async () => { it('opens the standard help control and explains tile interaction', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); const { container } = render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await screen.findByRole('heading', { name: 'PDF Workbench' });
await user.click(screen.getByRole('button', { name: 'Help' })); await user.click(screen.getByRole('button', { name: 'Help' }));
expect( expect(
screen.getByRole('dialog', { name: 'Choose and arrange your tools' }) screen.getByRole('dialog', { name: 'Choose and arrange your tools' })
).toHaveTextContent('click anywhere on a tool tile'); ).toHaveTextContent('click anywhere on a tool tile');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
await user.click(screen.getByRole('button', { name: 'Close help' })); await user.click(screen.getByRole('button', { name: 'Close help' }));
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); 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 () => {
@@ -249,31 +417,42 @@ describe('portal UI', () => {
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
it('contains preference focus and restores it to Personalize', async () => { it('hosts compact preferences in the shared Personalize popover', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
const { container } = render(<App />); const { container } = render(<App />);
const personalize = screen.getByRole('button', { name: 'Personalize' }); const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize); await user.click(personalize);
const dialog = screen.getByRole('dialog', { const heading = screen.getByRole('heading', {
name: 'Personalize your toolbox', name: 'Personalize your toolbox',
}); });
const close = screen.getByRole('button', { name: 'Close preferences' }); const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
expect(close).toHaveFocus(); expect(popover).toContainElement(heading);
expect(container.querySelector('.site-shell')).toHaveAttribute('inert'); expect(popover).toContainElement(screen.getByText('On this device'));
expect(dialog).toBeInTheDocument(); expect(
within(popover).getByRole('group', { name: 'Appearance' })
).toBeInTheDocument();
expect(
popover.querySelector('.toolbox-shell__preferences-content')
).toBeInTheDocument();
expect(popover).not.toHaveAttribute('aria-modal');
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent( expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
/Trusted code in another Toolbox app on this site can access same-origin storage/i /Trusted code in another Toolbox app on this site can access same-origin storage/i
); );
expect(
const reset = screen.getByRole('button', { name: 'Reset' }); screen.getByRole('button', { name: 'Export JSON' })
reset.focus(); ).toBeInTheDocument();
await user.tab(); expect(
expect(close).toHaveFocus(); screen.getByRole('button', { name: 'Import JSON' })
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
await user.keyboard('{Escape}'); await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(
screen.queryByRole('dialog', { name: 'Personalize Toolbox' })
).not.toBeInTheDocument();
expect(personalize).toHaveFocus(); expect(personalize).toHaveFocus();
}); });
}); });

View File

@@ -116,13 +116,15 @@ export default function App() {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [category, setCategory] = useState(''); const [category, setCategory] = useState('');
const [activeTag, setActiveTag] = useState(''); const [activeTag, setActiveTag] = useState('');
const [showHidden, setShowHidden] = useState(false); const [shownHiddenIds, setShownHiddenIds] = useState<
const [settingsOpen, setSettingsOpen] = useState(false); readonly string[] | null
>(null);
const [helpOpen, setHelpOpen] = useState(false); 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 closeHelp = useCallback(() => setHelpOpen(false), []);
const dialogOpen = settingsOpen || helpOpen; const showHidden =
preferences.hidden.length > 0 &&
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
@@ -204,6 +206,9 @@ export default function App() {
})); }));
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)
@@ -231,33 +236,37 @@ export default function App() {
setQuery(''); setQuery('');
setCategory(''); setCategory('');
setActiveTag(''); setActiveTag('');
if (preferences.hidden.length > 0) setShowHidden(true); setShownHiddenIds(null);
} }
const title = loaded?.catalogue.name ?? 'add·ideas Toolbox';
const brand = loaded?.catalogue.theme.brand ?? 'add·ideas';
return ( return (
<> <>
<div <div
className="site-shell" className="site-shell"
inert={dialogOpen} inert={helpOpen}
aria-hidden={dialogOpen || 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>
<ToolboxHeader <ToolboxHeader
className="portal-header" className="portal-header"
title={title} title="Toolbox"
iconUrl="./favicon.svg" subtitle="Local-first browser tools"
theme={preferences.theme} theme={preferences.theme}
homeHref={loaded?.homeUrl ?? './'} homeHref={loaded?.homeUrl ?? './'}
homeLabel={brand} homeLabel="add·ideas Toolbox"
brandIconUrl="./favicon.svg"
helpAction={{ onClick: () => setHelpOpen(true) }} helpAction={{ onClick: () => setHelpOpen(true) }}
sourceHref={PORTAL_SOURCE} sourceHref={PORTAL_SOURCE}
sourceLabel="Toolbox source on Gitea" sourceLabel="Toolbox source on Gitea"
onPersonalize={() => setSettingsOpen(true)} personalizeContent={
<PreferencesPanel
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
/>
}
apps={headerApps} apps={headerApps}
allAppsHref="#main-content" allAppsHref="#main-content"
/> />
@@ -270,8 +279,7 @@ export default function App() {
</p> </p>
<h2 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> </h2>
<p className="hero-copy"> <p className="hero-copy">
Focused browser utilities with a privacy disclosure for each Focused browser utilities with a privacy disclosure for each
@@ -315,16 +323,25 @@ export default function App() {
<span className="visually-hidden">Clear tag filter</span> <span className="visually-hidden">Clear tag filter</span>
</button> </button>
)} )}
{preferences.hidden.length > 0 && ( <label
<label className="hidden-toggle"> className={`hidden-toggle${
<input preferences.hidden.length === 0
type="checkbox" ? ' hidden-toggle--disabled'
checked={showHidden} : ''
onChange={(event) => setShowHidden(event.target.checked)} }`}
/> >
Show hidden ({preferences.hidden.length}) <input
</label> 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> </section>
@@ -456,9 +473,9 @@ export default function App() {
</span> </span>
</p> </p>
<span> <span>
Portal v0.2.0 ·{' '} Portal v0.2.2 ·{' '}
<a <a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.0" href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.2"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -468,13 +485,6 @@ export default function App() {
</footer> </footer>
</div> </div>
<PreferencesPanel
open={settingsOpen}
preferences={preferences}
storageAvailable={storageAvailable}
onChange={setPreferences}
onClose={closeSettings}
/>
<HelpPanel open={helpOpen} onClose={closeHelp} /> <HelpPanel open={helpOpen} onClose={closeHelp} />
</> </>
); );

View File

@@ -1,93 +1,25 @@
import { useEffect, useRef, useState, type ChangeEvent } from 'react'; import { useRef, useState, type ChangeEvent } from 'react';
import { ToolboxPersonalizePanel } from '@add-ideas/toolbox-shell-react';
import { import {
defaultPreferences, defaultPreferences,
parsePreferences, parsePreferences,
type Preferences, type Preferences,
} from '../preferences'; } from '../preferences';
import type { ThemeMode } from '../types';
interface PreferencesPanelProps { interface PreferencesPanelProps {
open: boolean;
preferences: Preferences; preferences: Preferences;
storageAvailable: boolean; storageAvailable: boolean;
onChange: (preferences: Preferences) => void; onChange: (preferences: Preferences) => void;
onClose: () => void;
} }
export function PreferencesPanel({ export function PreferencesPanel({
open,
preferences, preferences,
storageAvailable, storageAvailable,
onChange, onChange,
onClose,
}: PreferencesPanelProps) { }: PreferencesPanelProps) {
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const panel = useRef<HTMLElement>(null);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
useEffect(() => {
if (!open) return;
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusableElements = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => !element.hasAttribute('disabled')
)
: [];
focusableElements()[0]?.focus();
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusableElements();
if (elements.length === 0) {
event.preventDefault();
dialog?.focus();
return;
}
const first = elements[0]!;
const last = elements.at(-1)!;
const activeElement = document.activeElement;
if (
event.shiftKey &&
(activeElement === first || !dialog?.contains(activeElement))
) {
event.preventDefault();
last.focus();
} else if (
!event.shiftKey &&
(activeElement === last || !dialog?.contains(activeElement))
) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
returnFocus?.focus();
};
}, [open, onClose]);
if (!open) return null;
function setTheme(theme: ThemeMode) {
onChange({ ...preferences, theme });
}
function exportPreferences() { function exportPreferences() {
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], { const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
type: 'application/json', type: 'application/json',
@@ -119,119 +51,62 @@ export function PreferencesPanel({
} }
return ( return (
<div <ToolboxPersonalizePanel
className="dialog-backdrop" theme={preferences.theme}
role="presentation" onThemeChange={(theme) => onChange({ ...preferences, theme })}
onMouseDown={(event) => event.target === event.currentTarget && onClose()} storageAvailable={storageAvailable}
> >
<section <div className="preference-summary">
ref={panel} <div>
className="preferences-panel" <strong>{preferences.pinned.length}</strong>
role="dialog" <span>Pinned</span>
aria-modal="true"
aria-labelledby="preferences-title"
tabIndex={-1}
>
<header>
<div>
<p className="eyebrow">On this device</p>
<h2 id="preferences-title">Personalize your toolbox</h2>
</div>
<button
className="close-button"
type="button"
aria-label="Close preferences"
onClick={onClose}
>
×
</button>
</header>
{!storageAvailable && (
<p className="inline-warning" role="status">
Browser storage is unavailable. Changes will last only until this
page closes.
</p>
)}
<fieldset className="theme-choice">
<legend>Appearance</legend>
<div>
{(['system', 'light', 'dark'] as const).map((theme) => (
<button
key={theme}
type="button"
aria-pressed={preferences.theme === theme}
className={preferences.theme === theme ? 'is-selected' : ''}
onClick={() => setTheme(theme)}
>
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
</button>
))}
</div>
</fieldset>
<div className="preference-summary">
<div>
<strong>{preferences.pinned.length}</strong>
<span>Pinned</span>
</div>
<div>
<strong>{preferences.hidden.length}</strong>
<span>Hidden</span>
</div>
<div>
<strong>{preferences.order.length}</strong>
<span>Ordered</span>
</div>
</div> </div>
<div>
<p className="preferences-copy"> <strong>{preferences.hidden.length}</strong>
The portal stores pins, visibility, order, and appearance in this <span>Hidden</span>
browser and does not transmit them. Trusted code in another Toolbox
app on this site can access same-origin storage. Export a JSON backup
to move them to another device.
</p>
<div className="preferences-actions">
<button type="button" onClick={exportPreferences}>
Export JSON
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Import JSON
</button>
<input
ref={fileInput}
className="visually-hidden"
type="file"
tabIndex={-1}
accept="application/json,.json"
onChange={importPreferences}
/>
<button
className="danger-button"
type="button"
onClick={() => {
onChange({ ...defaultPreferences });
setMessage('Preferences reset.');
}}
>
Reset
</button>
</div> </div>
<p className="action-message" role="status" aria-live="polite"> <div>
{message} <strong>{preferences.order.length}</strong>
</p> <span>Ordered</span>
</section> </div>
</div> </div>
<p className="preferences-copy">
The portal stores pins, visibility, order, and appearance in this
browser and does not transmit them. Trusted code in another Toolbox app
on this site can access same-origin storage. Export a JSON backup to
move them to another device.
</p>
<div className="preferences-actions">
<button type="button" onClick={exportPreferences}>
Export JSON
</button>
<button type="button" onClick={() => fileInput.current?.click()}>
Import JSON
</button>
<input
ref={fileInput}
className="visually-hidden"
type="file"
tabIndex={-1}
accept="application/json,.json"
onChange={importPreferences}
/>
<button
className="danger-button"
type="button"
onClick={() => {
onChange({ ...defaultPreferences });
setMessage('Preferences reset.');
}}
>
Reset
</button>
</div>
<p className="action-message" role="status" aria-live="polite">
{message}
</p>
</ToolboxPersonalizePanel>
); );
} }
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

View File

@@ -123,7 +123,6 @@ summary:focus-visible {
transform: translateY(0); transform: translateY(0);
} }
.portal-header .toolbox-shell__bar,
main, main,
.site-footer { .site-footer {
width: min(1160px, calc(100% - 2.5rem)); width: min(1160px, calc(100% - 2.5rem));
@@ -192,13 +191,9 @@ main {
.tool-controls { .tool-controls {
display: grid; display: grid;
align-content: end; grid-template-columns: minmax(0, 1fr) minmax(8.75rem, 10.5rem);
align-items: center;
gap: 0.75rem; gap: 0.75rem;
padding: 1rem;
border: 1px solid var(--line);
border-radius: 1rem;
background: color-mix(in srgb, var(--surface) 88%, transparent);
box-shadow: var(--shadow);
} }
.search-field, .search-field,
@@ -212,7 +207,7 @@ main {
} }
.search-field { .search-field {
width: 100%; min-width: 0;
padding: 0 0.85rem; padding: 0 0.85rem;
gap: 0.55rem; gap: 0.55rem;
} }
@@ -235,7 +230,7 @@ main {
} }
.category-field { .category-field {
width: 100%; min-width: 0;
padding: 0 0.65rem; padding: 0 0.65rem;
} }
@@ -244,15 +239,21 @@ 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: 0.15rem 0 0;
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;
@@ -260,6 +261,7 @@ main {
} }
.active-filter { .active-filter {
grid-column: 1;
justify-self: start; justify-self: start;
padding: 0.4rem 0.65rem; padding: 0.4rem 0.65rem;
border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--line)); border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--line));
@@ -660,7 +662,6 @@ main {
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.preferences-panel,
.help-panel { .help-panel {
width: min(560px, 100%); width: min(560px, 100%);
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
@@ -672,7 +673,6 @@ 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 { .help-panel > header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -680,14 +680,12 @@ main {
gap: 1rem; gap: 1rem;
} }
.preferences-panel h2,
.help-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 { .help-panel .eyebrow {
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
@@ -720,44 +718,6 @@ main {
font-size: 1.3rem; font-size: 1.3rem;
} }
.theme-choice {
margin: 1.5rem 0;
padding: 0;
border: 0;
}
.theme-choice legend {
margin-bottom: 0.6rem;
color: var(--muted);
font-size: 0.78rem;
font-weight: 700;
text-transform: uppercase;
}
.theme-choice > div {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.45rem;
padding: 0.35rem;
border-radius: 0.75rem;
background: var(--surface-soft);
}
.theme-choice button {
padding: 0.62rem;
border: 0;
border-radius: 0.5rem;
background: transparent;
cursor: pointer;
}
.theme-choice button.is-selected {
background: var(--surface);
color: var(--brand);
box-shadow: 0 2px 8px rgba(20, 30, 60, 0.09);
font-weight: 700;
}
.preference-summary { .preference-summary {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
@@ -767,7 +727,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;
@@ -775,7 +735,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,
@@ -785,8 +745,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;
} }
@@ -797,7 +760,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 {
@@ -805,17 +768,12 @@ main {
color: var(--danger); color: var(--danger);
} }
.action-message { .portal-header .action-message {
min-height: 1.2em; min-height: 1.2em;
margin: 0.8rem 0 0; margin: 0.8rem 0 0;
} padding: 0;
color: var(--toolbox-muted, var(--muted));
.inline-warning { font-size: 0.78rem;
padding: 0.7rem 0.8rem;
border-radius: 0.6rem;
background: color-mix(in srgb, var(--danger) 8%, var(--surface));
color: var(--danger);
font-size: 0.82rem;
} }
.visually-hidden { .visually-hidden {
@@ -830,7 +788,6 @@ main {
} }
@media (max-width: 700px) { @media (max-width: 700px) {
.portal-header .toolbox-shell__bar,
main, main,
.site-footer { .site-footer {
width: min(100% - 1.4rem, 1160px); width: min(100% - 1.4rem, 1160px);
@@ -870,6 +827,23 @@ 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) { @media (min-width: 701px) and (max-width: 960px) {
.hero { .hero {
grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem); grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem);

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.4.0', version: '0.4.2',
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',
@@ -39,7 +39,7 @@ export const xsltManifest: ToolboxAppManifest = {
...pdfManifest, ...pdfManifest,
id: 'de.add-ideas.xslt-tools', id: 'de.add-ideas.xslt-tools',
name: 'XSLT Workbench', name: 'XSLT Workbench',
version: '0.4.0', version: '0.4.2',
description: 'Transform XML locally with XSLT in the browser.', description: 'Transform XML locally with XSLT in the browser.',
icon: './xslt.svg', icon: './xslt.svg',
categories: ['documents', 'developer'], categories: ['documents', 'developer'],
@@ -54,7 +54,7 @@ export const onenoteManifest: ToolboxAppManifest = {
...pdfManifest, ...pdfManifest,
id: 'de.add-ideas.onenote-tools', id: 'de.add-ideas.onenote-tools',
name: 'OneNote Reader', name: 'OneNote Reader',
version: '0.3.0', version: '0.3.2',
description: 'Inspect OneNote packages locally in the browser.', description: 'Inspect OneNote packages locally in the browser.',
icon: './onenote.svg', icon: './onenote.svg',
categories: ['documents', 'notes'], categories: ['documents', 'notes'],