2 Commits

21 changed files with 1066 additions and 630 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.1 TOOLBOX_RELEASE_VERSION=0.4.3
TOOLBOX_RELEASE_SHA256=7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124 TOOLBOX_RELEASE_SHA256=0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522

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.1 ARG TOOLBOX_RELEASE_VERSION=0.4.3
ARG TOOLBOX_RELEASE_SHA256=7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124 ARG TOOLBOX_RELEASE_SHA256=0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522
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,8 +1,8 @@
# 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.1` reads one same-origin catalogue, shows each app browser toolbox. Version `0.2.3` reads one same-origin catalogue, shows each app
as a full-page launch tile, and keeps personal pins, drag-and-drop ordering, as a compact 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
list. list.
@@ -30,7 +30,7 @@ npm run build
npm run dev npm run dev
``` ```
The package lock resolves the `^0.2.1` SDK ranges to the published `0.2.1` 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.1 ZIP during the image build, container downloads the immutable Toolbox 0.4.3 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.1 lock. Its URLs and checksums `release/toolbox.lock.json` is the reviewed v0.4.3 lock. Its URLs and checksums
pin the published PDF Tools 0.4.1, XSLT Tools 0.4.1, and OneNote Tools 0.3.1 pin the published PDF Tools 0.4.3, XSLT Tools 0.4.2, and OneNote Tools 0.3.3
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.1.zip --archive build/add-ideas-toolbox-0.4.3.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.1.zip build/add-ideas-toolbox-0.4.3.zip
build/add-ideas-toolbox-0.4.1.zip.sha256 build/add-ideas-toolbox-0.4.3.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.1.zip --output artifacts/add-ideas-toolbox-0.4.3.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.1 . -t git.add-ideas.de/zemion/toolbox:0.4.3 .
podman run --rm -p 8080:8080 \ podman run --rm -p 8080:8080 \
git.add-ideas.de/zemion/toolbox:0.4.1 git.add-ideas.de/zemion/toolbox:0.4.3
``` ```
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.1 \ --tag git.add-ideas.de/zemion/toolbox:0.4.3 \
--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.1.zip.sha256)`. `(cd build && sha256sum -c add-ideas-toolbox-0.4.3.zip.sha256)`.
6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.1`), and 6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.3`), 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.1 image: git.add-ideas.de/zemion/toolbox:0.4.3
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.1}" TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.3}"
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-7d8907990a8b91c2bfda48d007fd8136e95222858db8f51780cd10f4165e9124}" TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522}"
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.1}" image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.3}"
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.1", "version": "0.2.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@add-ideas/toolbox-portal", "name": "@add-ideas/toolbox-portal",
"version": "0.2.1", "version": "0.2.3",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "^0.2.1", "@add-ideas/toolbox-contract": "^0.2.2",
"@add-ideas/toolbox-shell-react": "^0.2.1", "@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.1", "version": "0.2.2",
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.1/toolbox-contract-0.2.1.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-NmwV41+ZS0qBWUnI9vflm0PcsiqITmxpSQQ5vMVaToFZuSSIJupgM43tuV9G4OJ1En0eTdYNxbsd3nsYhju2kw==", "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.1", "version": "0.2.2",
"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", "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-slpz9ODVbk27b+uvG3WeCmNa71Q6guabyVHT+jyGLT6ENLSbwmoEFZ0mKNJDxemltpt9KY8I7z47uNBsQ7yKYg==", "integrity": "sha512-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.1" "@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.1", "version": "0.2.3",
"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.1", "@add-ideas/toolbox-contract": "^0.2.2",
"@add-ideas/toolbox-shell-react": "^0.2.1", "@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.1 is available at: The source code corresponding to add·ideas Toolbox Portal 0.2.3 is available at:
https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.1 https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.3
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.1", "releaseVersion": "0.4.3",
"portalVersion": "0.2.1", "portalVersion": "0.2.3",
"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.1", "version": "0.4.3",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip", "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip",
"sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467", "sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.4.1", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip", "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
"sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb", "sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.3.1", "version": "0.3.3",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip",
"sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665", "sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec",
"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.1", "releaseVersion": "0.4.3",
"portalVersion": "0.2.1", "portalVersion": "0.2.3",
"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.1", "version": "0.4.3",
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip", "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip",
"sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467", "sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf",
"target": "pdf" "target": "pdf"
}, },
{ {
"id": "de.add-ideas.xslt-tools", "id": "de.add-ideas.xslt-tools",
"version": "0.4.1", "version": "0.4.2",
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip", "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
"sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb", "sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
"target": "xslt" "target": "xslt"
}, },
{ {
"id": "de.add-ideas.onenote-tools", "id": "de.add-ideas.onenote-tools",
"version": "0.3.1", "version": "0.3.3",
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip", "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip",
"sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665", "sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec",
"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.1', portalVersion: '0.2.3',
catalogue: { catalogue: {
id: 'de.add-ideas.toolbox', id: 'de.add-ideas.toolbox',
name: 'Test Toolbox', name: 'Test Toolbox',

View File

@@ -13,35 +13,48 @@ import { catalogue, catalogueFetch } from './test/fixtures';
afterEach(() => vi.unstubAllGlobals()); afterEach(() => vi.unstubAllGlobals());
function toolLink(name: string) {
return screen.getByRole('link', { name: `Open ${name}` });
}
function queryToolLink(name: string) {
return screen.queryByRole('link', { name: `Open ${name}` });
}
function findToolLink(name = 'PDF Workbench') {
return screen.findByRole('link', { name: `Open ${name}` });
}
describe('portal UI', () => { describe('portal UI', () => {
it('renders app cards and persists accessible pin/hide controls', async () => { it('renders compact actionable tiles and persists pin/hide controls', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const launch = await findToolLink();
const card = screen.getByTestId('app-card-de.add-ideas.pdf-tools');
expect(within(card).getByRole('heading', { level: 3 })).toHaveTextContent(
'PDF'
);
expect(within(card).getByText('Arrange PDF pages locally.')).toBeVisible();
expect( expect(
await screen.findByRole('heading', { name: 'PDF Workbench' }) screen.getByTestId('app-card-de.add-ideas.xslt-tools')
).toBeInTheDocument(); ).toHaveTextContent('XSLT');
expect( expect(
screen.getAllByText( screen.getByTestId('app-card-de.add-ideas.onenote-tools')
).toHaveTextContent('OneNote');
expect(
screen.queryByText(
'Page-level PDF operations performed locally in the browser.'
)
).not.toBeInTheDocument();
expect(
screen.queryByText(
'Local processing · no file uploads declared · no telemetry declared' 'Local processing · no file uploads declared · no telemetry declared'
) )
).toHaveLength(3); ).not.toBeInTheDocument();
const sourceLinks = screen.getAllByRole('link', {
name: 'Source and license',
});
expect(sourceLinks).toHaveLength(4);
expect( expect(
sourceLinks.find( screen.getAllByRole('link', { name: 'Source and license' })
(link) => ).toHaveLength(1);
link.getAttribute('href') ===
'https://git.add-ideas.de/zemion/pdf-tools'
)
).toHaveAttribute('rel', 'noopener noreferrer');
expect(
sourceLinks.find((link) =>
link.getAttribute('href')?.endsWith('/toolbox-portal/src/tag/v0.4.1')
)
).toBeDefined();
expect( expect(
screen.queryByRole('region', { name: 'Pinned' }) screen.queryByRole('region', { name: 'Pinned' })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
@@ -50,12 +63,20 @@ describe('portal UI', () => {
}); });
expect(showHidden).toBeDisabled(); expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked(); expect(showHidden).not.toBeChecked();
const launch = screen expect(launch).toHaveClass('app-card__launch-link');
.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');
const actions = within(card).getByRole('group', {
name: 'Actions for PDF Workbench',
});
const info = within(actions).getByRole('button', {
name: 'More information about PDF Workbench',
});
expect(info).toHaveAttribute('aria-haspopup', 'dialog');
for (const control of within(actions).getAllByRole('button'))
expect(control.closest('a')).toBeNull();
expect(launch).not.toContainElement(info);
await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' })); await user.click(screen.getByRole('button', { name: 'Pin PDF Workbench' }));
await waitFor(() => await waitFor(() =>
@@ -69,6 +90,12 @@ describe('portal UI', () => {
expect( expect(
screen.queryByRole('button', { name: /Move PDF Workbench/i }) screen.queryByRole('button', { name: /Move PDF Workbench/i })
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
const unpin = screen.getByRole('button', {
name: 'Unpin PDF Workbench',
});
expect(unpin).toHaveAttribute('aria-pressed', 'true');
expect(unpin).toHaveClass('is-active');
await waitFor(() => expect(unpin).toHaveFocus());
expect( expect(
screen.getByRole('button', { name: 'Reorder PDF Workbench' }) screen.getByRole('button', { name: 'Reorder PDF Workbench' })
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -76,27 +103,92 @@ describe('portal UI', () => {
await user.click( await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' }) screen.getByRole('button', { name: 'Hide PDF Workbench' })
); );
expect( expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(showHidden).toBeEnabled(); expect(showHidden).toBeEnabled();
expect(showHidden).toHaveAccessibleName('Show hidden (1)'); expect(showHidden).toHaveAccessibleName('Show hidden (1)');
await waitFor(() => expect(showHidden).toHaveFocus());
fireEvent.click(showHidden); fireEvent.click(showHidden);
expect( expect(toolLink('PDF Workbench')).toBeInTheDocument();
screen.getByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
await user.click( await user.click(
screen.getByRole('button', { name: 'Show PDF Workbench' }) screen.getByRole('button', { name: 'Show PDF Workbench' })
); );
expect(showHidden).toBeDisabled(); expect(showHidden).toBeDisabled();
expect(showHidden).not.toBeChecked(); expect(showHidden).not.toBeChecked();
await waitFor(() =>
expect(
screen.getByRole('button', { name: 'Hide PDF Workbench' })
).toHaveFocus()
);
});
it('moves the full disclosure into an accessible modal', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup();
const { container } = render(<App />);
await findToolLink();
const info = screen.getByRole('button', {
name: 'More information about PDF Workbench',
});
await user.click(info);
const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveTextContent(
'Page-level PDF operations performed locally in the browser.'
);
expect(dialog).toHaveTextContent(
'Local processing · no file uploads declared · no telemetry declared'
);
expect(dialog).toHaveTextContent(
'Needs Secure context · Web workers · IndexedDB'
);
expect(dialog).toHaveTextContent('v0.4.3');
expect(
within(dialog).getByRole('button', { name: 'documents' })
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: '#merge' })
).toBeInTheDocument();
const sourceLink = within(dialog).getByRole('link', {
name: 'Source and license',
});
expect(sourceLink).toHaveAttribute(
'href',
'https://git.add-ideas.de/zemion/pdf-tools'
);
expect(sourceLink).toHaveAttribute('rel', 'noopener noreferrer');
expect(
within(dialog).getByRole('link', { name: 'Open PDF' })
).toBeInTheDocument();
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
expect(document.documentElement).toHaveStyle({ overflow: 'hidden' });
const close = within(dialog).getByRole('button', {
name: 'Close information for PDF Workbench',
});
expect(close).toHaveFocus();
within(dialog).getByRole('link', { name: 'Open PDF' }).focus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(
screen.queryByRole('dialog', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
expect(info).toHaveFocus();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(document.documentElement).not.toHaveStyle({ overflow: 'hidden' });
}); });
it('keeps search and category controls together and filters with each', async () => { it('keeps search and category controls together and filters with each', 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 findToolLink();
const search = screen.getByRole('searchbox', { name: 'Search tools' }); const search = screen.getByRole('searchbox', { name: 'Search tools' });
const category = screen.getByRole('combobox', { const category = screen.getByRole('combobox', {
@@ -107,28 +199,37 @@ describe('portal UI', () => {
); );
await user.type(search, 'xslt'); await user.type(search, 'xslt');
expect( expect(toolLink('XSLT Workbench')).toBeInTheDocument();
screen.getByRole('heading', { name: 'XSLT Workbench' }) expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
).toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'PDF Workbench' })
).not.toBeInTheDocument();
await user.clear(search); await user.clear(search);
await user.selectOptions(category, 'notes'); await user.selectOptions(category, 'notes');
expect( expect(toolLink('OneNote Reader')).toBeInTheDocument();
screen.getByRole('heading', { name: 'OneNote Reader' }) expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
).toBeInTheDocument();
expect( await user.selectOptions(category, '');
screen.queryByRole('heading', { name: 'XSLT Workbench' }) await user.click(
).not.toBeInTheDocument(); screen.getByRole('button', {
name: 'More information about PDF Workbench',
})
);
await user.click(
within(screen.getByRole('dialog', { name: 'PDF Workbench' })).getByRole(
'button',
{ name: 'pdf' }
)
);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(category).toHaveValue('pdf');
expect(toolLink('PDF Workbench')).toBeInTheDocument();
expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
}); });
it('keeps remaining hidden tools visible while editing the hidden set', async () => { it('keeps remaining hidden tools visible while editing the hidden set', 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 findToolLink();
await user.click( await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' }) screen.getByRole('button', { name: 'Hide PDF Workbench' })
@@ -147,16 +248,14 @@ describe('portal UI', () => {
expect(showHidden).toBeChecked(); expect(showHidden).toBeChecked();
expect(showHidden).toHaveAccessibleName('Show hidden (1)'); expect(showHidden).toHaveAccessibleName('Show hidden (1)');
expect( expect(toolLink('XSLT Workbench')).toBeInTheDocument();
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
}); });
it('clears search filters without revealing hidden tools', async () => { it('clears search filters without revealing hidden tools', 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 findToolLink();
await user.click( await user.click(
screen.getByRole('button', { name: 'Hide PDF Workbench' }) screen.getByRole('button', { name: 'Hide PDF Workbench' })
); );
@@ -166,12 +265,8 @@ describe('portal UI', () => {
); );
await user.click(screen.getByRole('button', { name: 'Clear filters' })); await user.click(screen.getByRole('button', { name: 'Clear filters' }));
expect( expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument();
screen.queryByRole('heading', { name: 'PDF Workbench' }) expect(toolLink('XSLT Workbench')).toBeInTheDocument();
).not.toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'XSLT Workbench' })
).toBeInTheDocument();
expect( expect(
screen.getByRole('checkbox', { name: 'Show hidden (1)' }) screen.getByRole('checkbox', { name: 'Show hidden (1)' })
).not.toBeChecked(); ).not.toBeChecked();
@@ -181,7 +276,7 @@ describe('portal UI', () => {
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 findToolLink();
await user.click(screen.getByRole('button', { name: 'Apps' })); await user.click(screen.getByRole('button', { name: 'Apps' }));
const navigation = screen.getByRole('navigation', { const navigation = screen.getByRole('navigation', {
name: 'Toolbox applications', name: 'Toolbox applications',
@@ -197,7 +292,7 @@ describe('portal UI', () => {
it('uses the shared accessible header identity and control order', async () => { it('uses the shared accessible header identity and control order', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />); render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await findToolLink();
const header = document.querySelector<HTMLElement>('.portal-header')!; const header = document.querySelector<HTMLElement>('.portal-header')!;
expect( expect(
@@ -229,16 +324,33 @@ describe('portal UI', () => {
const brand = within(header).getByRole('link', { const brand = within(header).getByRole('link', {
name: 'add·ideas Toolbox', name: 'add·ideas Toolbox',
}); });
const controls = within(header).getByRole('group', {
name: 'Toolbox controls',
});
expect( expect(
personalize.compareDocumentPosition(apps) & Array.from(controls.children, (element) =>
Node.DOCUMENT_POSITION_FOLLOWING element.getAttribute('aria-label')
).toBeTruthy(); )
).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( expect(
apps.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING Array.from(
).toBeTruthy(); header.querySelector('.toolbox-shell__bar')?.children ?? [],
expect( (element) => element.className
source.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING )
).toBeTruthy(); ).toEqual([
'toolbox-shell__brand',
'toolbox-shell__identity',
'toolbox-shell__controls',
]);
expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg'); expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg');
}); });
@@ -246,7 +358,7 @@ describe('portal UI', () => {
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 findToolLink();
const personalize = screen.getByRole('button', { name: 'Personalize' }); const personalize = screen.getByRole('button', { name: 'Personalize' });
await user.click(personalize); await user.click(personalize);
expect( expect(
@@ -282,7 +394,7 @@ describe('portal UI', () => {
it('supports keyboard drag ordering from each tile grip', async () => { it('supports keyboard drag ordering from each tile grip', async () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
render(<App />); render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await findToolLink();
const cards = [ const cards = [
screen.getByTestId('app-card-de.add-ideas.pdf-tools'), 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.xslt-tools'),
@@ -292,11 +404,11 @@ describe('portal UI', () => {
const rect = { const rect = {
x: index * 220, x: index * 220,
y: 0, y: 0,
width: 200, width: 188,
height: 320, height: 188,
top: 0, top: 0,
right: index * 220 + 200, right: index * 220 + 188,
bottom: 320, bottom: 188,
left: index * 220, left: index * 220,
toJSON: () => ({}), toJSON: () => ({}),
} as DOMRect; } as DOMRect;
@@ -317,7 +429,7 @@ describe('portal UI', () => {
within(tools) within(tools)
.getAllByRole('heading', { level: 3 }) .getAllByRole('heading', { level: 3 })
.map((heading) => heading.textContent) .map((heading) => heading.textContent)
).toEqual(['XSLT Workbench', 'PDF Workbench', 'OneNote Reader']); ).toEqual(['XSLT', 'PDF', 'OneNote']);
}); });
expect( expect(
JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order
@@ -332,41 +444,53 @@ describe('portal UI', () => {
vi.stubGlobal('fetch', vi.fn(catalogueFetch())); vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
const user = userEvent.setup(); const user = userEvent.setup();
render(<App />); render(<App />);
const merge = await screen.findByRole('button', { name: '#merge' }); await findToolLink();
await user.click(merge); await user.click(
expect(merge).toHaveAttribute('aria-pressed', 'true'); screen.getByRole('button', {
expect( name: 'More information about PDF Workbench',
screen.getByRole('heading', { name: 'PDF Workbench' }) })
).toBeInTheDocument(); );
expect( const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' });
screen.queryByRole('heading', { name: 'XSLT Workbench' }) await user.click(within(dialog).getByRole('button', { name: '#merge' }));
).not.toBeInTheDocument();
expect(
screen.queryByRole('heading', { name: 'OneNote Reader' })
).not.toBeInTheDocument();
await user.click(screen.getByRole('button', { name: '#merge' })); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect( expect(toolLink('PDF Workbench')).toBeInTheDocument();
await screen.findByRole('heading', { name: 'XSLT Workbench' }) expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument();
).toBeInTheDocument(); expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument();
expect(
screen.getByRole('heading', { name: 'OneNote Reader' }) const activeTag = screen.getByRole('button', {
).toBeInTheDocument(); name: /#merge.*Clear tag filter/i,
});
await user.click(activeTag);
expect(await findToolLink('XSLT Workbench')).toBeInTheDocument();
expect(toolLink('OneNote Reader')).toBeInTheDocument();
}); });
it('opens the standard help control and explains tile interaction', async () => { 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();
const { container } = render(<App />); const { container } = render(<App />);
await screen.findByRole('heading', { name: 'PDF Workbench' }); await findToolLink();
await user.click(screen.getByRole('button', { name: 'Help' })); const help = screen.getByRole('button', { name: 'Help' });
expect( await user.click(help);
screen.getByRole('dialog', { name: 'Choose and arrange your tools' }) const dialog = screen.getByRole('dialog', {
).toHaveTextContent('click anywhere on a tool tile'); name: 'Choose and arrange your tools',
});
expect(dialog).toHaveTextContent('click anywhere on a tool tile');
expect(dialog).toHaveTextContent('use the light bulb');
expect(container.querySelector('.site-shell')).toHaveAttribute('inert'); expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
await user.click(screen.getByRole('button', { name: 'Close help' })); expect(container.querySelector('.site-shell')).toHaveAttribute(
'aria-hidden',
'true'
);
const close = within(dialog).getByRole('button', { name: 'Close help' });
expect(close).toHaveFocus();
await user.tab();
expect(close).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
expect(help).toHaveFocus();
}); });
it('shows a recoverable catalogue error', async () => { it('shows a recoverable catalogue error', async () => {
@@ -380,9 +504,7 @@ describe('portal UI', () => {
await screen.findByRole('heading', { name: 'Toolbox unavailable' }) await screen.findByRole('heading', { name: 'Toolbox unavailable' })
).toBeInTheDocument(); ).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Try again' })); fireEvent.click(screen.getByRole('button', { name: 'Try again' }));
expect( expect(await findToolLink()).toBeInTheDocument();
await screen.findByRole('heading', { name: 'PDF Workbench' })
).toBeInTheDocument();
}); });
it('shows a dedicated empty state for a valid catalogue', async () => { it('shows a dedicated empty state for a valid catalogue', async () => {
@@ -412,6 +534,13 @@ describe('portal UI', () => {
}); });
const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' }); const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
expect(popover).toContainElement(heading); expect(popover).toContainElement(heading);
expect(popover).toContainElement(screen.getByText('On this device'));
expect(
within(popover).getByRole('group', { name: 'Appearance' })
).toBeInTheDocument();
expect(
popover.querySelector('.toolbox-shell__preferences-content')
).toBeInTheDocument();
expect(popover).not.toHaveAttribute('aria-modal'); expect(popover).not.toHaveAttribute('aria-modal');
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); 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(

View File

@@ -16,8 +16,9 @@ import {
ToolboxHeader, ToolboxHeader,
type ToolboxHeaderApp, type ToolboxHeaderApp,
} from '@add-ideas/toolbox-shell-react'; } from '@add-ideas/toolbox-shell-react';
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { AppCard } from './components/AppCard'; import { AppCard } from './components/AppCard';
import { AppDetailsPanel } from './components/AppDetailsPanel';
import { HelpPanel } from './components/HelpPanel'; import { HelpPanel } from './components/HelpPanel';
import { Logo } from './components/Logo'; import { Logo } from './components/Logo';
import { PreferencesPanel } from './components/PreferencesPanel'; import { PreferencesPanel } from './components/PreferencesPanel';
@@ -34,6 +35,9 @@ type LoadState =
| { status: 'error'; message: string } | { status: 'error'; message: string }
| { status: 'ready'; value: LoadedCatalogue }; | { status: 'ready'; value: LoadedCatalogue };
type PortalOverlay =
{ kind: 'help' } | { kind: 'details'; appId: string } | null;
function appMatches( function appMatches(
app: ResolvedApp, app: ResolvedApp,
query: string, query: string,
@@ -50,29 +54,25 @@ function appMatches(
} }
interface AppSectionProps { interface AppSectionProps {
activeTag: string;
apps: ResolvedApp[]; apps: ResolvedApp[];
catalogueUrl: string; catalogueUrl: string;
hiddenIds: readonly string[]; hiddenIds: readonly string[];
id: string; id: string;
onCategory: (category: string) => void; onDetails: (id: string) => void;
onHide: (id: string) => void; onHide: (id: string) => void;
onPin: (id: string) => void; onPin: (id: string) => void;
onTag: (tag: string) => void;
pinned: boolean; pinned: boolean;
title: string; title: string;
} }
function AppSection({ function AppSection({
activeTag,
apps, apps,
catalogueUrl, catalogueUrl,
hiddenIds, hiddenIds,
id, id,
onCategory, onDetails,
onHide, onHide,
onPin, onPin,
onTag,
pinned, pinned,
title, title,
}: AppSectionProps) { }: AppSectionProps) {
@@ -97,11 +97,9 @@ function AppSection({
contextualUrl={contextualLaunchUrl} contextualUrl={contextualLaunchUrl}
pinned={pinned} pinned={pinned}
hidden={hiddenIds.includes(app.id)} hidden={hiddenIds.includes(app.id)}
activeTag={activeTag} onDetails={() => onDetails(app.id)}
onCategory={onCategory}
onPin={() => onPin(app.id)} onPin={() => onPin(app.id)}
onHide={() => onHide(app.id)} onHide={() => onHide(app.id)}
onTag={onTag}
/> />
))} ))}
</div> </div>
@@ -119,9 +117,10 @@ export default function App() {
const [shownHiddenIds, setShownHiddenIds] = useState< const [shownHiddenIds, setShownHiddenIds] = useState<
readonly string[] | null readonly string[] | null
>(null); >(null);
const [helpOpen, setHelpOpen] = useState(false); const [overlay, setOverlay] = useState<PortalOverlay>(null);
const hiddenToggle = useRef<HTMLInputElement>(null);
const [preferences, setPreferences, storageAvailable] = usePreferences(); const [preferences, setPreferences, storageAvailable] = usePreferences();
const closeHelp = useCallback(() => setHelpOpen(false), []); const closeOverlay = useCallback(() => setOverlay(null), []);
const showHidden = const showHidden =
preferences.hidden.length > 0 && preferences.hidden.length > 0 &&
shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true; shownHiddenIds?.some((id) => preferences.hidden.includes(id)) === true;
@@ -150,6 +149,11 @@ export default function App() {
const loaded = loadState.status === 'ready' ? loadState.value : undefined; const loaded = loadState.status === 'ready' ? loadState.value : undefined;
const apps = useMemo(() => loaded?.apps ?? [], [loaded]); const apps = useMemo(() => loaded?.apps ?? [], [loaded]);
const detailsApp =
overlay?.kind === 'details'
? apps.find((app) => app.id === overlay.appId)
: undefined;
const overlayOpen = overlay?.kind === 'help' || detailsApp !== undefined;
useEffect(() => { useEffect(() => {
if (!loaded) return; if (!loaded) return;
@@ -206,6 +210,7 @@ export default function App() {
})); }));
function toggleListItem(field: 'pinned' | 'hidden', id: string) { function toggleListItem(field: 'pinned' | 'hidden', id: string) {
const keepCardVisible = field === 'pinned' || showHidden;
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) { if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]); setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
} }
@@ -215,6 +220,20 @@ export default function App() {
? current[field].filter((item) => item !== id) ? current[field].filter((item) => item !== id)
: [...current[field], id], : [...current[field], id],
})); }));
window.requestAnimationFrame(() => {
if (!keepCardVisible) {
hiddenToggle.current?.focus();
return;
}
const action = field === 'pinned' ? 'pin' : 'visibility';
[
...document.querySelectorAll<HTMLButtonElement>(
`[data-card-action="${action}"]`
),
]
.find((button) => button.dataset.appId === id)
?.focus();
});
} }
function handleDragEnd(event: DragEndEvent) { function handleDragEnd(event: DragEndEvent) {
@@ -243,8 +262,8 @@ export default function App() {
<> <>
<div <div
className="site-shell" className="site-shell"
inert={helpOpen} inert={overlayOpen}
aria-hidden={helpOpen || undefined} aria-hidden={overlayOpen || undefined}
> >
<a className="skip-link" href="#main-content"> <a className="skip-link" href="#main-content">
Skip to tools Skip to tools
@@ -257,7 +276,7 @@ export default function App() {
homeHref={loaded?.homeUrl ?? './'} homeHref={loaded?.homeUrl ?? './'}
homeLabel="add·ideas Toolbox" homeLabel="add·ideas Toolbox"
brandIconUrl="./favicon.svg" brandIconUrl="./favicon.svg"
helpAction={{ onClick: () => setHelpOpen(true) }} helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }}
sourceHref={PORTAL_SOURCE} sourceHref={PORTAL_SOURCE}
sourceLabel="Toolbox source on Gitea" sourceLabel="Toolbox source on Gitea"
personalizeContent={ personalizeContent={
@@ -331,6 +350,7 @@ export default function App() {
}`} }`}
> >
<input <input
ref={hiddenToggle}
type="checkbox" type="checkbox"
checked={showHidden} checked={showHidden}
disabled={preferences.hidden.length === 0} disabled={preferences.hidden.length === 0}
@@ -415,30 +435,26 @@ export default function App() {
title="Pinned" title="Pinned"
apps={pinnedApps} apps={pinnedApps}
pinned pinned
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href} catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden} hiddenIds={preferences.hidden}
onCategory={setCategory} onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)} onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)} onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/> />
<AppSection <AppSection
id="regular-apps" id="regular-apps"
title="Tools" title="Tools"
apps={regularApps} apps={regularApps}
pinned={false} pinned={false}
activeTag={activeTag}
catalogueUrl={loaded.catalogueUrl.href} catalogueUrl={loaded.catalogueUrl.href}
hiddenIds={preferences.hidden} hiddenIds={preferences.hidden}
onCategory={setCategory} onDetails={(id) =>
setOverlay({ kind: 'details', appId: id })
}
onPin={(id) => toggleListItem('pinned', id)} onPin={(id) => toggleListItem('pinned', id)}
onHide={(id) => toggleListItem('hidden', id)} onHide={(id) => toggleListItem('hidden', id)}
onTag={(tag) =>
setActiveTag((current) => (current === tag ? '' : tag))
}
/> />
</div> </div>
</DndContext> </DndContext>
@@ -473,9 +489,9 @@ export default function App() {
</span> </span>
</p> </p>
<span> <span>
Portal v0.2.1 ·{' '} Portal v0.2.3 ·{' '}
<a <a
href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.1" href="https://git.add-ideas.de/zemion/toolbox-portal/src/tag/v0.4.3"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
@@ -485,7 +501,26 @@ export default function App() {
</footer> </footer>
</div> </div>
<HelpPanel open={helpOpen} onClose={closeHelp} /> <HelpPanel open={overlay?.kind === 'help'} onClose={closeOverlay} />
{detailsApp && loaded ? (
<AppDetailsPanel
app={detailsApp}
catalogueUrl={loaded.catalogueUrl.href}
contextualUrl={contextualLaunchUrl}
pinned={preferences.pinned.includes(detailsApp.id)}
hidden={preferences.hidden.includes(detailsApp.id)}
activeTag={activeTag}
onCategory={(nextCategory) => {
setCategory(nextCategory);
closeOverlay();
}}
onTag={(tag) => {
setActiveTag((current) => (current === tag ? '' : tag));
closeOverlay();
}}
onClose={closeOverlay}
/>
) : null}
</> </>
); );
} }

View File

@@ -1,5 +1,7 @@
import { useId } from 'react';
import { useSortable } from '@dnd-kit/sortable'; import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { shortToolDescription, shortToolTitle } from '../toolPresentation';
import type { ResolvedApp } from '../types'; import type { ResolvedApp } from '../types';
interface AppCardProps { interface AppCardProps {
@@ -7,48 +9,50 @@ interface AppCardProps {
catalogueUrl: string; catalogueUrl: string;
hidden: boolean; hidden: boolean;
pinned: boolean; pinned: boolean;
activeTag: string; onDetails: () => void;
onCategory: (category: string) => void;
onHide: () => void; onHide: () => void;
onPin: () => void; onPin: () => void;
onTag: (tag: string) => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string; contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
} }
function RequirementSummary({ app }: { app: ResolvedApp }) { function MoveIcon() {
if (!app.requirements) return null; return (
const requirements = [ <svg viewBox="0 0 20 20" aria-hidden="true">
app.requirements.secureContext && 'Secure context', <circle cx="7" cy="5" r="1.2" />
app.requirements.workers && 'Web workers', <circle cx="13" cy="5" r="1.2" />
app.requirements.indexedDb && 'IndexedDB', <circle cx="7" cy="10" r="1.2" />
app.requirements.crossOriginIsolated && 'Cross-origin isolation', <circle cx="13" cy="10" r="1.2" />
].filter(Boolean); <circle cx="7" cy="15" r="1.2" />
if (requirements.length === 0) return null; <circle cx="13" cy="15" r="1.2" />
return <p className="requirements">Needs {requirements.join(' · ')}</p>; </svg>
);
} }
function PrivacySummary({ app }: { app: ResolvedApp }) { function LightBulbIcon() {
if (!app.privacy)
return (
<p className="privacy-summary">
External destination · review its privacy terms
</p>
);
if (app.privacy.label)
return <p className="privacy-summary">{app.privacy.label}</p>;
const processing = {
local: 'Local processing',
remote: 'Remote processing',
mixed: 'Local and remote capabilities',
}[app.privacy.processing];
return ( return (
<p className="privacy-summary"> <svg viewBox="0 0 24 24" aria-hidden="true">
{processing} ·{' '} <path d="M9 18h6M10 21h4" />
{app.privacy.fileUploads <path d="M8.4 14.8A6 6 0 1 1 15.6 14.8c-.9.7-1.4 1.4-1.6 2.2h-4c-.2-.8-.7-1.5-1.6-2.2Z" />
? 'file/data transmission declared' </svg>
: 'no file uploads declared'}{' '} );
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'} }
</p>
function PinIcon() {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M8 3h8l-1 6 3 3v2H6v-2l3-3-1-6Z" />
<path d="M12 14v7" />
</svg>
);
}
function VisibilityIcon({ hidden }: { hidden: boolean }) {
return (
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M3 12s3.2-5 9-5 9 5 9 5-3.2 5-9 5-9-5-9-5Z" />
<circle cx="12" cy="12" r="2.3" />
{!hidden && <path d="m4 4 16 16" />}
</svg>
); );
} }
@@ -57,13 +61,12 @@ export function AppCard({
catalogueUrl, catalogueUrl,
hidden, hidden,
pinned, pinned,
activeTag, onDetails,
onCategory,
onHide, onHide,
onPin, onPin,
onTag,
contextualUrl, contextualUrl,
}: AppCardProps) { }: AppCardProps) {
const titleId = useId();
const { const {
attributes, attributes,
isDragging, isDragging,
@@ -84,10 +87,23 @@ export function AppCard({
className={`app-card${hidden ? ' app-card--hidden' : ''}${ className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : '' isDragging ? ' app-card--dragging' : ''
}`} }`}
aria-labelledby={titleId}
data-testid={`app-card-${app.id}`} data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }} style={{ transform: CSS.Transform.toString(transform), transition }}
> >
<div className="app-card__top"> <button
ref={setActivatorNodeRef}
{...attributes}
{...listeners}
className="drag-handle"
type="button"
aria-label={`Reorder ${app.name}`}
title="Drag to reorder"
>
<MoveIcon />
</button>
<div className="app-card__face">
<div className="app-card__icon" aria-hidden="true"> <div className="app-card__icon" aria-hidden="true">
{app.iconUrl ? ( {app.iconUrl ? (
<img src={app.iconUrl} alt="" loading="lazy" /> <img src={app.iconUrl} alt="" loading="lazy" />
@@ -95,112 +111,60 @@ export function AppCard({
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span> <span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)} )}
</div> </div>
<div <h3 id={titleId}>
className="app-card__actions" <a
aria-label={`Personalize ${app.name}`} className="app-card__launch-link"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
aria-label={`Open ${app.name}`}
>
{shortToolTitle(app)}
</a>
</h3>
<p>{shortToolDescription(app)}</p>
</div>
<div
className="app-card__actions"
role="group"
aria-label={`Actions for ${app.name}`}
>
<button
className="icon-button info-button"
type="button"
aria-label={`More information about ${app.name}`}
aria-haspopup="dialog"
title="More information"
data-details-trigger={app.id}
onClick={onDetails}
> >
<button <LightBulbIcon />
ref={setActivatorNodeRef} </button>
{...attributes} <button
{...listeners} className={`icon-button pin-button${pinned ? ' is-active' : ''}`}
className="icon-button drag-handle" type="button"
type="button" data-app-id={app.id}
aria-label={`Reorder ${app.name}`} data-card-action="pin"
title="Drag to reorder" aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`}
> aria-pressed={pinned}
<span aria-hidden="true"></span> title={pinned ? 'Unpin' : 'Pin'}
</button> onClick={onPin}
<button >
className={`icon-button${pinned ? ' is-active' : ''}`} <PinIcon />
type="button" </button>
aria-label={pinned ? `Unpin ${app.name}` : `Pin ${app.name}`} <button
aria-pressed={pinned} className="icon-button visibility-button"
title={pinned ? 'Unpin' : 'Pin'} type="button"
onClick={onPin} data-app-id={app.id}
> data-card-action="visibility"
<span aria-hidden="true"></span> aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`}
</button> title={hidden ? 'Show' : 'Hide'}
<button onClick={onHide}
className="icon-button" >
type="button" <VisibilityIcon hidden={hidden} />
aria-label={hidden ? `Show ${app.name}` : `Hide ${app.name}`} </button>
onClick={onHide}
>
<span aria-hidden="true">{hidden ? '○' : '—'}</span>
</button>
</div>
</div> </div>
<div className="app-card__body">
<div className="title-line">
<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>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
)}
{app.isExternal && (
<span className="status-chip status-chip--muted">External</span>
)}
</div>
<p>{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
{app.categories.length > 0 && (
<div className="category-row" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))}
</div>
)}
{app.tags.length > 0 && (
<div className="tag-row" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div>
<footer className="app-card__footer">
<div className="app-card__meta">
<span className="version">
{app.version ? `v${app.version}` : 'External service'}
</span>
{app.sourceUrl && (
<a
className="source-link"
href={app.sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
)}
</div>
<span className="open-hint">
Open tool <span aria-hidden="true"></span>
</span>
</footer>
</article> </article>
); );
} }

