From 2c13c563621b9f214aeaee75a90aa03bf665ed42 Mon Sep 17 00:00:00 2001
From: Albrecht Degering
Date: Thu, 23 Jul 2026 13:02:29 +0200
Subject: [PATCH] feat: add compact Toolbox tiles and details overlay
---
.env.example | 4 +-
Containerfile.release | 4 +-
README.md | 28 +--
compose.example.yaml | 2 +-
compose.yaml | 6 +-
package-lock.json | 4 +-
package.json | 2 +-
public/SOURCE.md | 4 +-
release/toolbox.lock.example.json | 16 +-
release/toolbox.lock.json | 16 +-
scripts/assembler.test.ts | 2 +-
src/App.test.tsx | 285 ++++++++++++++++--------
src/App.tsx | 91 +++++---
src/components/AppCard.tsx | 248 +++++++++------------
src/components/AppDetailsPanel.tsx | 172 +++++++++++++++
src/components/HelpPanel.tsx | 134 ++++-------
src/components/ModalPanel.tsx | 102 +++++++++
src/styles.css | 344 ++++++++++++++++++++++-------
src/test/fixtures.ts | 4 +-
src/toolPresentation.ts | 27 +++
20 files changed, 1009 insertions(+), 486 deletions(-)
create mode 100644 src/components/AppDetailsPanel.tsx
create mode 100644 src/components/ModalPanel.tsx
create mode 100644 src/toolPresentation.ts
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 ? (
+ {
+ setCategory(nextCategory);
+ closeOverlay();
+ }}
+ onTag={(tag) => {
+ setActiveTag((current) => (current === tag ? '' : tag));
+ closeOverlay();
+ }}
+ onClose={closeOverlay}
+ />
+ ) : null}
>
);
}
diff --git a/src/components/AppCard.tsx b/src/components/AppCard.tsx
index 2d1a391..ca3f6cc 100644
--- a/src/components/AppCard.tsx
+++ b/src/components/AppCard.tsx
@@ -1,5 +1,7 @@
+import { useId } from 'react';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
+import { shortToolDescription, shortToolTitle } from '../toolPresentation';
import type { ResolvedApp } from '../types';
interface AppCardProps {
@@ -7,48 +9,50 @@ interface AppCardProps {
catalogueUrl: string;
hidden: boolean;
pinned: boolean;
- activeTag: string;
- onCategory: (category: string) => void;
+ onDetails: () => void;
onHide: () => void;
onPin: () => void;
- onTag: (tag: string) => void;
contextualUrl: (entryUrl: string, catalogueUrl: string) => string;
}
-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 MoveIcon() {
+ return (
+
+
+
+
+
+
+
+
+ );
}
-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];
+function LightBulbIcon() {
return (
-
- {processing} ·{' '}
- {app.privacy.fileUploads
- ? 'file/data transmission declared'
- : 'no file uploads declared'}{' '}
- · {app.privacy.telemetry ? 'telemetry declared' : 'no telemetry declared'}
-
+
+
+
+
+ );
+}
+
+function PinIcon() {
+ return (
+
+
+
+
+ );
+}
+
+function VisibilityIcon({ hidden }: { hidden: boolean }) {
+ return (
+
+
+
+ {!hidden && }
+
);
}
@@ -57,13 +61,12 @@ export function AppCard({
catalogueUrl,
hidden,
pinned,
- activeTag,
- onCategory,
+ onDetails,
onHide,
onPin,
- onTag,
contextualUrl,
}: AppCardProps) {
+ const titleId = useId();
const {
attributes,
isDragging,
@@ -84,10 +87,23 @@ export function AppCard({
className={`app-card${hidden ? ' app-card--hidden' : ''}${
isDragging ? ' app-card--dragging' : ''
}`}
+ aria-labelledby={titleId}
data-testid={`app-card-${app.id}`}
style={{ transform: CSS.Transform.toString(transform), transition }}
>
-
+
+
+
+
+
{app.iconUrl ? (
@@ -95,112 +111,60 @@ export function AppCard({
{app.name.slice(0, 1).toLocaleUpperCase()}
)}
-
+
+
+
-
- ⠿
-
-
- ⌁
-
-
- {hidden ? '○' : '—'}
-
-
+
+
+
+
+
+
+
+
-
-
-
- {pinned &&
Pinned }
- {hidden && (
-
Hidden
- )}
- {app.isExternal && (
-
External
- )}
-
-
{app.description}
-
-
- {app.categories.length > 0 && (
-
- {app.categories.slice(0, 3).map((category) => (
- onCategory(category)}
- >
- {category}
-
- ))}
-
- )}
- {app.tags.length > 0 && (
-
- {app.tags.map((tag) => (
- onTag(tag)}
- >
- #{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 (
+
+
+
+
+ {app.iconUrl ? (
+
+ ) : (
+
{app.name.slice(0, 1).toLocaleUpperCase()}
+ )}
+
+
+
Tool information
+
+
{app.name}
+ {pinned && Pinned }
+ {hidden && (
+ Hidden
+ )}
+ {app.isExternal && (
+ External
+ )}
+
+
+
+
+ ×
+
+
+
+
+
{app.description}
+
+
+
+ {app.categories.length > 0 && (
+
+ {app.categories.slice(0, 3).map((category) => (
+ onCategory(category)}
+ >
+ {category}
+
+ ))}
+
+ )}
+ {app.tags.length > 0 && (
+
+ {app.tags.map((tag) => (
+ onTag(tag)}
+ >
+ #{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()
+ }
+ >
+
+
+ );
+}
+
+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;
+}