diff --git a/.env.example b/.env.example index 7d3cb97..413c0ec 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.2 -TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389 +TOOLBOX_RELEASE_VERSION=0.4.3 +TOOLBOX_RELEASE_SHA256=0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522 diff --git a/Containerfile.release b/Containerfile.release index 7c1a1c3..a8e7a0f 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.2 -ARG TOOLBOX_RELEASE_SHA256=20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389 +ARG TOOLBOX_RELEASE_VERSION=0.4.3 +ARG TOOLBOX_RELEASE_SHA256=0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522 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 97d57f3..8f9eefd 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # add·ideas Toolbox Portal `toolbox-portal` is the static launcher and release assembler for the add·ideas -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, +browser toolbox. Version `0.2.3` reads one same-origin catalogue, shows each app +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 section; search, category, and clickable tag filters stay close to the tool list. @@ -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.2 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 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.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/toolbox.lock.json` is the reviewed v0.4.3 lock. Its URLs and checksums +pin the published PDF Tools 0.4.3, XSLT Tools 0.4.2, and OneNote Tools 0.3.3 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.2.zip + --archive build/add-ideas-toolbox-0.4.3.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.2.zip -build/add-ideas-toolbox-0.4.2.zip.sha256 +build/add-ideas-toolbox-0.4.3.zip +build/add-ideas-toolbox-0.4.3.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.2.zip + --output artifacts/add-ideas-toolbox-0.4.3.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.2 . + -t git.add-ideas.de/zemion/toolbox:0.4.3 . podman run --rm -p 8080:8080 \ - git.add-ideas.de/zemion/toolbox:0.4.2 + git.add-ideas.de/zemion/toolbox:0.4.3 ``` 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.2 \ + --tag git.add-ideas.de/zemion/toolbox:0.4.3 \ --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.2.zip.sha256)`. -6. Commit the reviewed lock, tag the toolbox release (for example `v0.4.2`), and + `(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.3`), 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 95d140a..e8f492d 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.2 + image: git.add-ideas.de/zemion/toolbox:0.4.3 restart: unless-stopped read_only: true ports: diff --git a/compose.yaml b/compose.yaml index b5220f6..bff4049 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.2}" - TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-20c1eacbcf03a9c8f0beeb53d2326a963e54dd9ca93e4f0118f95f10aec45389}" - image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.2}" + TOOLBOX_RELEASE_VERSION: "${TOOLBOX_RELEASE_VERSION:-0.4.3}" + TOOLBOX_RELEASE_SHA256: "${TOOLBOX_RELEASE_SHA256:-0c26315c987f7ef6d025c66c21e25999786ede3e971b66495c856117e0b7c522}" + image: "git.add-ideas.de/zemion/toolbox:${TOOLBOX_RELEASE_VERSION:-0.4.3}" restart: unless-stopped read_only: true tmpfs: diff --git a/package-lock.json b/package-lock.json index 323f611..635e1ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.2.2", + "version": "0.2.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-portal", - "version": "0.2.2", + "version": "0.2.3", "license": "AGPL-3.0-only", "dependencies": { "@add-ideas/toolbox-contract": "^0.2.2", diff --git a/package.json b/package.json index 340bfb7..48fbf40 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-portal", - "version": "0.2.2", + "version": "0.2.3", "license": "AGPL-3.0-only", "private": true, "type": "module", diff --git a/public/SOURCE.md b/public/SOURCE.md index 0be31ae..f1dc1e1 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.2 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.2 +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 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 f136486..b8dc7da 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.2", - "portalVersion": "0.2.2", + "releaseVersion": "0.4.3", + "portalVersion": "0.2.3", "catalogue": { "id": "de.add-ideas.toolbox", "name": "add·ideas Toolbox", @@ -15,9 +15,9 @@ "apps": [ { "id": "de.add-ideas.pdf-tools", - "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", + "version": "0.4.3", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip", + "sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf", "target": "pdf" }, { @@ -29,9 +29,9 @@ }, { "id": "de.add-ideas.onenote-tools", - "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", + "version": "0.3.3", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip", + "sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec", "target": "onenote" } ] diff --git a/release/toolbox.lock.json b/release/toolbox.lock.json index f136486..b8dc7da 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.2", - "portalVersion": "0.2.2", + "releaseVersion": "0.4.3", + "portalVersion": "0.2.3", "catalogue": { "id": "de.add-ideas.toolbox", "name": "add·ideas Toolbox", @@ -15,9 +15,9 @@ "apps": [ { "id": "de.add-ideas.pdf-tools", - "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", + "version": "0.4.3", + "artifact": "https://git.add-ideas.de/zemion/pdf-tools/releases/download/v0.4.3/pdf-tools-0.4.3.zip", + "sha256": "f2dfd3bade49a4b8be9c0c54c6c2bf1bbb153fc0505a38af14de5ab52d5183cf", "target": "pdf" }, { @@ -29,9 +29,9 @@ }, { "id": "de.add-ideas.onenote-tools", - "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", + "version": "0.3.3", + "artifact": "https://git.add-ideas.de/zemion/onenote-tools/releases/download/v0.3.3/onenote-tools-0.3.3.zip", + "sha256": "37c0ef7cdc7f435d0d4f5a341523f4e522118d5b258c374b5a7ef7b8e6c27cec", "target": "onenote" } ] diff --git a/scripts/assembler.test.ts b/scripts/assembler.test.ts index 1e7d7e8..9a63c2b 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.2', + portalVersion: '0.2.3', catalogue: { id: 'de.add-ideas.toolbox', name: 'Test Toolbox', diff --git a/src/App.test.tsx b/src/App.test.tsx index d0c689d..1ce9b01 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -13,35 +13,48 @@ import { catalogue, catalogueFetch } from './test/fixtures'; 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', () => { - 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())); const user = userEvent.setup(); render(); + 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( - await screen.findByRole('heading', { name: 'PDF Workbench' }) - ).toBeInTheDocument(); + screen.getByTestId('app-card-de.add-ideas.xslt-tools') + ).toHaveTextContent('XSLT'); 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' ) - ).toHaveLength(3); - const sourceLinks = screen.getAllByRole('link', { - name: 'Source and license', - }); - expect(sourceLinks).toHaveLength(4); + ).not.toBeInTheDocument(); expect( - sourceLinks.find( - (link) => - 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.2') - ) - ).toBeDefined(); + screen.getAllByRole('link', { name: 'Source and license' }) + ).toHaveLength(1); expect( screen.queryByRole('region', { name: 'Pinned' }) ).not.toBeInTheDocument(); @@ -50,12 +63,20 @@ describe('portal UI', () => { }); expect(showHidden).toBeDisabled(); expect(showHidden).not.toBeChecked(); - const launch = screen - .getAllByRole('link', { name: 'PDF Workbench' }) - .find((link) => link.classList.contains('app-card__launch-link'))!; + expect(launch).toHaveClass('app-card__launch-link'); expect( new URL(launch.getAttribute('href')!).searchParams.get('toolbox') ).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 waitFor(() => @@ -69,6 +90,12 @@ describe('portal UI', () => { expect( screen.queryByRole('button', { name: /Move PDF Workbench/i }) ).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( screen.getByRole('button', { name: 'Reorder PDF Workbench' }) ).toBeInTheDocument(); @@ -76,27 +103,92 @@ describe('portal UI', () => { await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ); - expect( - screen.queryByRole('heading', { name: 'PDF Workbench' }) - ).not.toBeInTheDocument(); + expect(queryToolLink('PDF Workbench')).not.toBeInTheDocument(); expect(showHidden).toBeEnabled(); expect(showHidden).toHaveAccessibleName('Show hidden (1)'); + await waitFor(() => expect(showHidden).toHaveFocus()); fireEvent.click(showHidden); - expect( - screen.getByRole('heading', { name: 'PDF Workbench' }) - ).toBeInTheDocument(); + expect(toolLink('PDF Workbench')).toBeInTheDocument(); await user.click( screen.getByRole('button', { name: 'Show PDF Workbench' }) ); expect(showHidden).toBeDisabled(); 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(); + 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 () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); - await screen.findByRole('heading', { name: 'PDF Workbench' }); + await findToolLink(); const search = screen.getByRole('searchbox', { name: 'Search tools' }); const category = screen.getByRole('combobox', { @@ -107,28 +199,37 @@ describe('portal UI', () => { ); await user.type(search, 'xslt'); - expect( - screen.getByRole('heading', { name: 'XSLT Workbench' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('heading', { name: 'PDF Workbench' }) - ).not.toBeInTheDocument(); + expect(toolLink('XSLT Workbench')).toBeInTheDocument(); + expect(queryToolLink('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(); + expect(toolLink('OneNote Reader')).toBeInTheDocument(); + expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument(); + + await user.selectOptions(category, ''); + await user.click( + 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 () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); - await screen.findByRole('heading', { name: 'PDF Workbench' }); + await findToolLink(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) @@ -147,16 +248,14 @@ describe('portal UI', () => { expect(showHidden).toBeChecked(); expect(showHidden).toHaveAccessibleName('Show hidden (1)'); - expect( - screen.getByRole('heading', { name: 'XSLT Workbench' }) - ).toBeInTheDocument(); + expect(toolLink('XSLT Workbench')).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 findToolLink(); await user.click( screen.getByRole('button', { name: 'Hide PDF Workbench' }) ); @@ -166,12 +265,8 @@ describe('portal UI', () => { ); 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(queryToolLink('PDF Workbench')).not.toBeInTheDocument(); + expect(toolLink('XSLT Workbench')).toBeInTheDocument(); expect( screen.getByRole('checkbox', { name: 'Show hidden (1)' }) ).not.toBeChecked(); @@ -181,7 +276,7 @@ describe('portal UI', () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); - await screen.findByRole('heading', { name: 'PDF Workbench' }); + await findToolLink(); await user.click(screen.getByRole('button', { name: 'Apps' })); const navigation = screen.getByRole('navigation', { name: 'Toolbox applications', @@ -197,7 +292,7 @@ describe('portal UI', () => { 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' }); + await findToolLink(); const header = document.querySelector('.portal-header')!; expect( @@ -263,7 +358,7 @@ describe('portal UI', () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); - await screen.findByRole('heading', { name: 'PDF Workbench' }); + await findToolLink(); const personalize = screen.getByRole('button', { name: 'Personalize' }); await user.click(personalize); expect( @@ -299,7 +394,7 @@ describe('portal UI', () => { it('supports keyboard drag ordering from each tile grip', async () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); render(); - await screen.findByRole('heading', { name: 'PDF Workbench' }); + await findToolLink(); const cards = [ screen.getByTestId('app-card-de.add-ideas.pdf-tools'), screen.getByTestId('app-card-de.add-ideas.xslt-tools'), @@ -309,11 +404,11 @@ describe('portal UI', () => { const rect = { x: index * 220, y: 0, - width: 200, - height: 320, + width: 188, + height: 188, top: 0, - right: index * 220 + 200, - bottom: 320, + right: index * 220 + 188, + bottom: 188, left: index * 220, toJSON: () => ({}), } as DOMRect; @@ -334,7 +429,7 @@ describe('portal UI', () => { within(tools) .getAllByRole('heading', { level: 3 }) .map((heading) => heading.textContent) - ).toEqual(['XSLT Workbench', 'PDF Workbench', 'OneNote Reader']); + ).toEqual(['XSLT', 'PDF', 'OneNote']); }); expect( JSON.parse(localStorage.getItem(PREFERENCES_KEY) ?? '{}').order @@ -349,41 +444,53 @@ describe('portal UI', () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); render(); - const merge = await screen.findByRole('button', { name: '#merge' }); - await user.click(merge); - expect(merge).toHaveAttribute('aria-pressed', 'true'); - expect( - screen.getByRole('heading', { name: 'PDF Workbench' }) - ).toBeInTheDocument(); - expect( - screen.queryByRole('heading', { name: 'XSLT Workbench' }) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole('heading', { name: 'OneNote Reader' }) - ).not.toBeInTheDocument(); + await findToolLink(); + await user.click( + screen.getByRole('button', { + name: 'More information about PDF Workbench', + }) + ); + const dialog = screen.getByRole('dialog', { name: 'PDF Workbench' }); + await user.click(within(dialog).getByRole('button', { name: '#merge' })); - await user.click(screen.getByRole('button', { name: '#merge' })); - expect( - await screen.findByRole('heading', { name: 'XSLT Workbench' }) - ).toBeInTheDocument(); - expect( - screen.getByRole('heading', { name: 'OneNote Reader' }) - ).toBeInTheDocument(); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + expect(toolLink('PDF Workbench')).toBeInTheDocument(); + expect(queryToolLink('XSLT Workbench')).not.toBeInTheDocument(); + expect(queryToolLink('OneNote Reader')).not.toBeInTheDocument(); + + const activeTag = screen.getByRole('button', { + 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 () => { vi.stubGlobal('fetch', vi.fn(catalogueFetch())); const user = userEvent.setup(); 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'); + await findToolLink(); + const help = screen.getByRole('button', { name: 'Help' }); + await user.click(help); + const dialog = screen.getByRole('dialog', { + 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'); - 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(container.querySelector('.site-shell')).not.toHaveAttribute('inert'); + expect(help).toHaveFocus(); }); it('shows a recoverable catalogue error', async () => { @@ -397,9 +504,7 @@ describe('portal UI', () => { await screen.findByRole('heading', { name: 'Toolbox unavailable' }) ).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: 'Try again' })); - expect( - await screen.findByRole('heading', { name: 'PDF Workbench' }) - ).toBeInTheDocument(); + expect(await findToolLink()).toBeInTheDocument(); }); it('shows a dedicated empty state for a valid catalogue', async () => { diff --git a/src/App.tsx b/src/App.tsx index d2342e5..c24226b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,8 +16,9 @@ import { ToolboxHeader, type ToolboxHeaderApp, } 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 { AppDetailsPanel } from './components/AppDetailsPanel'; import { HelpPanel } from './components/HelpPanel'; import { Logo } from './components/Logo'; import { PreferencesPanel } from './components/PreferencesPanel'; @@ -34,6 +35,9 @@ type LoadState = | { status: 'error'; message: string } | { status: 'ready'; value: LoadedCatalogue }; +type PortalOverlay = + { kind: 'help' } | { kind: 'details'; appId: string } | null; + function appMatches( app: ResolvedApp, query: string, @@ -50,29 +54,25 @@ function appMatches( } interface AppSectionProps { - activeTag: string; apps: ResolvedApp[]; catalogueUrl: string; hiddenIds: readonly string[]; id: string; - onCategory: (category: string) => void; + onDetails: (id: string) => void; onHide: (id: string) => void; onPin: (id: string) => void; - onTag: (tag: string) => void; pinned: boolean; title: string; } function AppSection({ - activeTag, apps, catalogueUrl, hiddenIds, id, - onCategory, + onDetails, onHide, onPin, - onTag, pinned, title, }: AppSectionProps) { @@ -97,11 +97,9 @@ function AppSection({ contextualUrl={contextualLaunchUrl} pinned={pinned} hidden={hiddenIds.includes(app.id)} - activeTag={activeTag} - onCategory={onCategory} + onDetails={() => onDetails(app.id)} onPin={() => onPin(app.id)} onHide={() => onHide(app.id)} - onTag={onTag} /> ))} @@ -119,9 +117,10 @@ export default function App() { const [shownHiddenIds, setShownHiddenIds] = useState< readonly string[] | null >(null); - const [helpOpen, setHelpOpen] = useState(false); + const [overlay, setOverlay] = useState(null); + const hiddenToggle = useRef(null); const [preferences, setPreferences, storageAvailable] = usePreferences(); - const closeHelp = useCallback(() => setHelpOpen(false), []); + const closeOverlay = useCallback(() => setOverlay(null), []); const showHidden = preferences.hidden.length > 0 && 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 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(() => { if (!loaded) return; @@ -206,6 +210,7 @@ export default function App() { })); function toggleListItem(field: 'pinned' | 'hidden', id: string) { + const keepCardVisible = field === 'pinned' || showHidden; if (field === 'hidden' && showHidden && !preferences.hidden.includes(id)) { setShownHiddenIds((current) => [...new Set([...(current ?? []), id])]); } @@ -215,6 +220,20 @@ export default function App() { ? current[field].filter((item) => item !== id) : [...current[field], id], })); + window.requestAnimationFrame(() => { + if (!keepCardVisible) { + hiddenToggle.current?.focus(); + return; + } + const action = field === 'pinned' ? 'pin' : 'visibility'; + [ + ...document.querySelectorAll( + `[data-card-action="${action}"]` + ), + ] + .find((button) => button.dataset.appId === id) + ?.focus(); + }); } function handleDragEnd(event: DragEndEvent) { @@ -243,8 +262,8 @@ export default function App() { <>
Skip to tools @@ -257,7 +276,7 @@ export default function App() { homeHref={loaded?.homeUrl ?? './'} homeLabel="add·ideas Toolbox" brandIconUrl="./favicon.svg" - helpAction={{ onClick: () => setHelpOpen(true) }} + helpAction={{ onClick: () => setOverlay({ kind: 'help' }) }} sourceHref={PORTAL_SOURCE} sourceLabel="Toolbox source on Gitea" personalizeContent={ @@ -331,6 +350,7 @@ export default function App() { }`} > + setOverlay({ kind: 'details', appId: id }) + } onPin={(id) => toggleListItem('pinned', id)} onHide={(id) => toggleListItem('hidden', id)} - onTag={(tag) => - setActiveTag((current) => (current === tag ? '' : tag)) - } /> + setOverlay({ kind: 'details', appId: id }) + } onPin={(id) => toggleListItem('pinned', id)} onHide={(id) => toggleListItem('hidden', id)} - onTag={(tag) => - setActiveTag((current) => (current === tag ? '' : tag)) - } />
@@ -473,9 +489,9 @@ export default function App() {

- Portal v0.2.2 ·{' '} + Portal v0.2.3 ·{' '}
@@ -485,7 +501,26 @@ export default function App() { - + + {detailsApp && loaded ? ( +
+ + +
-
+ + {shortToolTitle(app)} + + +

{shortToolDescription(app)}

+
+ +
+ - - -
+ + + +
-
-
-

- - {app.name} - -

- {pinned && Pinned} - {hidden && ( - Hidden - )} - {app.isExternal && ( - External - )} -
-

{app.description}

- - - {app.categories.length > 0 && ( -
- {app.categories.slice(0, 3).map((category) => ( - - ))} -
- )} - {app.tags.length > 0 && ( -
- {app.tags.map((tag) => ( - - ))} -
- )} -
-
-
- - {app.version ? `v${app.version}` : 'External service'} - - {app.sourceUrl && ( - - Source and license - - )} -
- - Open tool - -
); } diff --git a/src/components/AppDetailsPanel.tsx b/src/components/AppDetailsPanel.tsx new file mode 100644 index 0000000..d58804d --- /dev/null +++ b/src/components/AppDetailsPanel.tsx @@ -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

Needs {requirements.join(' · ')}

; +} + +function PrivacySummary({ app }: { app: ResolvedApp }) { + if (!app.privacy) + return ( +

+ External destination · review its privacy terms +

+ ); + if (app.privacy.label) + return

{app.privacy.label}

; + const processing = { + local: 'Local processing', + remote: 'Remote processing', + mixed: 'Local and remote capabilities', + }[app.privacy.processing]; + return ( +

+ {processing} ·{' '} + {app.privacy.fileUploads + ? 'file/data transmission declared' + : 'no file uploads declared'}{' '} + · {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'} +

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

Tool information

+
+

{app.name}

+ {pinned && Pinned} + {hidden && ( + Hidden + )} + {app.isExternal && ( + External + )} +
+
+
+ +
+ +
+

{app.description}

+ + + + {app.categories.length > 0 && ( +
+ {app.categories.slice(0, 3).map((category) => ( + + ))} +
+ )} + {app.tags.length > 0 && ( +
+ {app.tags.map((tag) => ( + + ))} +
+ )} +
+ + +
+ ); +} diff --git a/src/components/HelpPanel.tsx b/src/components/HelpPanel.tsx index f97d151..67cfe36 100644 --- a/src/components/HelpPanel.tsx +++ b/src/components/HelpPanel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react'; +import { ModalPanel } from './ModalPanel'; interface HelpPanelProps { open: boolean; @@ -6,104 +6,46 @@ interface HelpPanelProps { } export function HelpPanel({ open, onClose }: HelpPanelProps) { - const panel = useRef(null); - - useEffect(() => { - if (!open) return; - const returnFocus = - document.activeElement instanceof HTMLElement - ? document.activeElement - : null; - const dialog = panel.current; - const focusable = () => - dialog - ? [...dialog.querySelectorAll(FOCUSABLE_SELECTOR)] - : []; - focusable()[0]?.focus(); - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - event.preventDefault(); - onClose(); - return; - } - if (event.key !== 'Tab') return; - const elements = focusable(); - const first = elements[0]; - const last = elements.at(-1); - if (!first || !last) return; - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - }; - document.addEventListener('keydown', handleKeyDown); - return () => { - document.removeEventListener('keydown', handleKeyDown); - returnFocus?.focus(); - }; - }, [open, onClose]); - if (!open) return null; return ( -
event.target === event.currentTarget && onClose()} + -
-
-
-

Toolbox guide

-

Choose and arrange your tools

-
- -
-
-

- Open: click anywhere on a tool tile that is not a - control. -

-

- Filter: search, choose a category, or click a tag. - Click the selected tag again to clear it. -

-

- Arrange: 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. -

-

- Privacy: each tile reports its declared processing, - uploads, telemetry, and browser requirements before you open it. -

+
+
+

Toolbox guide

+

Choose and arrange your tools

-
-
+ + +
+

+ Open: click anywhere on a tool tile that is not a + control. +

+

+ Details: use the light bulb to review privacy, + requirements, categories, tags, version, and source before opening. +

+

+ Filter: search, choose a category, or select a tag + from a tool's information panel. Select the tag again to clear it. +

+

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

+
+ ); } - -const FOCUSABLE_SELECTOR = [ - 'a[href]', - 'button:not([disabled])', - 'input:not([disabled])', - 'select:not([disabled])', - '[tabindex]:not([tabindex="-1"])', -].join(','); diff --git a/src/components/ModalPanel.tsx b/src/components/ModalPanel.tsx new file mode 100644 index 0000000..6105cce --- /dev/null +++ b/src/components/ModalPanel.tsx @@ -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(null); + + useEffect(() => { + const returnFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const dialog = panel.current; + const focusable = () => + dialog + ? [...dialog.querySelectorAll(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 ( +
+ event.target === event.currentTarget && onClose() + } + > +
+ {children} +
+
+ ); +} + +const FOCUSABLE_SELECTOR = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', +].join(','); diff --git a/src/styles.css b/src/styles.css index fb0df20..268c14a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -125,8 +125,9 @@ summary:focus-visible { main, .site-footer { - width: min(1160px, calc(100% - 2.5rem)); + width: min(100%, 90rem); margin-inline: auto; + padding-inline: 1rem; } .state-card button, @@ -305,23 +306,31 @@ main { .app-grid { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 1.1rem; + grid-template-columns: repeat( + auto-fill, + minmax(min(10.5rem, 100%), 11.75rem) + ); + align-items: start; + justify-content: start; + gap: 0.9rem; } .app-card { position: relative; - min-height: 340px; + aspect-ratio: 1; + min-width: 0; display: flex; flex-direction: column; + align-items: center; + justify-content: space-between; overflow: hidden; + padding: 1rem 0.85rem 0.75rem; border: 1px solid var(--line); - border-radius: 1.2rem; + border-radius: 1rem; background: var(--surface); box-shadow: var(--shadow); transition: border-color 180ms ease, - transform 180ms ease, box-shadow 180ms ease; } @@ -333,33 +342,35 @@ main { .app-card:hover { 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); } .app-card--hidden { border-style: dashed; - opacity: 0.7; + background: color-mix(in srgb, var(--surface-soft) 55%, var(--surface)); } -.app-card__top { - display: flex; - align-items: flex-start; - justify-content: space-between; - padding: 1.35rem 1.35rem 0; +.app-card__face { + min-height: 0; + display: grid; + flex: 1; + align-content: center; + justify-items: center; + padding: 0.2rem 0.25rem 0; + text-align: center; } .app-card__icon { - width: 52px; - height: 52px; + width: 3.6rem; + height: 3.6rem; display: grid; place-items: center; overflow: hidden; - border-radius: 0.95rem; + border-radius: 0.9rem; background: var(--brand-soft); color: var(--brand); font-family: Inter, Avenir, 'Segoe UI', sans-serif; - font-size: 1.45rem; + font-size: 1.35rem; font-weight: 700; } @@ -369,51 +380,130 @@ main { object-fit: contain; } -.app-card__actions { - position: relative; - z-index: 2; - display: flex; - gap: 0.15rem; +.app-card__face h3 { + margin: 0.7rem 0 0; + font-family: Inter, Avenir, 'Segoe UI', sans-serif; + font-size: 1.05rem; + 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 { - 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; + 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 { 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, .close-button { - width: 32px; - height: 32px; + width: 2.15rem; + height: 2.15rem; display: grid; place-items: center; - border: 0; + padding: 0; + border: 1px solid var(--line); border-radius: 50%; - background: transparent; + background: var(--surface-soft); color: var(--muted); cursor: pointer; } +.app-card__actions .icon-button { + pointer-events: auto; +} + .icon-button:hover:not(:disabled), .icon-button.is-active { + border-color: color-mix(in srgb, var(--brand) 30%, var(--line)); background: var(--brand-soft); 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 { cursor: not-allowed; opacity: 0.28; } -.app-card__body { - flex: 1; - padding: 1.25rem 1.35rem 1rem; -} - .title-line { display: flex; flex-wrap: wrap; @@ -421,10 +511,10 @@ main { gap: 0.45rem; } -.title-line h3 { +.title-line h2 { margin: 0; font-family: Inter, Avenir, 'Segoe UI', sans-serif; - font-size: 1.3rem; + font-size: 1.35rem; letter-spacing: -0.025em; } @@ -472,22 +562,6 @@ main { 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, .category-row { display: flex; @@ -521,26 +595,11 @@ main { 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 { color: var(--muted); font-size: 0.78rem; } -.app-card__meta { - display: grid; - gap: 0.25rem; -} - .source-link, .site-footer a { position: relative; @@ -548,12 +607,6 @@ main { color: var(--brand); } -.open-hint { - color: var(--brand); - font-size: 0.84rem; - font-weight: 700; -} - .state-card { max-width: 600px; margin: 0 auto 5rem; @@ -657,14 +710,16 @@ main { inset: 0; display: grid; place-items: center; + overflow-y: auto; padding: 1rem; background: rgba(5, 9, 22, 0.56); backdrop-filter: blur(5px); } -.help-panel { - width: min(560px, 100%); +.help-panel, +.app-info-panel { max-height: calc(100vh - 2rem); + max-height: calc(100dvh - 2rem); overflow: auto; padding: 1.5rem; border: 1px solid var(--line); @@ -673,6 +728,15 @@ main { 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 { display: flex; align-items: flex-start; @@ -686,6 +750,105 @@ main { 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 { margin-bottom: 0.5rem; } @@ -790,7 +953,8 @@ main { @media (max-width: 700px) { main, .site-footer { - width: min(100% - 1.4rem, 1160px); + width: min(100%, 90rem); + padding-inline: 0.7rem; } .hero { @@ -811,14 +975,6 @@ main { margin: 0.25rem 0; } - .app-grid { - grid-template-columns: 1fr; - } - - .app-card { - min-height: 320px; - } - .site-footer { min-height: 130px; align-items: flex-start; @@ -842,15 +998,35 @@ main { .hidden-toggle { 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) { .hero { grid-template-columns: minmax(0, 1fr) minmax(17rem, 21rem); } +} - .app-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); +@media (hover: none) { + .drag-handle { + opacity: 1; + pointer-events: auto; + transform: translate(0, -50%); } } diff --git a/src/test/fixtures.ts b/src/test/fixtures.ts index b5dba95..af1546c 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.2', + version: '0.4.3', description: 'Page-level PDF operations performed locally in the browser.', entry: './', icon: './favicon.svg', @@ -54,7 +54,7 @@ export const onenoteManifest: ToolboxAppManifest = { ...pdfManifest, id: 'de.add-ideas.onenote-tools', name: 'OneNote Reader', - version: '0.3.2', + version: '0.3.3', description: 'Inspect OneNote packages locally in the browser.', icon: './onenote.svg', categories: ['documents', 'notes'], diff --git a/src/toolPresentation.ts b/src/toolPresentation.ts new file mode 100644 index 0000000..e3bb94c --- /dev/null +++ b/src/toolPresentation.ts @@ -0,0 +1,27 @@ +import type { ResolvedApp } from './types'; + +const PRESENTATION: Record = { + '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; +}