Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 803397ff52 | |||
| 21283ce25f | |||
| e44d35fdfb |
@@ -4,5 +4,5 @@ TRAEFIK_NETWORK=internal
|
||||
TRAEFIK_CERT_RESOLVER=netcup
|
||||
|
||||
# Keep these two values paired when deploying a newer published toolbox release.
|
||||
TOOLBOX_RELEASE_VERSION=0.4.0
|
||||
TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112
|
||||
TOOLBOX_RELEASE_VERSION=0.4.2
|
||||
TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389
|
||||
|
||||
@@ -2,8 +2,8 @@ ARG NGINX_IMAGE=nginxinc/nginx-unprivileged:1.31.3-alpine@sha256:18d67281256ded3
|
||||
|
||||
FROM ${NGINX_IMAGE} AS release
|
||||
|
||||
ARG TOOLBOX_RELEASE_VERSION=0.4.0
|
||||
ARG TOOLBOX_RELEASE_SHA256=0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112
|
||||
ARG TOOLBOX_RELEASE_VERSION=0.4.2
|
||||
ARG TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389
|
||||
ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download
|
||||
|
||||
RUN set -eu; \
|
||||
|
||||
28
README.md
28
README.md
@@ -1,7 +1,7 @@
|
||||
# add·ideas Toolbox Portal
|
||||
|
||||
`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,
|
||||
visibility, and appearance in the current browser. Pinned tools have their own
|
||||
section; search, category, and clickable tag filters stay close to the tool
|
||||
@@ -30,7 +30,7 @@ npm run build
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
files into the pinned unprivileged nginx image. Node.js and a local portal
|
||||
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
|
||||
versions are independent.
|
||||
|
||||
`release/toolbox.lock.json` is the reviewed v0.4.0 lock. Its URLs and checksums
|
||||
pin the published PDF Tools 0.4.0, XSLT Tools 0.4.0, and OneNote Tools 0.3.0
|
||||
`release/toolbox.lock.json` is the reviewed v0.4.2 lock. Its URLs and checksums
|
||||
pin the published PDF Tools 0.4.2, XSLT Tools 0.4.2, and OneNote Tools 0.3.2
|
||||
release bytes. Use
|
||||
`release/toolbox.lock.example.json` as the template for a future release and
|
||||
verify every downloaded asset before committing updated values. Artifacts may be:
|
||||
@@ -142,7 +142,7 @@ npm run assemble -- \
|
||||
--lock release/toolbox.lock.json \
|
||||
--portal-dist dist \
|
||||
--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
|
||||
@@ -162,8 +162,8 @@ build/toolbox/
|
||||
├── xslt/
|
||||
└── onenote/
|
||||
|
||||
build/add-ideas-toolbox-0.4.0.zip
|
||||
build/add-ideas-toolbox-0.4.0.zip.sha256
|
||||
build/add-ideas-toolbox-0.4.2.zip
|
||||
build/add-ideas-toolbox-0.4.2.zip.sha256
|
||||
```
|
||||
|
||||
The assembler verifies SHA-256 before opening an artifact, validates every app
|
||||
@@ -184,7 +184,7 @@ directory separately:
|
||||
```sh
|
||||
npm run package:static -- \
|
||||
--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.
|
||||
@@ -201,9 +201,9 @@ normal local XML/XSLT transformations do not require that permission.
|
||||
|
||||
```sh
|
||||
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 \
|
||||
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:
|
||||
@@ -220,7 +220,7 @@ docker login git.add-ideas.de
|
||||
docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--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 \
|
||||
--push .
|
||||
```
|
||||
@@ -246,8 +246,8 @@ permission to push code, releases, and container packages.
|
||||
all three apps, switch between them, and confirm the encoded `toolbox`
|
||||
context.
|
||||
5. Verify the distribution with
|
||||
`(cd build && sha256sum -c add-ideas-toolbox-0.4.0.zip.sha256)`.
|
||||
6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.0`), and
|
||||
`(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.2`), and
|
||||
push the branch and tag to Gitea.
|
||||
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
|
||||
|
||||
@@ -3,7 +3,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
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
|
||||
read_only: true
|
||||
ports:
|
||||
|
||||
@@ -6,9 +6,9 @@ services:
|
||||
context: .
|
||||
dockerfile: Containerfile.release
|
||||
args:
|
||||
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.0}"
|
||||
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-0264f5ee2c545c10c50b96a5b6d55c3576b39bb29c546e1e4f1459fec4e8e112}"
|
||||
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.0}"
|
||||
TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.2}"
|
||||
TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389}"
|
||||
image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.2}"
|
||||
restart: unless-stopped
|
||||
read_only: true
|
||||
tmpfs:
|
||||
|
||||
22
package-lock.json
generated
22
package-lock.json
generated
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-portal",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@add-ideas/toolbox-portal",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "^0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.0",
|
||||
"@add-ideas/toolbox-contract": "^0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
@@ -44,18 +44,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.0/toolbox-contract-0.2.0.tgz",
|
||||
"integrity": "sha512-up+wY+ft1L7TbXrflcTeTOwEdcFwHBWFlGFj8skQvcLy5z2mDBpd6/7PVQKDvDTkiODu2npviHm6AFglFw+17Q==",
|
||||
"version": "0.2.2",
|
||||
"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-VcZ8j4O2PgFaIYgxs6r9u0uPxA+hHKwhCW+omfeVCtbjAAYhH2KhRlBGm0ERVVYcK5kchyzZLXrC4LKWRotVzg==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.0",
|
||||
"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",
|
||||
"integrity": "sha512-qYR+OBXX8rfB+BYCsUbpiCzo2xK12SIPWVNXHre158Da5uT6usr8rRvp5qa5u2oQJ50PNzlXyXMyTeRp0Vz5wg==",
|
||||
"version": "0.2.2",
|
||||
"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-w/xbLCd50a2Jq7vQ9Z9ygUOuqlOCRkIYlk4vSJx0mf4REJNeMYi3PE2MbaLUC4DkQWyrmatsa5HNT89o0l91BA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.0"
|
||||
"@add-ideas/toolbox-contract": "0.2.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-portal",
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.2",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -21,8 +21,8 @@
|
||||
"package:static": "node scripts/package-static.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "^0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.0",
|
||||
"@add-ideas/toolbox-contract": "^0.2.2",
|
||||
"@add-ideas/toolbox-shell-react": "^0.2.2",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# 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
|
||||
license materials. The application sources are also linked from the portal.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "./toolbox-release-lock.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"releaseVersion": "0.4.0",
|
||||
"portalVersion": "0.2.0",
|
||||
"releaseVersion": "0.4.2",
|
||||
"portalVersion": "0.2.2",
|
||||
"catalogue": {
|
||||
"id": "de.add-ideas.toolbox",
|
||||
"name": "add·ideas Toolbox",
|
||||
@@ -15,23 +15,23 @@
|
||||
"apps": [
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"version": "0.4.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip",
|
||||
"sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca",
|
||||
"version": "0.4.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.2/pdf-tools-0.4.2.zip",
|
||||
"sha256": "ebb6711e149a59abf51eddc0927debba9118205bde91faf7dded132478bc23d0",
|
||||
"target": "pdf"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.xslt-tools",
|
||||
"version": "0.4.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip",
|
||||
"sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142",
|
||||
"version": "0.4.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
|
||||
"sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
|
||||
"target": "xslt"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.onenote-tools",
|
||||
"version": "0.3.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip",
|
||||
"sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3",
|
||||
"version": "0.3.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.2/onenote-tools-0.3.2.zip",
|
||||
"sha256": "c07e6909ae57bd7681f26ec29c7a4e927b6919123ef9e28aa2d8c55b842b65d2",
|
||||
"target": "onenote"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"$schema": "./toolbox-release-lock.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"releaseVersion": "0.4.0",
|
||||
"portalVersion": "0.2.0",
|
||||
"releaseVersion": "0.4.2",
|
||||
"portalVersion": "0.2.2",
|
||||
"catalogue": {
|
||||
"id": "de.add-ideas.toolbox",
|
||||
"name": "add·ideas Toolbox",
|
||||
@@ -15,23 +15,23 @@
|
||||
"apps": [
|
||||
{
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"version": "0.4.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.0/pdf-tools-0.4.0.zip",
|
||||
"sha256": "c9a2d3bed41c41dc6664f6ab7ecefebb5ef4895ce49cd7b0726e3d47d62a66ca",
|
||||
"version": "0.4.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.2/pdf-tools-0.4.2.zip",
|
||||
"sha256": "ebb6711e149a59abf51eddc0927debba9118205bde91faf7dded132478bc23d0",
|
||||
"target": "pdf"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.xslt-tools",
|
||||
"version": "0.4.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.0/xslt-tools-0.4.0.zip",
|
||||
"sha256": "62e02c08d25456cd0a876d4005dfdcadf69471619230cb5f03f62451f501f142",
|
||||
"version": "0.4.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.2/xslt-tools-0.4.2.zip",
|
||||
"sha256": "daaa821c10c6c6285f97128ba3d923e48efa24449f1fb0a21bf478abaf27499f",
|
||||
"target": "xslt"
|
||||
},
|
||||
{
|
||||
"id": "de.add-ideas.onenote-tools",
|
||||
"version": "0.3.0",
|
||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.0/onenote-tools-0.3.0.zip",
|
||||
"sha256": "44fa28252995b4bf7789b7a546091ee70d43990ab893481d9f4831d5cf5525e3",
|
||||
"version": "0.3.2",
|
||||
"artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.2/onenote-tools-0.3.2.zip",
|
||||
"sha256": "c07e6909ae57bd7681f26ec29c7a4e927b6919123ef9e28aa2d8c55b842b65d2",
|
||||
"target": "onenote"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -83,7 +83,7 @@ function makeLock(artifact: string, sha256: string) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
releaseVersion: '0.1.0',
|
||||
portalVersion: '0.2.0',
|
||||
portalVersion: '0.2.2',
|
||||
catalogue: {
|
||||
id: 'de.add-ideas.toolbox',
|
||||
name: 'Test Toolbox',
|
||||
|
||||
215
src/App.test.tsx
215
src/App.test.tsx
@@ -39,12 +39,17 @@ describe('portal UI', () => {
|
||||
).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
expect(
|
||||
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();
|
||||
expect(
|
||||
screen.queryByRole('region', { name: 'Pinned' })
|
||||
).not.toBeInTheDocument();
|
||||
const showHidden = screen.getByRole('checkbox', {
|
||||
name: 'Show hidden (0)',
|
||||
});
|
||||
expect(showHidden).toBeDisabled();
|
||||
expect(showHidden).not.toBeChecked();
|
||||
const launch = screen
|
||||
.getAllByRole('link', { name: 'PDF Workbench' })
|
||||
.find((link) => link.classList.contains('app-card__launch-link'))!;
|
||||
@@ -74,10 +79,102 @@ describe('portal UI', () => {
|
||||
expect(
|
||||
screen.queryByRole('heading', { name: 'PDF Workbench' })
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('checkbox', { name: /show hidden/i }));
|
||||
expect(showHidden).toBeEnabled();
|
||||
expect(showHidden).toHaveAccessibleName('Show hidden (1)');
|
||||
fireEvent.click(showHidden);
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'PDF Workbench' })
|
||||
).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 () => {
|
||||
@@ -85,7 +182,7 @@ describe('portal UI', () => {
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
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', {
|
||||
name: 'Toolbox applications',
|
||||
});
|
||||
@@ -97,18 +194,87 @@ describe('portal UI', () => {
|
||||
).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 () => {
|
||||
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: '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' }));
|
||||
expect(document.documentElement).toHaveAttribute('data-theme', 'dark');
|
||||
await waitFor(() =>
|
||||
expect(localStorage.getItem(PREFERENCES_KEY)).toContain('"theme":"dark"')
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: 'Close preferences' }));
|
||||
await user.click(personalize);
|
||||
|
||||
fireEvent(
|
||||
window,
|
||||
@@ -208,14 +374,16 @@ describe('portal UI', () => {
|
||||
it('opens the standard help control and explains tile interaction', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn(catalogueFetch()));
|
||||
const user = userEvent.setup();
|
||||
render(<App />);
|
||||
const { container } = render(<App />);
|
||||
await screen.findByRole('heading', { name: 'PDF Workbench' });
|
||||
await user.click(screen.getByRole('button', { name: 'Help' }));
|
||||
expect(
|
||||
screen.getByRole('dialog', { name: 'Choose and arrange your tools' })
|
||||
).toHaveTextContent('click anywhere on a tool tile');
|
||||
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||
await user.click(screen.getByRole('button', { name: 'Close help' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
|
||||
});
|
||||
|
||||
it('shows a recoverable catalogue error', async () => {
|
||||
@@ -249,31 +417,42 @@ describe('portal UI', () => {
|
||||
).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()));
|
||||
const user = userEvent.setup();
|
||||
const { container } = render(<App />);
|
||||
const personalize = screen.getByRole('button', { name: 'Personalize' });
|
||||
|
||||
await user.click(personalize);
|
||||
const dialog = screen.getByRole('dialog', {
|
||||
const heading = screen.getByRole('heading', {
|
||||
name: 'Personalize your toolbox',
|
||||
});
|
||||
const close = screen.getByRole('button', { name: 'Close preferences' });
|
||||
expect(close).toHaveFocus();
|
||||
expect(container.querySelector('.site-shell')).toHaveAttribute('inert');
|
||||
expect(dialog).toBeInTheDocument();
|
||||
const popover = screen.getByRole('dialog', { name: 'Personalize Toolbox' });
|
||||
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(container.querySelector('.site-shell')).not.toHaveAttribute('inert');
|
||||
expect(screen.getByText(/does not transmit them/i)).toHaveTextContent(
|
||||
/Trusted code in another Toolbox app on this site can access same-origin storage/i
|
||||
);
|
||||
|
||||
const reset = screen.getByRole('button', { name: 'Reset' });
|
||||
reset.focus();
|
||||
await user.tab();
|
||||
expect(close).toHaveFocus();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export JSON' })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Import JSON' })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Reset' })).toBeInTheDocument();
|
||||
|
||||
await user.keyboard('{Escape}');
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: 'Personalize Toolbox' })
|
||||
).not.toBeInTheDocument();
|
||||
expect(personalize).toHaveFocus();
|
||||
});
|
||||
});
|
||||
|
||||
80
src/App.tsx
80
src/App.tsx
@@ -116,13 +116,15 @@ export default function App() {
|
||||
const [query, setQuery] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [activeTag, setActiveTag] = useState('');
|
||||
const [showHidden, setShowHidden] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [shownHiddenIds, setShownHiddenIds] = useState<
|
||||
readonly string[] | null
|
||||
>(null);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [preferences, setPreferences, storageAvailable] = usePreferences();
|
||||
const closeSettings = useCallback(() => setSettingsOpen(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(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 8 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })
|
||||
@@ -204,6 +206,9 @@ export default function App() {
|
||||
}));
|
||||
|
||||
function toggleListItem(field: 'pinned' | 'hidden', id: string) {
|
||||
if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) {
|
||||
setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]);
|
||||
}
|
||||
setPreferences((current) => ({
|
||||
...current,
|
||||
[field]: current[field].includes(id)
|
||||
@@ -231,33 +236,37 @@ export default function App() {
|
||||
setQuery('');
|
||||
setCategory('');
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
className="site-shell"
|
||||
inert={dialogOpen}
|
||||
aria-hidden={dialogOpen || undefined}
|
||||
inert={helpOpen}
|
||||
aria-hidden={helpOpen || undefined}
|
||||
>
|
||||
<a className="skip-link" href="#main-content">
|
||||
Skip to tools
|
||||
</a>
|
||||
<ToolboxHeader
|
||||
className="portal-header"
|
||||
title={title}
|
||||
iconUrl="./favicon.svg"
|
||||
title="Toolbox"
|
||||
subtitle="Local-first browser tools"
|
||||
theme={preferences.theme}
|
||||
homeHref={loaded?.homeUrl ?? './'}
|
||||
homeLabel={brand}
|
||||
homeLabel="add·ideas Toolbox"
|
||||
brandIconUrl="./favicon.svg"
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
sourceHref={PORTAL_SOURCE}
|
||||
sourceLabel="Toolbox source on Gitea"
|
||||
onPersonalize={() => setSettingsOpen(true)}
|
||||
personalizeContent={
|
||||
<PreferencesPanel
|
||||
preferences={preferences}
|
||||
storageAvailable={storageAvailable}
|
||||
onChange={setPreferences}
|
||||
/>
|
||||
}
|
||||
apps={headerApps}
|
||||
allAppsHref="#main-content"
|
||||
/>
|
||||
@@ -270,8 +279,7 @@ export default function App() {
|
||||
</p>
|
||||
<h2 id="page-title">
|
||||
Your document tools,
|
||||
<br />
|
||||
<span>in one calm place.</span>
|
||||
<br /> <span>in one calm place.</span>
|
||||
</h2>
|
||||
<p className="hero-copy">
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
{preferences.hidden.length > 0 && (
|
||||
<label className="hidden-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
onChange={(event) => setShowHidden(event.target.checked)}
|
||||
/>
|
||||
Show hidden ({preferences.hidden.length})
|
||||
</label>
|
||||
)}
|
||||
<label
|
||||
className={`hidden-toggle${
|
||||
preferences.hidden.length === 0
|
||||
? ' hidden-toggle--disabled'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showHidden}
|
||||
disabled={preferences.hidden.length === 0}
|
||||
onChange={(event) =>
|
||||
setShownHiddenIds(
|
||||
event.target.checked ? [...preferences.hidden] : null
|
||||
)
|
||||
}
|
||||
/>
|
||||
Show hidden ({preferences.hidden.length})
|
||||
</label>
|
||||
</section>
|
||||
)}
|
||||
</section>
|
||||
@@ -456,9 +473,9 @@ export default function App() {
|
||||
</span>
|
||||
</p>
|
||||
<span>
|
||||
Portal v0.2.0 ·{' '}
|
||||
Portal v0.2.2 ·{' '}
|
||||
<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"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
@@ -468,13 +485,6 @@ export default function App() {
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<PreferencesPanel
|
||||
open={settingsOpen}
|
||||
preferences={preferences}
|
||||
storageAvailable={storageAvailable}
|
||||
onChange={setPreferences}
|
||||
onClose={closeSettings}
|
||||
/>
|
||||
<HelpPanel open={helpOpen} onClose={closeHelp} />
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
defaultPreferences,
|
||||
parsePreferences,
|
||||
type Preferences,
|
||||
} from '../preferences';
|
||||
import type { ThemeMode } from '../types';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
open: boolean;
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
onChange: (preferences: Preferences) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PreferencesPanel({
|
||||
open,
|
||||
preferences,
|
||||
storageAvailable,
|
||||
onChange,
|
||||
onClose,
|
||||
}: PreferencesPanelProps) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
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() {
|
||||
const blob = new Blob([`${JSON.stringify(preferences, null, 2)}\n`], {
|
||||
type: 'application/json',
|
||||
@@ -119,119 +51,62 @@ export function PreferencesPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
|
||||
<ToolboxPersonalizePanel
|
||||
theme={preferences.theme}
|
||||
onThemeChange={(theme) => onChange({ ...preferences, theme })}
|
||||
storageAvailable={storageAvailable}
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="preferences-panel"
|
||||
role="dialog"
|
||||
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 className="preference-summary">
|
||||
<div>
|
||||
<strong>{preferences.pinned.length}</strong>
|
||||
<span>Pinned</span>
|
||||
</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>
|
||||
<strong>{preferences.hidden.length}</strong>
|
||||
<span>Hidden</span>
|
||||
</div>
|
||||
<p className="action-message" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.order.length}</strong>
|
||||
<span>Ordered</span>
|
||||
</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(',');
|
||||
|
||||
108
src/styles.css
108
src/styles.css
@@ -123,7 +123,6 @@ summary:focus-visible {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.portal-header .toolbox-shell__bar,
|
||||
main,
|
||||
.site-footer {
|
||||
width: min(1160px, calc(100% - 2.5rem));
|
||||
@@ -192,13 +191,9 @@ main {
|
||||
|
||||
.tool-controls {
|
||||
display: grid;
|
||||
align-content: end;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(8.75rem, 10.5rem);
|
||||
align-items: center;
|
||||
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,
|
||||
@@ -212,7 +207,7 @@ main {
|
||||
}
|
||||
|
||||
.search-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 0.85rem;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
@@ -235,7 +230,7 @@ main {
|
||||
}
|
||||
|
||||
.category-field {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: 0 0.65rem;
|
||||
}
|
||||
|
||||
@@ -244,15 +239,21 @@ main {
|
||||
}
|
||||
|
||||
.hidden-toggle {
|
||||
grid-column: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-self: end;
|
||||
gap: 0.45rem;
|
||||
margin: 0.15rem 0 0;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.hidden-toggle--disabled {
|
||||
cursor: default;
|
||||
opacity: 0.58;
|
||||
}
|
||||
|
||||
.hidden-toggle input {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
@@ -260,6 +261,7 @@ main {
|
||||
}
|
||||
|
||||
.active-filter {
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
padding: 0.4rem 0.65rem;
|
||||
border: 1px solid color-mix(in srgb, var(--brand) 35%, var(--line));
|
||||
@@ -660,7 +662,6 @@ main {
|
||||
backdrop-filter: blur(5px);
|
||||
}
|
||||
|
||||
.preferences-panel,
|
||||
.help-panel {
|
||||
width: min(560px, 100%);
|
||||
max-height: calc(100vh - 2rem);
|
||||
@@ -672,7 +673,6 @@ main {
|
||||
box-shadow: 0 30px 90px rgba(0, 0, 0, 0.28);
|
||||
}
|
||||
|
||||
.preferences-panel > header,
|
||||
.help-panel > header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -680,14 +680,12 @@ main {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.preferences-panel h2,
|
||||
.help-panel h2 {
|
||||
margin: 0;
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.preferences-panel .eyebrow,
|
||||
.help-panel .eyebrow {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
@@ -720,44 +718,6 @@ main {
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
@@ -767,7 +727,7 @@ main {
|
||||
.preference-summary > div {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.9rem;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.7rem;
|
||||
text-align: center;
|
||||
@@ -775,7 +735,7 @@ main {
|
||||
|
||||
.preference-summary strong {
|
||||
font-family: Inter, Avenir, 'Segoe UI', sans-serif;
|
||||
font-size: 1.25rem;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.preference-summary span,
|
||||
@@ -785,8 +745,11 @@ main {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.preferences-copy {
|
||||
margin: 1.1rem 0;
|
||||
.portal-header .preferences-copy {
|
||||
margin: 0.9rem 0;
|
||||
padding: 0;
|
||||
color: var(--toolbox-muted, var(--muted));
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@@ -797,7 +760,7 @@ main {
|
||||
}
|
||||
|
||||
.preferences-actions button {
|
||||
padding: 0.62rem 0.78rem;
|
||||
padding: 0.52rem 0.68rem;
|
||||
}
|
||||
|
||||
.preferences-actions .danger-button {
|
||||
@@ -805,17 +768,12 @@ main {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.action-message {
|
||||
.portal-header .action-message {
|
||||
min-height: 1.2em;
|
||||
margin: 0.8rem 0 0;
|
||||
}
|
||||
|
||||
.inline-warning {
|
||||
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;
|
||||
padding: 0;
|
||||
color: var(--toolbox-muted, var(--muted));
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
@@ -830,7 +788,6 @@ main {
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.portal-header .toolbox-shell__bar,
|
||||
main,
|
||||
.site-footer {
|
||||
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) {
|
||||
.hero {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem);
|
||||
|
||||
@@ -7,7 +7,7 @@ export const pdfManifest: ToolboxAppManifest = {
|
||||
schemaVersion: 1,
|
||||
id: 'de.add-ideas.pdf-tools',
|
||||
name: 'PDF Workbench',
|
||||
version: '0.4.0',
|
||||
version: '0.4.2',
|
||||
description: 'Page-level PDF operations performed locally in the browser.',
|
||||
entry: './',
|
||||
icon: './favicon.svg',
|
||||
@@ -39,7 +39,7 @@ export const xsltManifest: ToolboxAppManifest = {
|
||||
...pdfManifest,
|
||||
id: 'de.add-ideas.xslt-tools',
|
||||
name: 'XSLT Workbench',
|
||||
version: '0.4.0',
|
||||
version: '0.4.2',
|
||||
description: 'Transform XML locally with XSLT in the browser.',
|
||||
icon: './xslt.svg',
|
||||
categories: ['documents', 'developer'],
|
||||
@@ -54,7 +54,7 @@ export const onenoteManifest: ToolboxAppManifest = {
|
||||
...pdfManifest,
|
||||
id: 'de.add-ideas.onenote-tools',
|
||||
name: 'OneNote Reader',
|
||||
version: '0.3.0',
|
||||
version: '0.3.2',
|
||||
description: 'Inspect OneNote packages locally in the browser.',
|
||||
icon: './onenote.svg',
|
||||
categories: ['documents', 'notes'],
|
||||
|
||||
Reference in New Issue
Block a user