View File

@@ -0,0 +1,172 @@
import { useId } from 'react';
import type { ResolvedApp } from '../types';
import { shortToolTitle } from '../toolPresentation';
import { ModalPanel } from './ModalPanel';
interface AppDetailsPanelProps {
activeTag: string;
app: ResolvedApp;
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
onCategory: (category: string) => void;
onClose: () => void;
onTag: (tag: string) => void;
}
function RequirementSummary({ app }: { app: ResolvedApp }) {
if (!app.requirements) return null;
const requirements = [
app.requirements.secureContext && 'Secure context',
app.requirements.workers && 'Web workers',
app.requirements.indexedDb && 'IndexedDB',
app.requirements.crossOriginIsolated && 'Cross-origin isolation',
].filter(Boolean);
if (requirements.length === 0) return null;
return <p className="requirements">Needs {requirements.join(' · ')}</p>;
}
function PrivacySummary({ app }: { app: ResolvedApp }) {
if (!app.privacy)
return (
<p className="privacy-summary">
External destination · review its privacy terms
</p>
);
if (app.privacy.label)
return <p className="privacy-summary">{app.privacy.label}</p>;
const processing = {
local: 'Local processing',
remote: 'Remote processing',
mixed: 'Local and remote capabilities',
}[app.privacy.processing];
return (
<p className="privacy-summary">
{processing} ·{' '}
{app.privacy.fileUploads
? 'file/data transmission declared'
: 'no file uploads declared'}{' '}
· {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
</p>
);
}
export function AppDetailsPanel({
activeTag,
app,
catalogueUrl,
hidden,
pinned,
contextualUrl,
onCategory,
onClose,
onTag,
}: AppDetailsPanelProps) {
const titleId = useId();
const launchUrl = app.isExternal
? app.entryUrl
: contextualUrl(app.entryUrl, catalogueUrl);
const target = app.launchModes.includes('navigate') ? undefined : '_blank';
return (
<ModalPanel
className="app-info-panel"
labelledBy={titleId}
onClose={onClose}
>
<header className="app-info-panel__header">
<div className="app-info-panel__identity">
<div className="app-info-panel__icon" aria-hidden="true">
{app.iconUrl ? (
<img src={app.iconUrl} alt="" />
) : (
<span>{app.name.slice(0, 1).toLocaleUpperCase()}</span>
)}
</div>
<div>
<p className="eyebrow">Tool information</p>
<div className="title-line">
<h2 id={titleId}>{app.name}</h2>
{pinned && <span className="status-chip">Pinned</span>}
{hidden && (
<span className="status-chip status-chip--muted">Hidden</span>
)}
{app.isExternal && (
<span className="status-chip status-chip--muted">External</span>
)}
</div>
</div>
</div>
<button
className="close-button"
type="button"
aria-label={`Close information for ${app.name}`}
onClick={onClose}
>
×
</button>
</header>
<div className="app-info-panel__body">
<p className="app-info-panel__description">{app.description}</p>
<PrivacySummary app={app} />
<RequirementSummary app={app} />
{app.categories.length > 0 && (
<div className="category-row" role="group" aria-label="Categories">
{app.categories.slice(0, 3).map((category) => (
<button
type="button"
key={category}
onClick={() => onCategory(category)}
>
{category}
</button>
))}
</div>
)}
{app.tags.length > 0 && (
<div className="tag-row" role="group" aria-label="Tags">
{app.tags.map((tag) => (
<button
type="button"
key={tag}
aria-pressed={activeTag === tag}
onClick={() => onTag(tag)}
>
#{tag}
</button>
))}
</div>
)}
</div>
<footer className="app-info-panel__footer">
<div className="app-info-panel__meta">
<span className="version">
{app.version ? `v${app.version}` : 'External service'}
</span>
{app.sourceUrl && (
<a
className="source-link"
href={app.sourceUrl}
target="_blank"
rel="noopener noreferrer"
>
Source and license
</a>
)}
</div>
<a
className="app-info-panel__launch"
href={launchUrl}
target={target}
rel={target ? 'noopener noreferrer' : undefined}
>
Open {shortToolTitle(app)} <span aria-hidden="true"></span>
</a>
</footer>
</ModalPanel>
);
}

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef } from 'react'; import { ModalPanel } from './ModalPanel';
interface HelpPanelProps { interface HelpPanelProps {
open: boolean; open: boolean;
@@ -6,104 +6,46 @@ interface HelpPanelProps {
} }
export function HelpPanel({ open, onClose }: HelpPanelProps) { 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; if (!open) return null;
return ( return (
<div <ModalPanel
className="dialog-backdrop" className="help-panel"
role="presentation" labelledBy="help-title"
onMouseDown={(event) => event.target === event.currentTarget && onClose()} onClose={onClose}
> >
<section <header>
ref={panel} <div>
className="help-panel" <p className="eyebrow">Toolbox guide</p>
role="dialog" <h2 id="help-title">Choose and arrange your tools</h2>
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> </div>
</section> <button
</div> className="close-button"
type="button"
aria-label="Close help"
onClick={onClose}
>
×
</button>
</header>
<div className="help-steps">
<p>
<strong>Open:</strong> click anywhere on a tool tile that is not a
control.
</p>
<p>
<strong>Details:</strong> use the light bulb to review privacy,
requirements, categories, tags, version, and source before opening.
</p>
<p>
<strong>Filter:</strong> search, choose a category, or select a tag
from a tool's information panel. Select the tag again to clear it.
</p>
<p>
<strong>Arrange:</strong> pin frequently used tools into their own
section, then drag the grip to reorder. The grip also supports
keyboard dragging with Space, arrow keys, and Escape.
</p>
</div>
</ModalPanel>
); );
} }
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

