From e44d35fdfb7669bc97dbefc0e40905bbef06116f Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 23 Jul 2026 01:21:16 +0200 Subject: [PATCH] fix: stabilize Toolbox navigation layout --- .env.example | 4 +- Containerfile.release | 4 +- README.md | 28 +-- compose.example.yaml | 2 +- compose.yaml | 6 +- package-lock.json | 22 +-- package.json | 6 +- public/SOURCE.md | 4 +- release/toolbox.lock.example.json | 22 +-- release/toolbox.lock.json | 22 +-- scripts/assembler.test.ts | 2 +- src/App.test.tsx | 163 ++++++++++++++++-- src/App.tsx | 76 +++++---- src/components/PreferencesPanel.tsx | 255 +++++++++------------------- src/styles.css | 99 ++++++++--- src/test/fixtures.ts | 6 +- 16 files changed, 407 insertions(+), 314 deletions(-) diff --git a/.env.example b/.env.example index cbf3440..69c4cbc 100644 --- a/.env.example +++ b/.env.example @@ -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.1 +TOOLBOX_RELEASE_SHA256=36133af8d2b3a7084874303a88c62a5581950c51e9e4786b985fe7ea48f71f0d diff --git a/Containerfile.release b/Containerfile.release index cb79da9..0dd7cea 100644 --- a/Containerfile.release +++ b/Containerfile.release @@ -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.1 +ARG TOOLBOX_RELEASE_SHA256=36133af8d2b3a7084874303a88c62a5581950c51e9e4786b985fe7ea48f71f0d ARG TOOLBOX_RELEASE_BASE_URL=https://git.add-ideas.de/zemion/toolbox-portal/releases/download RUN set -eu; \ diff --git a/README.md b/README.md index 0d5ed6f..59d934d 100644 --- a/README.md +++ b/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.1` 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.1` SDK ranges to the published `0.2.1` 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.1 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.1 lock. Its URLs and checksums +pin the published PDF Tools 0.4.1, XSLT Tools 0.4.1, and OneNote Tools 0.3.1 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.1.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.1.zip +build/add-ideas-toolbox-0.4.1.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.1.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.1 . podman run --rm -p 8080:8080 \ - git.add-ideas.de/zemion/toolbox:0.4.0 + git.add-ideas.de/zemion/toolbox:0.4.1 ``` 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.1 \ --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.1.zip.sha256)`. +6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.1`), 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 diff --git a/compose.example.yaml b/compose.example.yaml index ac6b7ae..e9560d2 100644 --- a/compose.example.yaml +++ b/compose.example.yaml @@ -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.1 restart: unless-stopped read_only: true ports: diff --git a/compose.yaml b/compose.yaml index 59fd2ec..cfe182a 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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.1}" + TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-36133af8d2b3a7084874303a88c62a5581950c51e9e4786b985fe7ea48f71f0d}" + image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.1}" restart: unless-stopped read_only: true tmpfs: diff --git a/package-lock.json b/package-lock.json index 1f11167..8744af5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,16 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.2.0", + "version": "0.2.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-portal", - "version": "0.2.0", + "version": "0.2.1", "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.1", + "@add-ideas/toolbox-shell-react": "^0.2.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", @@ -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.1", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.1/toolbox-contract-0.2.1.tgz", + "integrity": "sha512-NmwV41+ZS0qBWUnI9vflm0PcsiqITmxpSQQ5vMVaToFZuSSIJupgM43tuV9G4OJ1En0eTdYNxbsd3nsYhju2kw==", "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.1", + "resolved": "https://git.add-ideas.de/api/packages/zemion/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.1/toolbox-shell-react-0.2.1.tgz", + "integrity": "sha512-slpz9ODVbk27b+uvG3WeCmNa71Q6guabyVHT+jyGLT6ENLSbwmoEFZ0mKNJDxemltpt9KY8I7z47uNBsQ7yKYg==", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.0" + "@add-ideas/toolbox-contract": "0.2.1" }, "peerDependencies": { "react": ">=18 <20", diff --git a/package.json b/package.json index 53686d7..437e49a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.2.0", + "version": "0.2.1", "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.1", + "@add-ideas/toolbox-shell-react": "^0.2.1", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/public/SOURCE.md b/public/SOURCE.md index 15bd081..9806cdb 100644 --- a/public/SOURCE.md +++ b/public/SOURCE.md @@ -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.1 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.1 Each bundled application contains its own `SOURCE.md`, license, and third-party license materials. The application sources are also linked from the portal. diff --git a/release/toolbox.lock.example.json b/release/toolbox.lock.example.json index bcd59e6..fc2bfb7 100644 --- a/release/toolbox.lock.example.json +++ b/release/toolbox.lock.example.json @@ -1,8 +1,8 @@ { "$schema": "./toolbox-release-lock.schema.json", "schemaVersion": 1, - "releaseVersion": "0.4.0", - "portalVersion": "0.2.0", + "releaseVersion": "0.4.1", + "portalVersion": "0.2.1", "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.1", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip", + "sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467", "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.1", + "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip", + "sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb", "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.1", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip", + "sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665", "target": "onenote" } ] diff --git a/release/toolbox.lock.json b/release/toolbox.lock.json index bcd59e6..fc2bfb7 100644 --- a/release/toolbox.lock.json +++ b/release/toolbox.lock.json @@ -1,8 +1,8 @@ { "$schema": "./toolbox-release-lock.schema.json", "schemaVersion": 1, - "releaseVersion": "0.4.0", - "portalVersion": "0.2.0", + "releaseVersion": "0.4.1", + "portalVersion": "0.2.1", "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.1", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.1/pdf-tools-0.4.1.zip", + "sha256": "a7313e1fc709ec1fd3c09d46f7de84e07c299bd98f36fcba839ca0d6c8afc467", "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.1", + "artifact": "https://git.add-ideas.de/zemion/xslt-tools/releases/download/v0.4.1/xslt-tools-0.4.1.zip", + "sha256": "0f24480b8a3f007d8e330592e598babf5982306e1e9566325c9ca60441deb2fb", "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.1", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.1/onenote-tools-0.3.1.zip", + "sha256": "52d0443698ae875438f01bb6b7735722d511bc99d4b9ca5060727ebb41276665", "target": "onenote" } ] diff --git a/scripts/assembler.test.ts b/scripts/assembler.test.ts index 26a4dd7..258c083 100644 --- a/scripts/assembler.test.ts +++ b/scripts/assembler.test.ts @@ -83,7 +83,7 @@ function makeLock(artifact: string, sha256: string) { return { schemaVersion: 1, releaseVersion: '0.1.0', - portalVersion: '0.2.0', + portalVersion: '0.2.1', catalogue: { id: 'de.add-ideas.toolbox', name: 'Test Toolbox', diff --git a/src/App.test.tsx b/src/App.test.tsx index d61213d..d793745 100644 --- a/src/App.test.tsx +++ b/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.1') ) ).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,74 @@ 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(); + 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('clears search filters without revealing hidden tools', async () => { + vi.stubGlobal('fetch', vi.fn(catalogueFetch())); + const user = userEvent.setup(); + render(); + 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 +154,7 @@ describe('portal UI', () => { const user = userEvent.setup(); render(); 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 +166,70 @@ 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(); + await screen.findByRole('heading', { name: 'PDF Workbench' }); + + const header = document.querySelector('.portal-header')!; + expect( + within(header).getByRole('heading', { level: 1, name: 'Toolbox' }) + ).toBeInTheDocument(); + expect( + within(header).getByText('Local-first browser tools') + ).toBeInTheDocument(); + expect( + screen.getByRole('heading', { + level: 2, + name: 'Your document tools, in one calm place.', + }) + ).toBeInTheDocument(); + expect( + screen.getAllByRole('heading', { + name: 'Your document tools, in one calm place.', + }) + ).toHaveLength(1); + + const personalize = within(header).getByRole('button', { + name: 'Personalize', + }); + const apps = within(header).getByRole('button', { name: 'Apps' }); + const source = within(header).getByRole('link', { + name: 'Toolbox source on Gitea', + }); + const help = within(header).getByRole('button', { name: 'Help' }); + const brand = within(header).getByRole('link', { + name: 'add·ideas Toolbox', + }); + expect( + personalize.compareDocumentPosition(apps) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + apps.compareDocumentPosition(source) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + source.compareDocumentPosition(help) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect(brand.querySelector('img')).toHaveAttribute('src', './favicon.svg'); + }); + it('persists an explicit appearance and accepts cross-tab updates', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); 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 +329,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(); + const { container } = render(); 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 +372,35 @@ 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(); 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).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(); }); }); diff --git a/src/App.tsx b/src/App.tsx index 9945732..9759175 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -116,13 +116,14 @@ 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 === preferences.hidden; const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 8 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }) @@ -231,33 +232,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 ( <>
Skip to tools setHelpOpen(true) }} sourceHref={PORTAL_SOURCE} sourceLabel="Toolbox source on Gitea" - onPersonalize={() => setSettingsOpen(true)} + personalizeContent={ + + } apps={headerApps} allAppsHref="#main-content" /> @@ -270,8 +275,7 @@ export default function App() {

Your document tools, -
- in one calm place. +
in one calm place.

Focused browser utilities with a privacy disclosure for each @@ -315,16 +319,25 @@ export default function App() { Clear tag filter )} - {preferences.hidden.length > 0 && ( - - )} + )} @@ -456,9 +469,9 @@ export default function App() {

- Portal v0.2.0 ·{' '} + Portal v0.2.1 ·{' '} @@ -468,13 +481,6 @@ export default function App() {
- ); diff --git a/src/components/PreferencesPanel.tsx b/src/components/PreferencesPanel.tsx index 830b2cb..a8ce36a 100644 --- a/src/components/PreferencesPanel.tsx +++ b/src/components/PreferencesPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type ChangeEvent } from 'react'; +import { useRef, useState, type ChangeEvent } from 'react'; import { defaultPreferences, parsePreferences, @@ -7,83 +7,19 @@ import { 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(null); - const panel = useRef(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(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 }); } @@ -119,119 +55,90 @@ export function PreferencesPanel({ } return ( -
event.target === event.currentTarget && onClose()} +
-
-
-
-

On this device

-

Personalize your toolbox

-
- -
+
+

On this device

+

Personalize your toolbox

+
- {!storageAvailable && ( -

- Browser storage is unavailable. Changes will last only until this - page closes. -

- )} - -
- Appearance -
- {(['system', 'light', 'dark'] as const).map((theme) => ( - - ))} -
-
- -
-
- {preferences.pinned.length} - Pinned -
-
- {preferences.hidden.length} - Hidden -
-
- {preferences.order.length} - Ordered -
-
- -

- 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. + {!storageAvailable && ( +

+ Browser storage is unavailable. Changes will last only until this page + closes.

+ )} -
- - - - +
+ Appearance +
+ {(['system', 'light', 'dark'] as const).map((theme) => ( + + ))}
-

- {message} -

-
-
+ + +
+
+ {preferences.pinned.length} + Pinned +
+
+ {preferences.hidden.length} + Hidden +
+
+ {preferences.order.length} + Ordered +
+
+ +

+ 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. +

+ +
+ + + + +
+

+ {message} +

+ ); } - -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(','); diff --git a/src/styles.css b/src/styles.css index 717cb19..278f378 100644 --- a/src/styles.css +++ b/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,18 +680,48 @@ 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; } +.portal-header .toolbox-shell__personalize-popover { + width: min(26rem, calc(100vw - 1.5rem)); + max-height: calc(100vh - 5.5rem); + overflow: auto; +} + +.preferences-content { + padding: 0.35rem; + color: var(--toolbox-text, var(--text)); +} + +.preferences-heading { + margin-bottom: 0.9rem; +} + +.preferences-content h2 { + margin: 0; + font-family: Inter, Avenir, 'Segoe UI', sans-serif; + font-size: 1rem; + letter-spacing: -0.025em; +} + +.portal-header .preferences-kicker { + margin: 0 0 0.3rem; + padding: 0; + color: var(--toolbox-accent, var(--brand)); + font-size: 0.68rem; + font-weight: 750; + letter-spacing: 0.1em; + text-transform: uppercase; +} + .help-steps { display: grid; gap: 0.85rem; @@ -721,7 +751,7 @@ main { } .theme-choice { - margin: 1.5rem 0; + margin: 1rem 0; padding: 0; border: 0; } @@ -767,7 +797,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 +805,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 +815,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 +830,7 @@ main { } .preferences-actions button { - padding: 0.62rem 0.78rem; + padding: 0.52rem 0.68rem; } .preferences-actions .danger-button { @@ -805,12 +838,16 @@ main { color: var(--danger); } -.action-message { +.portal-header .action-message { min-height: 1.2em; margin: 0.8rem 0 0; + padding: 0; + color: var(--toolbox-muted, var(--muted)); + font-size: 0.78rem; } -.inline-warning { +.portal-header .inline-warning { + margin: 0 0 0.75rem; padding: 0.7rem 0.8rem; border-radius: 0.6rem; background: color-mix(in srgb, var(--danger) 8%, var(--surface)); @@ -830,7 +867,6 @@ main { } @media (max-width: 700px) { - .portal-header .toolbox-shell__bar, main, .site-footer { width: min(100% - 1.4rem, 1160px); @@ -870,6 +906,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); diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index 59367bd..1749151 100644 --- a/src/test/fixtures.ts +++ b/src/test/fixtures.ts @@ -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.1', 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.1', 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.1', description: 'Inspect OneNote packages locally in the browser.', icon: './onenote.svg', categories: ['documents', 'notes'],