View File

@@ -0,0 +1,102 @@
import { useEffect, useRef, type ReactNode } from 'react';
interface ModalPanelProps {
children: ReactNode;
className: string;
labelledBy: string;
onClose: () => void;
}
export function ModalPanel({
children,
className,
labelledBy,
onClose,
}: ModalPanelProps) {
const panel = useRef<HTMLElement>(null);
useEffect(() => {
const returnFocus =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
const dialog = panel.current;
const focusable = () =>
dialog
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
(element) => !element.hasAttribute('disabled')
)
: [];
(focusable()[0] ?? dialog)?.focus();
const previousOverflow = document.documentElement.style.overflow;
document.documentElement.style.overflow = 'hidden';
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
onClose();
return;
}
if (event.key !== 'Tab') return;
const elements = focusable();
const first = elements[0];
const last = elements.at(-1);
if (!first || !last) {
event.preventDefault();
dialog?.focus();
return;
}
const active = document.activeElement;
if (!dialog?.contains(active)) {
event.preventDefault();
(event.shiftKey ? last : first).focus();
} else if (event.shiftKey && active === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && active === last) {
event.preventDefault();
first.focus();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.documentElement.style.overflow = previousOverflow;
if (returnFocus?.isConnected) returnFocus.focus();
};
}, [onClose]);
return (
<div
className="dialog-backdrop"
role="presentation"
onPointerDown={(event) =>
event.target === event.currentTarget && onClose()
}
>
<section
ref={panel}
className={className}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
tabIndex={-1}
>
{children}
</section>
</div>
);
}
const FOCUSABLE_SELECTOR = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(',');

View File

@@ -1,10 +1,10 @@
import { 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 {
preferences: Preferences; preferences: Preferences;
@@ -20,10 +20,6 @@ export function PreferencesPanel({
const fileInput = useRef<HTMLInputElement>(null); const fileInput = useRef<HTMLInputElement>(null);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
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',
@@ -55,39 +51,11 @@ export function PreferencesPanel({
} }
return ( return (
<section <ToolboxPersonalizePanel
className="preferences-content" theme={preferences.theme}
aria-labelledby="preferences-title" onThemeChange={(theme) => onChange({ ...preferences, theme })}
storageAvailable={storageAvailable}
> >
<div className="preferences-heading">
<p className="preferences-kicker">On this device</p>
<h2 id="preferences-title">Personalize your toolbox</h2>
</div>
{!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 className="preference-summary">
<div> <div>
<strong>{preferences.pinned.length}</strong> <strong>{preferences.pinned.length}</strong>
@@ -139,6 +107,6 @@ export function PreferencesPanel({
<p className="action-message" role="status" aria-live="polite"> <p className="action-message" role="status" aria-live="polite">
{message} {message}
</p> </p>
</section> </ToolboxPersonalizePanel>
); );
} }

View File

@@ -125,8 +125,9 @@ summary:focus-visible {
main, main,
.site-footer { .site-footer {
width: min(1160px, calc(100% - 2.5rem)); width: min(100%, 90rem);
margin-inline: auto; margin-inline: auto;
padding-inline: 1rem;
} }
.state-card button, .state-card button,
@@ -305,23 +306,31 @@ main {
.app-grid { .app-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(
gap: 1.1rem; auto-fill,
minmax(min(10.5rem, 100%), 11.75rem)
);
align-items: start;
justify-content: start;
gap: 0.9rem;
} }
.app-card { .app-card {
position: relative; position: relative;
min-height: 340px; aspect-ratio: 1;
min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center;
justify-content: space-between;
overflow: hidden; overflow: hidden;
padding: 1rem 0.85rem 0.75rem;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: 1.2rem; border-radius: 1rem;
background: var(--surface); background: var(--surface);
box-shadow: var(--shadow); box-shadow: var(--shadow);
transition: transition:
border-color 180ms ease, border-color 180ms ease,
transform 180ms ease,
box-shadow 180ms ease; box-shadow 180ms ease;
} }
@@ -333,33 +342,35 @@ main {
.app-card:hover { .app-card:hover {
border-color: color-mix(in srgb, var(--brand) 35%, var(--line)); border-color: color-mix(in srgb, var(--brand) 35%, var(--line));
transform: translateY(-2px);
box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13); box-shadow: 0 20px 54px rgba(24, 34, 68, 0.13);
} }
.app-card--hidden { .app-card--hidden {
border-style: dashed; border-style: dashed;
opacity: 0.7; background: color-mix(in srgb, var(--surface-soft) 55%, var(--surface));
} }
.app-card__top { .app-card__face {
display: flex; min-height: 0;
align-items: flex-start; display: grid;
justify-content: space-between; flex: 1;
padding: 1.35rem 1.35rem 0; align-content: center;
justify-items: center;
padding: 0.2rem 0.25rem 0;
text-align: center;
} }
.app-card__icon { .app-card__icon {
width: 52px; width: 3.6rem;
height: 52px; height: 3.6rem;
display: grid; display: grid;
place-items: center; place-items: center;
overflow: hidden; overflow: hidden;
border-radius: 0.95rem; border-radius: 0.9rem;
background: var(--brand-soft); background: var(--brand-soft);
color: var(--brand); color: var(--brand);
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.45rem; font-size: 1.35rem;
font-weight: 700; font-weight: 700;
} }
@@ -369,51 +380,130 @@ main {
object-fit: contain; object-fit: contain;
} }
.app-card__actions { .app-card__face h3 {
position: relative; margin: 0.7rem 0 0;
z-index: 2; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
display: flex; font-size: 1.05rem;
gap: 0.15rem; letter-spacing: -0.025em;
}
.app-card__face p {
display: -webkit-box;
margin: 0.35rem 0 0;
overflow: hidden;
color: var(--muted);
font-size: 0.72rem;
line-height: 1.35;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.drag-handle { .drag-handle {
touch-action: none; position: absolute;
z-index: 2;
top: 50%;
left: 0.35rem;
width: 1.7rem;
height: 2.25rem;
display: grid;
place-items: center;
padding: 0;
border: 1px solid var(--line);
border-radius: 0.55rem;
background: var(--surface);
color: var(--muted);
opacity: 0;
cursor: grab; cursor: grab;
pointer-events: none;
touch-action: none;
transform: translate(-0.2rem, -50%);
transition:
opacity 150ms ease,
color 150ms ease,
transform 150ms ease;
}
.app-card:hover .drag-handle,
.app-card:focus-within .drag-handle,
.drag-handle:focus-visible {
opacity: 1;
pointer-events: auto;
transform: translate(0, -50%);
}
.drag-handle:hover {
color: var(--brand);
} }
.drag-handle:active { .drag-handle:active {
cursor: grabbing; cursor: grabbing;
} }
.drag-handle svg {
width: 1rem;
height: 1rem;
fill: currentColor;
}
.app-card__actions {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
gap: 0.35rem;
pointer-events: none;
}
.icon-button, .icon-button,
.close-button { .close-button {
width: 32px; width: 2.15rem;
height: 32px; height: 2.15rem;
display: grid; display: grid;
place-items: center; place-items: center;
border: 0; padding: 0;
border: 1px solid var(--line);
border-radius: 50%; border-radius: 50%;
background: transparent; background: var(--surface-soft);
color: var(--muted); color: var(--muted);
cursor: pointer; cursor: pointer;
} }
.app-card__actions .icon-button {
pointer-events: auto;
}
.icon-button:hover:not(:disabled), .icon-button:hover:not(:disabled),
.icon-button.is-active { .icon-button.is-active {
border-color: color-mix(in srgb, var(--brand) 30%, var(--line));
background: var(--brand-soft); background: var(--brand-soft);
color: var(--brand); color: var(--brand);
} }
.icon-button svg {
width: 1.05rem;
height: 1.05rem;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.7;
}
.pin-button svg {
transform: rotate(0);
transition: transform 180ms ease;
}
.pin-button.is-active svg {
transform: rotate(45deg);
}
.icon-button:disabled { .icon-button:disabled {
cursor: not-allowed; cursor: not-allowed;
opacity: 0.28; opacity: 0.28;
} }
.app-card__body {
flex: 1;
padding: 1.25rem 1.35rem 1rem;
}
.title-line { .title-line {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -421,10 +511,10 @@ main {
gap: 0.45rem; gap: 0.45rem;
} }
.title-line h3 { .title-line h2 {
margin: 0; margin: 0;
font-family: Inter, Avenir, 'Segoe UI', sans-serif; font-family: Inter, Avenir, 'Segoe UI', sans-serif;
font-size: 1.3rem; font-size: 1.35rem;
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
@@ -472,22 +562,6 @@ main {
color: var(--muted); color: var(--muted);
} }
.app-card__body > p {
margin: 0.75rem 0 0;
color: var(--muted);
line-height: 1.6;
}
.app-card__body .requirements {
font-size: 0.72rem;
}
.app-card__body .privacy-summary {
color: var(--text);
font-size: 0.76rem;
font-weight: 650;
}
.tag-row, .tag-row,
.category-row { .category-row {
display: flex; display: flex;
@@ -521,26 +595,11 @@ main {
color: var(--brand); color: var(--brand);
} }
.app-card__footer {
min-height: 68px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.85rem 1.35rem;
border-top: 1px solid var(--line);
}
.version { .version {
color: var(--muted); color: var(--muted);
font-size: 0.78rem; font-size: 0.78rem;
} }
.app-card__meta {
display: grid;
gap: 0.25rem;
}
.source-link, .source-link,
.site-footer a { .site-footer a {
position: relative; position: relative;
@@ -548,12 +607,6 @@ main {
color: var(--brand); color: var(--brand);
} }
.open-hint {
color: var(--brand);
font-size: 0.84rem;
font-weight: 700;
}
.state-card { .state-card {
max-width: 600px; max-width: 600px;
margin: 0 auto 5rem; margin: 0 auto 5rem;
@@ -657,14 +710,16 @@ main {
inset: 0; inset: 0;
display: grid; display: grid;
place-items: center; place-items: center;
overflow-y: auto;
padding: 1rem; padding: 1rem;
background: rgba(5, 9, 22, 0.56); background: rgba(5, 9, 22, 0.56);
backdrop-filter: blur(5px); backdrop-filter: blur(5px);
} }
.help-panel { .help-panel,
width: min(560px, 100%); .app-info-panel {
max-height: calc(100vh - 2rem); max-height: calc(100vh - 2rem);
max-height: calc(100dvh - 2rem);
overflow: auto; overflow: auto;
padding: 1.5rem; padding: 1.5rem;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -673,6 +728,15 @@ main {
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28); box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
} }
.help-panel {
width: min(560px, 100%);
}
.app-info-panel {
position: relative;
width: min(680px, 100%);
}
.help-panel > header { .help-panel > header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
@@ -686,42 +750,109 @@ main {
letter-spacing: -0.025em; letter-spacing: -0.025em;
} }
.app-info-panel__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.app-info-panel__identity {
min-width: 0;
display: flex;
align-items: center;
gap: 1rem;
}
.app-info-panel__icon {
width: 4rem;
height: 4rem;
display: grid;
flex: 0 0 auto;
place-items: center;
overflow: hidden;
border-radius: 1rem;
background: var(--brand-soft);
color: var(--brand);
font-size: 1.45rem;
font-weight: 700;
}
.app-info-panel__icon img {
width: 100%;
height: 100%;
object-fit: contain;
}
.app-info-panel .eyebrow {
margin-bottom: 0.4rem;
}
.app-info-panel__body {
margin-top: 1.35rem;
padding: 1.1rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--surface);
}
.app-info-panel__description {
margin: 0;
color: var(--muted);
font-size: 1rem;
line-height: 1.6;
}
.app-info-panel__body .privacy-summary {
margin: 1rem 0 0;
color: var(--text);
font-size: 0.8rem;
font-weight: 650;
line-height: 1.5;
}
.app-info-panel__body .requirements {
margin: 0.55rem 0 0;
color: var(--muted);
font-size: 0.76rem;
line-height: 1.5;
}
.app-info-panel__footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
}
.app-info-panel__meta {
display: grid;
gap: 0.25rem;
}
.app-info-panel__launch {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.7rem 0.9rem;
border-radius: 0.7rem;
background: var(--brand);
color: var(--surface) !important;
font-size: 0.85rem;
font-weight: 700;
}
.app-info-panel__launch:hover {
background: var(--brand-hover);
text-decoration: none;
}
.help-panel .eyebrow { .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 { .help-steps {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;
@@ -750,44 +881,6 @@ main {
font-size: 1.3rem; font-size: 1.3rem;
} }
.theme-choice {
margin: 1rem 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);
@@ -846,15 +939,6 @@ main {
font-size: 0.78rem; font-size: 0.78rem;
} }
.portal-header .inline-warning {
margin: 0 0 0.75rem;
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 {
position: absolute !important; position: absolute !important;
width: 1px !important; width: 1px !important;
@@ -869,7 +953,8 @@ main {
@media (max-width: 700px) { @media (max-width: 700px) {
main, main,
.site-footer { .site-footer {
width: min(100% - 1.4rem, 1160px); width: min(100%, 90rem);
padding-inline: 0.7rem;
} }
.hero { .hero {
@@ -890,14 +975,6 @@ main {
margin: 0.25rem 0; margin: 0.25rem 0;
} }
.app-grid {
grid-template-columns: 1fr;
}
.app-card {
min-height: 320px;
}
.site-footer { .site-footer {
min-height: 130px; min-height: 130px;
align-items: flex-start; align-items: flex-start;
@@ -921,15 +998,35 @@ main {
.hidden-toggle { .hidden-toggle {
justify-self: start; justify-self: start;
} }
.app-info-panel__header,
.app-info-panel__footer {
align-items: flex-start;
flex-direction: column;
}
.app-info-panel__header .close-button {
position: absolute;
top: 1.1rem;
right: 1.1rem;
}
.app-info-panel__identity {
padding-right: 2.5rem;
}
} }
@media (min-width: 701px) and (max-width: 960px) { @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);
} }
}
.app-grid { @media (hover: none) {
grid-template-columns: repeat(2, minmax(0, 1fr)); .drag-handle {
opacity: 1;
pointer-events: auto;
transform: translate(0, -50%);
} }
} }

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.1', version: '0.4.3',
description: 'Page-level PDF operations performed locally in the browser.', description: 'Page-level PDF operations performed locally in the browser.',
entry: './', entry: './',
icon: './favicon.svg', icon: './favicon.svg',
@@ -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.1', 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.1', version: '0.3.3',
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'],

27
src/toolPresentation.ts Normal file
View File

@@ -0,0 +1,27 @@
import type { ResolvedApp } from './types';
const PRESENTATION: Record<string, { title: string; description: string }> = {
'de.add-ideas.pdf-tools': {
title: 'PDF',
description: 'Arrange PDF pages locally.',
},
'de.add-ideas.xslt-tools': {
title: 'XSLT',
description: 'Transform XML locally.',
},
'de.add-ideas.onenote-tools': {
title: 'OneNote',
description: 'Open OneNote files locally.',
},
};
export function shortToolTitle(app: ResolvedApp): string {
return (
PRESENTATION[app.id]?.title ??
app.name.replace(/\s+(?:reader|tools?|workbench)$/iu, '')
);
}
export function shortToolDescription(app: ResolvedApp): string {
return PRESENTATION[app.id]?.description ?? app.description;
}