Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc91659a42 | ||
|
|
ef2dab4b46 | ||
|
|
53c40a61ba | ||
|
|
c296281f89 | ||
|
|
a058858b50 |
@@ -0,0 +1,35 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
@@ -1 +1 @@
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/zemion/npm/
|
||||
@add-ideas:registry=https://git.add-ideas.de/api/packages/lotobo/npm/
|
||||
|
||||
@@ -3,6 +3,87 @@
|
||||
All notable changes to the independently versioned Toolbox SDK packages are
|
||||
recorded here. The packages currently share one release version.
|
||||
|
||||
## 0.3.0 — 2026-09-02
|
||||
|
||||
### Added
|
||||
|
||||
- Runtime-validated manifest I/O and progressive browser-capability profiles.
|
||||
- Bounded, same-origin, target-routed, expiring, one-time artifact handoffs
|
||||
through IndexedDB, with versioned operation evidence and opaque tokens.
|
||||
|
||||
### Security
|
||||
|
||||
- Reject unsafe artifact names, malformed descriptors, oversized evidence,
|
||||
excessive file counts and cross-origin handoff destinations before storage or
|
||||
navigation.
|
||||
- Document the origin-wide IndexedDB trust boundary explicitly: target app ids
|
||||
route transfers but cannot authorize mutually untrusted same-origin code.
|
||||
|
||||
## 0.2.3 — 2026-07-27
|
||||
|
||||
### Changed
|
||||
|
||||
- Moved the canonical repository, schema identifiers, examples, and source
|
||||
metadata to the `lotobo` (LocalToolBox) Gitea organization.
|
||||
- Moved publication of all three packages to the `lotobo` Gitea npm registry.
|
||||
|
||||
## 0.2.2 — 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- `ToolboxPersonalizePanel` for the portal-style personalization heading,
|
||||
storage warning, appearance picker, and portal extension content.
|
||||
|
||||
### Changed
|
||||
|
||||
- The invariant top row now fixes the Toolbox brand/home at the far left and
|
||||
Help, source, Apps, and Personalize at the far right in that order while the
|
||||
text identity remains geometrically centered.
|
||||
- Default app personalization now matches the central portal's panel styling;
|
||||
portal-only catalogue counts and import/export actions remain extensible
|
||||
content.
|
||||
|
||||
## 0.2.1 — 2026-07-23
|
||||
|
||||
### Added
|
||||
|
||||
- `ToolboxHeader.personalizeContent` for custom preferences inside the shared
|
||||
controlled popover and `brandIconUrl` for a portal favicon with an inline
|
||||
Toolbox cube fallback.
|
||||
- Accessible, mutually exclusive Personalize and Apps popovers that close on
|
||||
Escape, outside interaction, and link selection.
|
||||
|
||||
### Changed
|
||||
|
||||
- The invariant top row now orders Personalize, Apps, source, and Help on the
|
||||
left, keeps the text-only app identity geometrically centered, and fixes the
|
||||
Toolbox brand/home block at the far right.
|
||||
- Personalize and Apps keep visible desktop labels, become compact icon controls
|
||||
responsively without losing accessible names, and source and Help remain
|
||||
present at every supported width.
|
||||
- `AppShell` now renders version, privacy, manifest actions, and legacy app
|
||||
actions in a standard footer instead of changing the top-row geometry.
|
||||
- `iconUrl`, `leadingActions`, and `metadata` remain accepted for source
|
||||
compatibility but are deprecated for the fixed header design.
|
||||
|
||||
## 0.2.0 — 2026-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- A reusable, genuinely centered `ToolboxHeader` with standard Help, Gitea
|
||||
source, Personalize, and Apps controls.
|
||||
- Shared versioned browser preferences in `@add-ideas/toolbox-contract`, using
|
||||
the portal's existing storage key so light, dark, and system mode follow the
|
||||
user between same-origin Toolbox apps.
|
||||
|
||||
### Changed
|
||||
|
||||
- `AppShell` now uses the common top bar in both connected and standalone mode,
|
||||
derives its source link from manifest metadata, and offers app switching from
|
||||
a consistent Apps menu.
|
||||
- Explicit relative `manifestUrl` values now resolve against the deployed page
|
||||
URL, including nested-path deployments.
|
||||
|
||||
## 0.1.1 — 2026-07-20
|
||||
|
||||
### Fixed
|
||||
|
||||
@@ -5,7 +5,7 @@ tools. An app always works by itself. When it receives a trusted same-origin
|
||||
catalog URL, the same app gains a toolbox home link and an app switcher without
|
||||
becoming coupled to a portal or client-side router.
|
||||
|
||||
Version `0.1.0` contains three publish-ready packages:
|
||||
Version `0.3.0` contains three publish-ready packages:
|
||||
|
||||
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
||||
discovery/loading, resolved URLs, and contextual link helpers.
|
||||
@@ -36,7 +36,7 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.pdf-tools",
|
||||
"name": "PDF Workbench",
|
||||
@@ -57,24 +57,41 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
|
||||
"indexedDb": true,
|
||||
"crossOriginIsolated": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }],
|
||||
"produces": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": ["workers"],
|
||||
"optional": ["file-system-access"]
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/pdf-tools",
|
||||
"repository": "https://git.add-ideas.de/lotobo/pdf-tools",
|
||||
"license": "AGPL-3.0-only"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`,
|
||||
`actions`, and `assets` are optional v1 additions. `toolbox-check` verifies the
|
||||
entry, icon, and every declared asset. Runtime parsers validate every known
|
||||
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, `io`,
|
||||
`capabilities`, `actions`, and `assets` are optional v1 additions. `io`
|
||||
advertises accepted and produced media types/extensions; `capabilities`
|
||||
describes required and progressive browser features. `toolbox-check` verifies
|
||||
the entry, icon, and every declared asset. Runtime parsers validate every known
|
||||
field, require `schemaVersion: 1`, and deliberately discard unknown fields so
|
||||
future optional additions do not break v1 consumers.
|
||||
|
||||
When a manifest includes `capabilities`, worker declarations are cross-checked:
|
||||
`requirements.workers: true` means the app cannot run without workers and
|
||||
therefore requires `"workers"` in `capabilities.required`. Apps with a
|
||||
main-thread fallback set the requirement to `false` and may list `"workers"` in
|
||||
`capabilities.optional` instead. Legacy v1 manifests without a capability
|
||||
profile remain valid.
|
||||
|
||||
For typed source definitions, use the literal-preserving identity helper:
|
||||
|
||||
```ts
|
||||
@@ -88,6 +105,24 @@ export const manifest = defineToolboxApp({
|
||||
Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is
|
||||
compile-time only and does not replace runtime parsing.
|
||||
|
||||
## Explicit local artifact handoff
|
||||
|
||||
`createToolboxTransfer()` stores bounded `Blob` objects in same-origin IndexedDB
|
||||
using a cryptographic, short-lived token routed to one target app. Only that
|
||||
opaque token is added to the target URL by `createToolboxTransferUrl()`. The
|
||||
target calls `consumeToolboxTransfer()` and the record is atomically deleted.
|
||||
File bytes are neither uploaded nor placed in URLs, cross-origin destinations
|
||||
are rejected, and transfers expire after fifteen minutes by default. File
|
||||
descriptors, evidence, counts, names, sizes, and lifetimes are bounded before
|
||||
IndexedDB receives them.
|
||||
|
||||
The target app id is a routing/integrity check in this API, not a browser access
|
||||
control boundary. IndexedDB is shared by the entire origin, so any script
|
||||
running on that origin can open the transfer database directly and read or
|
||||
delete its records. Deploy only mutually trusted, reviewed Toolbox apps on one
|
||||
origin; an untrusted app must use a separate origin and cannot participate in
|
||||
this same-origin handoff.
|
||||
|
||||
## Catalog v1
|
||||
|
||||
The canonical schema is
|
||||
@@ -160,23 +195,41 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="/toolbox-app.json"
|
||||
appActions={<a href="./help/">Help</a>}
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
onContextError={(error) => console.warn(error)}
|
||||
>
|
||||
<Application />
|
||||
</AppShell>;
|
||||
```
|
||||
|
||||
The shell always renders app identity as an `h1`, version, derived privacy
|
||||
facts, manifest actions, and `appActions`. With valid context it additionally
|
||||
renders the catalog brand/home and a switcher at the accessible navigation
|
||||
landmark `Toolbox applications`. Destinations are ordinary links, so switching
|
||||
The shell's fixed top row renders the Toolbox brand/home at the far left, a
|
||||
geometrically centered text identity, and Help, source, Apps, and Personalize
|
||||
controls at the far right in that order. Personalize and Apps have labeled
|
||||
desktop controls and retain their accessible names when compact. The mutually
|
||||
exclusive popovers close on Escape, outside interaction, and link selection.
|
||||
|
||||
`AppShell` moves version, derived privacy facts, manifest actions, and legacy
|
||||
`appActions` into its standard footer, keeping the top row stable in connected
|
||||
and standalone modes. With valid context it supplies the catalog home, favicon,
|
||||
and available app destinations. Destinations are ordinary links, so switching
|
||||
performs full-page navigation. On missing, cross-origin, invalid, or unavailable
|
||||
context the shell quietly remains usable in standalone mode; `onContextError` is
|
||||
optional observability.
|
||||
|
||||
For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent`
|
||||
for content hosted inside the shared controlled popover and `brandIconUrl` for
|
||||
the Toolbox mark. `ToolboxPersonalizePanel` provides the shared portal-style
|
||||
heading, storage warning, and appearance picker, with a child slot for
|
||||
portal-specific controls. `onPersonalize` remains available for callback-only
|
||||
integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props
|
||||
are retained for source compatibility; the centered app icon is intentionally no
|
||||
longer rendered.
|
||||
|
||||
CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The
|
||||
catalog `light`, `dark`, or `system` mode selects the built-in palette.
|
||||
user's `light`, `dark`, or `system` preference is shared with same-origin apps
|
||||
under `TOOLBOX_PREFERENCES_KEY`; the catalog mode is used until a preference is
|
||||
saved. `ToolboxHeader` is also exported for portal-like pages that do not need
|
||||
the complete application shell.
|
||||
|
||||
## Validate a build
|
||||
|
||||
@@ -210,6 +263,8 @@ The main exports are:
|
||||
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
|
||||
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
|
||||
`contextualizeToolboxLink()`.
|
||||
- Preferences: `parseToolboxPreferences()`, `readToolboxPreferences()`,
|
||||
`writeToolboxPreferences()`, and `TOOLBOX_PREFERENCES_KEY`.
|
||||
|
||||
See each emitted `.d.ts` file for the complete signatures.
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.example-tool",
|
||||
"name": "Example Tool",
|
||||
@@ -26,7 +26,7 @@
|
||||
"telemetry": false
|
||||
},
|
||||
"source": {
|
||||
"repository": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
"repository": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.example-toolbox",
|
||||
"name": "Example Toolbox",
|
||||
|
||||
@@ -27,7 +27,7 @@ export const toolboxApp = defineToolboxApp({
|
||||
telemetry: false,
|
||||
},
|
||||
source: {
|
||||
repository: "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
repository: "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||
license: "Apache-2.0",
|
||||
},
|
||||
});
|
||||
|
||||
Generated
+7
-7
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -3946,7 +3946,7 @@
|
||||
},
|
||||
"packages/contract": {
|
||||
"name": "@add-ideas/toolbox-contract",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -3954,10 +3954,10 @@
|
||||
},
|
||||
"packages/shell-react": {
|
||||
"name": "@add-ideas/toolbox-shell-react",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -3966,10 +3966,10 @@
|
||||
},
|
||||
"packages/testkit": {
|
||||
"name": "@add-ideas/toolbox-testkit",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git"
|
||||
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git"
|
||||
},
|
||||
"type": "module",
|
||||
"workspaces": [
|
||||
|
||||
@@ -13,7 +13,34 @@ const app = parseToolboxApp(await response.json());
|
||||
const result = await loadToolboxContext();
|
||||
```
|
||||
|
||||
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
|
||||
The package also owns the versioned same-origin browser preference contract. Use
|
||||
`readToolboxPreferences()` and `writeToolboxPreferences()` to share pinned apps,
|
||||
ordering, visibility, and light/dark/system mode with the Toolbox portal.
|
||||
|
||||
Applications can advertise accepted and produced formats through the optional
|
||||
`io` manifest profile, and required or optional browser features through
|
||||
`capabilities`. These fields are runtime validated while remaining compatible
|
||||
with existing v1 manifests. When the capability profile is present, a manifest
|
||||
that sets `requirements.workers` to `true` must also list `"workers"` in
|
||||
`capabilities.required`; progressive worker enhancements belong in
|
||||
`capabilities.optional` with the requirement set to `false`. Legacy v1 manifests
|
||||
without `capabilities` remain valid.
|
||||
|
||||
The package also provides an explicit local artifact handoff. A sender stores
|
||||
one or more bounded `Blob` objects in same-origin IndexedDB with a
|
||||
cryptographic, short-lived token and a target routing label.
|
||||
`createToolboxTransferUrl()` places only the opaque token in the target URL;
|
||||
`consumeToolboxTransfer()` atomically consumes it once. No file bytes are put in
|
||||
a URL, uploaded, or persisted after consumption. Destination URLs must remain
|
||||
same-origin, and descriptors, evidence, file counts, total bytes, and lifetimes
|
||||
are validated and bounded before storage.
|
||||
|
||||
All code on one origin is inside the trust boundary: same-origin scripts can
|
||||
open IndexedDB without using this API, so `targetAppId` is not authorization.
|
||||
Host mutually untrusted apps on distinct origins; they deliberately cannot use
|
||||
this transfer mechanism.
|
||||
|
||||
See the workspace [README](https://git.add-ideas.de/lotobo/toolbox-sdk#readme)
|
||||
for the v1 document formats and full API.
|
||||
|
||||
The canonical schemas are also published at
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-contract",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||
"directory": "packages/contract"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||
"keywords": [
|
||||
"toolbox",
|
||||
"manifest",
|
||||
@@ -43,6 +43,6 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
||||
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
export {
|
||||
TOOLBOX_META_NAME,
|
||||
TOOLBOX_QUERY_PARAMETER,
|
||||
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||
TOOLBOX_ARTIFACT_VERSION,
|
||||
TOOLBOX_SCHEMA_VERSION,
|
||||
ToolboxError,
|
||||
ToolboxValidationError,
|
||||
@@ -26,6 +28,9 @@ export type {
|
||||
ToolboxDiscoverySource,
|
||||
ToolboxErrorCode,
|
||||
ToolboxIntegration,
|
||||
ToolboxFormat,
|
||||
ToolboxIoProfile,
|
||||
ToolboxCapabilityProfile,
|
||||
ToolboxLaunchMode,
|
||||
ToolboxPrivacy,
|
||||
ToolboxRequirements,
|
||||
@@ -53,6 +58,36 @@ export {
|
||||
loadToolboxCatalog,
|
||||
loadToolboxContext,
|
||||
} from "./load.js";
|
||||
export {
|
||||
consumeToolboxTransfer,
|
||||
createToolboxTransfer,
|
||||
createToolboxTransferUrl,
|
||||
deleteExpiredToolboxTransfers,
|
||||
readToolboxTransferToken,
|
||||
} from "./transfer.js";
|
||||
export type {
|
||||
ToolboxArtifactDescriptor,
|
||||
ToolboxArtifactEvidence,
|
||||
ToolboxArtifactFile,
|
||||
ToolboxArtifactSource,
|
||||
ToolboxTransfer,
|
||||
ToolboxTransferCreateInput,
|
||||
ToolboxTransferOptions,
|
||||
ToolboxTransferStore,
|
||||
ToolboxTransferUrlOptions,
|
||||
} from "./transfer.js";
|
||||
export {
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
defaultToolboxPreferences,
|
||||
parseToolboxPreferences,
|
||||
readToolboxPreferences,
|
||||
writeToolboxPreferences,
|
||||
} from "./preferences.js";
|
||||
export type {
|
||||
ToolboxPreferences,
|
||||
ToolboxPreferenceStorage,
|
||||
ToolboxThemeMode,
|
||||
} from "./preferences.js";
|
||||
export type {
|
||||
ToolboxContextLoadOptions,
|
||||
ToolboxFetch,
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
type ToolboxCatalogManifestEntry,
|
||||
type ToolboxCatalogTheme,
|
||||
type ToolboxIntegration,
|
||||
type ToolboxIoProfile,
|
||||
type ToolboxFormat,
|
||||
type ToolboxCapabilityProfile,
|
||||
type ToolboxLaunchMode,
|
||||
type ToolboxPrivacy,
|
||||
type ToolboxRequirements,
|
||||
@@ -21,6 +24,9 @@ type UnknownRecord = Record<string, unknown>;
|
||||
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
||||
const WEB_PROTOCOLS = new Set(["http:", "https:"]);
|
||||
const REFERENCE_BASE = "https://toolbox.invalid/";
|
||||
const MEDIA_TYPE_PATTERN =
|
||||
/^(?:\*|[a-z0-9!#$&^_.+-]+)\/(?:\*|[a-z0-9!#$&^_.+-]+)$/iu;
|
||||
const EXTENSION_PATTERN = /^\.[a-z0-9][a-z0-9._+-]*$/iu;
|
||||
|
||||
function recordAt(
|
||||
value: unknown,
|
||||
@@ -186,6 +192,107 @@ function stringListAt(
|
||||
});
|
||||
}
|
||||
|
||||
function identifierListAt(
|
||||
object: UnknownRecord,
|
||||
key: string,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): readonly string[] {
|
||||
const values = stringListAt(object, key, path, issues);
|
||||
values.forEach((value, index) => {
|
||||
if (!ID_PATTERN.test(value)) {
|
||||
issues.push(`${path}.${key}[${index}] must be a lowercase toolbox id`);
|
||||
}
|
||||
});
|
||||
return values.filter((value) => ID_PATTERN.test(value));
|
||||
}
|
||||
|
||||
function parseFormats(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): readonly ToolboxFormat[] {
|
||||
if (!Array.isArray(value)) {
|
||||
issues.push(`${path} must be an array`);
|
||||
return [];
|
||||
}
|
||||
const identities = new Set<string>();
|
||||
return value.flatMap((item, index) => {
|
||||
const itemPath = `${path}[${index}]`;
|
||||
const object = recordAt(item, itemPath, issues);
|
||||
const mediaType = stringAt(object, "mediaType", itemPath, issues);
|
||||
const extensions = stringListAt(object, "extensions", itemPath, issues);
|
||||
const label = stringAt(object, "label", itemPath, issues, {
|
||||
optional: true,
|
||||
});
|
||||
if (mediaType !== undefined && !MEDIA_TYPE_PATTERN.test(mediaType)) {
|
||||
issues.push(`${itemPath}.mediaType must be an Internet media type`);
|
||||
}
|
||||
extensions.forEach((extension, extensionIndex) => {
|
||||
if (!EXTENSION_PATTERN.test(extension)) {
|
||||
issues.push(
|
||||
`${itemPath}.extensions[${extensionIndex}] must start with a dot`,
|
||||
);
|
||||
}
|
||||
});
|
||||
if (
|
||||
mediaType === undefined ||
|
||||
!MEDIA_TYPE_PATTERN.test(mediaType) ||
|
||||
extensions.some((extension) => !EXTENSION_PATTERN.test(extension))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const normalizedMediaType = mediaType.toLowerCase();
|
||||
const normalizedExtensions = extensions.map((extension) =>
|
||||
extension.toLowerCase(),
|
||||
);
|
||||
const identity = `${normalizedMediaType}\u0000${normalizedExtensions.join(",")}`;
|
||||
if (identities.has(identity)) {
|
||||
issues.push(`${itemPath} duplicates an earlier format`);
|
||||
return [];
|
||||
}
|
||||
identities.add(identity);
|
||||
return [
|
||||
{
|
||||
mediaType: normalizedMediaType,
|
||||
extensions: normalizedExtensions,
|
||||
...(label === undefined ? {} : { label }),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseIoProfile(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxIoProfile | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const accepts = parseFormats(object.accepts, `${path}.accepts`, issues);
|
||||
const produces = parseFormats(object.produces, `${path}.produces`, issues);
|
||||
return { accepts, produces };
|
||||
}
|
||||
|
||||
function parseCapabilities(
|
||||
value: unknown,
|
||||
path: string,
|
||||
issues: string[],
|
||||
): ToolboxCapabilityProfile | undefined {
|
||||
const object = recordAt(value, path, issues);
|
||||
const required = identifierListAt(object, "required", path, issues);
|
||||
const optional = identifierListAt(object, "optional", path, issues);
|
||||
const requiredSet = new Set(required);
|
||||
optional.forEach((capability, index) => {
|
||||
if (requiredSet.has(capability)) {
|
||||
issues.push(`${path}.optional[${index}] is already required`);
|
||||
}
|
||||
});
|
||||
return {
|
||||
required,
|
||||
optional: optional.filter((item) => !requiredSet.has(item)),
|
||||
};
|
||||
}
|
||||
|
||||
function parsePrivacy(
|
||||
value: unknown,
|
||||
path: string,
|
||||
@@ -384,6 +491,25 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
|
||||
"$.requirements",
|
||||
issues,
|
||||
);
|
||||
const io =
|
||||
"io" in object ? parseIoProfile(object.io, "$.io", issues) : undefined;
|
||||
const capabilities =
|
||||
"capabilities" in object
|
||||
? parseCapabilities(object.capabilities, "$.capabilities", issues)
|
||||
: undefined;
|
||||
if (requirements !== undefined && capabilities !== undefined) {
|
||||
const workersRequired = capabilities.required.includes("workers");
|
||||
if (requirements.workers && !workersRequired) {
|
||||
issues.push(
|
||||
"$.capabilities.required must include workers when $.requirements.workers is true",
|
||||
);
|
||||
}
|
||||
if (!requirements.workers && workersRequired) {
|
||||
issues.push(
|
||||
"$.capabilities.required must not include workers when $.requirements.workers is false",
|
||||
);
|
||||
}
|
||||
}
|
||||
const privacy = parsePrivacy(object.privacy, "$.privacy", issues);
|
||||
const source =
|
||||
"source" in object
|
||||
@@ -424,6 +550,8 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
|
||||
tags,
|
||||
integration,
|
||||
requirements,
|
||||
...(io === undefined ? {} : { io }),
|
||||
...(capabilities === undefined ? {} : { capabilities }),
|
||||
privacy,
|
||||
...(source === undefined ? {} : { source }),
|
||||
...(actions === undefined ? {} : { actions }),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import type { ToolboxCatalogTheme } from "./types.js";
|
||||
|
||||
export const TOOLBOX_PREFERENCES_KEY =
|
||||
"@add-ideas/toolbox-portal:v1:preferences" as const;
|
||||
|
||||
export type ToolboxThemeMode = ToolboxCatalogTheme["mode"];
|
||||
|
||||
export interface ToolboxPreferences {
|
||||
version: 1;
|
||||
pinned: string[];
|
||||
order: string[];
|
||||
hidden: string[];
|
||||
theme: ToolboxThemeMode;
|
||||
}
|
||||
|
||||
export interface ToolboxPreferenceStorage {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
}
|
||||
|
||||
export const defaultToolboxPreferences: Readonly<ToolboxPreferences> = {
|
||||
version: 1,
|
||||
pinned: [],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: "system",
|
||||
};
|
||||
|
||||
function freshDefaults(): ToolboxPreferences {
|
||||
return {
|
||||
...defaultToolboxPreferences,
|
||||
pinned: [],
|
||||
order: [],
|
||||
hidden: [],
|
||||
};
|
||||
}
|
||||
|
||||
function uniqueStrings(value: unknown, field: string): string[] {
|
||||
if (
|
||||
!Array.isArray(value) ||
|
||||
value.some((item) => typeof item !== "string" || item.length === 0)
|
||||
) {
|
||||
throw new Error(`${field} must be an array of non-empty strings.`);
|
||||
}
|
||||
return [...new Set(value)];
|
||||
}
|
||||
|
||||
export function parseToolboxPreferences(value: unknown): ToolboxPreferences {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
||||
throw new Error("Preferences must be a JSON object.");
|
||||
}
|
||||
|
||||
const candidate = value as Record<string, unknown>;
|
||||
if (candidate.version !== 1) {
|
||||
throw new Error("Unsupported preferences version.");
|
||||
}
|
||||
|
||||
const theme = candidate.theme;
|
||||
if (theme !== "light" && theme !== "dark" && theme !== "system") {
|
||||
throw new Error("Invalid theme preference.");
|
||||
}
|
||||
|
||||
return {
|
||||
version: 1,
|
||||
pinned: uniqueStrings(candidate.pinned, "pinned"),
|
||||
order: uniqueStrings(candidate.order, "order"),
|
||||
hidden: uniqueStrings(candidate.hidden, "hidden"),
|
||||
theme,
|
||||
};
|
||||
}
|
||||
|
||||
export function readToolboxPreferences(
|
||||
storage?: ToolboxPreferenceStorage,
|
||||
): ToolboxPreferences {
|
||||
if (!storage) return freshDefaults();
|
||||
const value = storage.getItem(TOOLBOX_PREFERENCES_KEY);
|
||||
if (!value) return freshDefaults();
|
||||
|
||||
try {
|
||||
return parseToolboxPreferences(JSON.parse(value));
|
||||
} catch {
|
||||
return freshDefaults();
|
||||
}
|
||||
}
|
||||
|
||||
export function writeToolboxPreferences(
|
||||
preferences: ToolboxPreferences,
|
||||
storage: ToolboxPreferenceStorage,
|
||||
): void {
|
||||
storage.setItem(
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
JSON.stringify(parseToolboxPreferences(preferences)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,477 @@
|
||||
import {
|
||||
TOOLBOX_ARTIFACT_VERSION,
|
||||
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||
} from "./types.js";
|
||||
|
||||
const DATABASE_NAME = "add-ideas-toolbox-transfers-v1";
|
||||
const STORE_NAME = "transfers";
|
||||
const DEFAULT_TTL_MS = 15 * 60 * 1000;
|
||||
const MAX_TTL_MS = 60 * 60 * 1000;
|
||||
const DEFAULT_MAX_BYTES = 512 * 1024 * 1024;
|
||||
const MAX_FILES = 128;
|
||||
const MAX_NAME_CHARACTERS = 255;
|
||||
const MAX_EVIDENCE_BYTES = 1024 * 1024;
|
||||
const APP_ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
|
||||
const TOKEN_PATTERN = /^[A-Za-z0-9_-]{22}$/;
|
||||
const MEDIA_TYPE_PATTERN =
|
||||
/^[a-z0-9!#$&^_.+-]+\/[a-z0-9!#$&^_.+-]+(?:\s*;\s*[a-z0-9!#$&^_.+-]+=(?:[a-z0-9!#$&^_.+-]+|"[^"]*"))*$/iu;
|
||||
|
||||
export interface ToolboxArtifactSource {
|
||||
appId: string;
|
||||
appVersion?: string;
|
||||
}
|
||||
|
||||
export interface ToolboxArtifactDescriptor {
|
||||
name: string;
|
||||
mediaType: string;
|
||||
size: number;
|
||||
lastModified?: number;
|
||||
sha256?: string;
|
||||
}
|
||||
|
||||
export interface ToolboxArtifactFile extends ToolboxArtifactDescriptor {
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
export interface ToolboxArtifactEvidence {
|
||||
formatVersion: string;
|
||||
operation?: string;
|
||||
engine?: string;
|
||||
settings?: Readonly<Record<string, unknown>>;
|
||||
warnings?: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolboxTransfer {
|
||||
artifactVersion: typeof TOOLBOX_ARTIFACT_VERSION;
|
||||
token: string;
|
||||
source: ToolboxArtifactSource;
|
||||
targetAppId: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
files: readonly ToolboxArtifactFile[];
|
||||
evidence?: ToolboxArtifactEvidence;
|
||||
}
|
||||
|
||||
export interface ToolboxTransferCreateInput {
|
||||
source: ToolboxArtifactSource;
|
||||
targetAppId: string;
|
||||
files: readonly ToolboxArtifactFile[];
|
||||
evidence?: ToolboxArtifactEvidence;
|
||||
ttlMs?: number;
|
||||
maxBytes?: number;
|
||||
}
|
||||
|
||||
export interface ToolboxTransferStore {
|
||||
put(transfer: ToolboxTransfer): Promise<void>;
|
||||
take(
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
now: number,
|
||||
): Promise<ToolboxTransfer | undefined>;
|
||||
deleteExpired(now: number): Promise<number>;
|
||||
}
|
||||
|
||||
export interface ToolboxTransferOptions {
|
||||
store?: ToolboxTransferStore;
|
||||
indexedDB?: IDBFactory;
|
||||
crypto?: Pick<Crypto, "getRandomValues">;
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface ToolboxTransferUrlOptions {
|
||||
location?: string | URL;
|
||||
}
|
||||
|
||||
function openDatabase(factory: IDBFactory): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = factory.open(DATABASE_NAME, 1);
|
||||
request.onupgradeneeded = () => {
|
||||
const database = request.result;
|
||||
if (!database.objectStoreNames.contains(STORE_NAME)) {
|
||||
const store = database.createObjectStore(STORE_NAME, {
|
||||
keyPath: "token",
|
||||
});
|
||||
store.createIndex("expiresAt", "expiresAt");
|
||||
}
|
||||
};
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("IndexedDB open failed"));
|
||||
request.onblocked = () => reject(new Error("IndexedDB upgrade is blocked"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function requestResult<T>(request: IDBRequest<T>): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("IndexedDB request failed"));
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
});
|
||||
}
|
||||
|
||||
function transactionComplete(transaction: IDBTransaction): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
transaction.oncomplete = () => resolve();
|
||||
transaction.onabort = () =>
|
||||
reject(transaction.error ?? new Error("IndexedDB transaction aborted"));
|
||||
transaction.onerror = () =>
|
||||
reject(transaction.error ?? new Error("IndexedDB transaction failed"));
|
||||
});
|
||||
}
|
||||
|
||||
class IndexedDbTransferStore implements ToolboxTransferStore {
|
||||
constructor(private readonly factory: IDBFactory) {}
|
||||
|
||||
async put(transfer: ToolboxTransfer): Promise<void> {
|
||||
const database = await openDatabase(this.factory);
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||
transaction.objectStore(STORE_NAME).put(transfer);
|
||||
await transactionComplete(transaction);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async take(
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
now: number,
|
||||
): Promise<ToolboxTransfer | undefined> {
|
||||
const database = await openDatabase(this.factory);
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||
const store = transaction.objectStore(STORE_NAME);
|
||||
const value = await requestResult(store.get(token));
|
||||
const transfer = value as ToolboxTransfer | undefined;
|
||||
if (
|
||||
transfer !== undefined &&
|
||||
transfer.targetAppId !== expectedTargetAppId
|
||||
) {
|
||||
transaction.abort();
|
||||
throw new Error(
|
||||
"Artifact transfer was addressed to another application",
|
||||
);
|
||||
}
|
||||
if (transfer !== undefined) store.delete(token);
|
||||
await transactionComplete(transaction);
|
||||
return transfer === undefined || transfer.expiresAt <= now
|
||||
? undefined
|
||||
: transfer;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async deleteExpired(now: number): Promise<number> {
|
||||
const database = await openDatabase(this.factory);
|
||||
let count = 0;
|
||||
try {
|
||||
const transaction = database.transaction(STORE_NAME, "readwrite");
|
||||
const index = transaction.objectStore(STORE_NAME).index("expiresAt");
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const request = index.openCursor(IDBKeyRange.upperBound(now));
|
||||
request.onerror = () =>
|
||||
reject(request.error ?? new Error("IndexedDB cursor failed"));
|
||||
request.onsuccess = () => {
|
||||
const cursor = request.result;
|
||||
if (cursor === null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
cursor.delete();
|
||||
count += 1;
|
||||
cursor.continue();
|
||||
};
|
||||
});
|
||||
await transactionComplete(transaction);
|
||||
return count;
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function defaultStore(options: ToolboxTransferOptions): ToolboxTransferStore {
|
||||
const factory = options.indexedDB ?? globalThis.indexedDB;
|
||||
if (factory === undefined) throw new Error("IndexedDB is not available");
|
||||
return new IndexedDbTransferStore(factory);
|
||||
}
|
||||
|
||||
function assertAppId(value: string, label: string): void {
|
||||
if (!APP_ID_PATTERN.test(value))
|
||||
throw new TypeError(`${label} is not a toolbox app id`);
|
||||
}
|
||||
|
||||
function normalizedFile(file: ToolboxArtifactFile): ToolboxArtifactFile {
|
||||
if (!(file.blob instanceof Blob))
|
||||
throw new TypeError("Artifact file blob is invalid");
|
||||
const hasControlCharacter = Array.from(file.name).some((character) => {
|
||||
const point = character.codePointAt(0)!;
|
||||
return point <= 0x1f || point === 0x7f;
|
||||
});
|
||||
if (
|
||||
file.name.trim() === "" ||
|
||||
Array.from(file.name).length > MAX_NAME_CHARACTERS ||
|
||||
hasControlCharacter ||
|
||||
file.name.includes("/") ||
|
||||
file.name.includes("\\")
|
||||
) {
|
||||
throw new TypeError("Artifact file name is invalid");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(file.size) ||
|
||||
file.size < 0 ||
|
||||
file.size !== file.blob.size
|
||||
) {
|
||||
throw new TypeError(`Artifact size does not match ${file.name}`);
|
||||
}
|
||||
if (file.sha256 !== undefined && !/^[a-f0-9]{64}$/u.test(file.sha256)) {
|
||||
throw new TypeError(`Artifact SHA-256 is invalid for ${file.name}`);
|
||||
}
|
||||
if (
|
||||
file.lastModified !== undefined &&
|
||||
(!Number.isSafeInteger(file.lastModified) || file.lastModified < 0)
|
||||
) {
|
||||
throw new TypeError(
|
||||
`Artifact modification time is invalid for ${file.name}`,
|
||||
);
|
||||
}
|
||||
const mediaType = file.mediaType || "application/octet-stream";
|
||||
if (mediaType.length > 255 || !MEDIA_TYPE_PATTERN.test(mediaType)) {
|
||||
throw new TypeError(`Artifact media type is invalid for ${file.name}`);
|
||||
}
|
||||
return { ...file, mediaType };
|
||||
}
|
||||
|
||||
function normalizedEvidence(
|
||||
evidence: ToolboxArtifactEvidence | undefined,
|
||||
): ToolboxArtifactEvidence | undefined {
|
||||
if (evidence === undefined) return undefined;
|
||||
if (
|
||||
typeof evidence.formatVersion !== "string" ||
|
||||
evidence.formatVersion.trim() === "" ||
|
||||
evidence.formatVersion.length > 64
|
||||
) {
|
||||
throw new TypeError("Artifact evidence formatVersion is invalid");
|
||||
}
|
||||
for (const [label, value] of [
|
||||
["operation", evidence.operation],
|
||||
["engine", evidence.engine],
|
||||
] as const) {
|
||||
if (
|
||||
value !== undefined &&
|
||||
(typeof value !== "string" || value.length > 256)
|
||||
) {
|
||||
throw new TypeError(`Artifact evidence ${label} is invalid`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
evidence.warnings !== undefined &&
|
||||
(!Array.isArray(evidence.warnings) ||
|
||||
evidence.warnings.length > 100 ||
|
||||
evidence.warnings.some(
|
||||
(warning) => typeof warning !== "string" || warning.length > 1_024,
|
||||
))
|
||||
) {
|
||||
throw new TypeError("Artifact evidence warnings are invalid");
|
||||
}
|
||||
let serialized: string;
|
||||
try {
|
||||
serialized = JSON.stringify(evidence);
|
||||
} catch {
|
||||
throw new TypeError("Artifact evidence must be JSON-serializable");
|
||||
}
|
||||
if (
|
||||
serialized === undefined ||
|
||||
new TextEncoder().encode(serialized).byteLength > MAX_EVIDENCE_BYTES
|
||||
) {
|
||||
throw new RangeError("Artifact evidence exceeds the 1 MiB limit");
|
||||
}
|
||||
return evidence;
|
||||
}
|
||||
|
||||
function tokenFrom(random: Pick<Crypto, "getRandomValues">): string {
|
||||
const bytes = random.getRandomValues(new Uint8Array(16));
|
||||
let binary = "";
|
||||
bytes.forEach((value) => {
|
||||
binary += String.fromCharCode(value);
|
||||
});
|
||||
return btoa(binary)
|
||||
.replaceAll("+", "-")
|
||||
.replaceAll("/", "_")
|
||||
.replace(/=+$/u, "");
|
||||
}
|
||||
|
||||
function validatedStoredTransfer(
|
||||
value: ToolboxTransfer,
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
now: number,
|
||||
): ToolboxTransfer {
|
||||
if (
|
||||
value.artifactVersion !== TOOLBOX_ARTIFACT_VERSION ||
|
||||
value.token !== token ||
|
||||
value.targetAppId !== expectedTargetAppId
|
||||
) {
|
||||
throw new TypeError("Stored artifact transfer identity is invalid");
|
||||
}
|
||||
if (typeof value.source?.appId !== "string") {
|
||||
throw new TypeError("Stored source app id is invalid");
|
||||
}
|
||||
assertAppId(value.source.appId, "Stored source app id");
|
||||
if (
|
||||
value.source.appVersion !== undefined &&
|
||||
(typeof value.source.appVersion !== "string" ||
|
||||
value.source.appVersion.trim() === "" ||
|
||||
value.source.appVersion.length > 64)
|
||||
) {
|
||||
throw new TypeError("Stored source app version is invalid");
|
||||
}
|
||||
if (
|
||||
!Number.isSafeInteger(value.createdAt) ||
|
||||
!Number.isSafeInteger(value.expiresAt) ||
|
||||
value.createdAt < 0 ||
|
||||
value.expiresAt <= value.createdAt ||
|
||||
value.expiresAt - value.createdAt > MAX_TTL_MS ||
|
||||
value.expiresAt <= now
|
||||
) {
|
||||
throw new TypeError("Stored artifact transfer lifetime is invalid");
|
||||
}
|
||||
if (
|
||||
!Array.isArray(value.files) ||
|
||||
value.files.length === 0 ||
|
||||
value.files.length > MAX_FILES
|
||||
) {
|
||||
throw new RangeError("Stored artifact transfer file count is invalid");
|
||||
}
|
||||
const files = value.files.map(normalizedFile);
|
||||
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
||||
if (!Number.isSafeInteger(totalBytes) || totalBytes > DEFAULT_MAX_BYTES) {
|
||||
throw new RangeError("Stored artifact transfer exceeds the byte limit");
|
||||
}
|
||||
const evidence = normalizedEvidence(value.evidence);
|
||||
return {
|
||||
...value,
|
||||
source: { ...value.source },
|
||||
files,
|
||||
...(evidence === undefined ? {} : { evidence }),
|
||||
};
|
||||
}
|
||||
|
||||
export async function createToolboxTransfer(
|
||||
input: ToolboxTransferCreateInput,
|
||||
options: ToolboxTransferOptions = {},
|
||||
): Promise<ToolboxTransfer> {
|
||||
assertAppId(input.source.appId, "Source app id");
|
||||
assertAppId(input.targetAppId, "Target app id");
|
||||
if (
|
||||
input.source.appVersion !== undefined &&
|
||||
(input.source.appVersion.trim() === "" ||
|
||||
input.source.appVersion.length > 64)
|
||||
) {
|
||||
throw new TypeError("Source app version is invalid");
|
||||
}
|
||||
if (input.files.length === 0 || input.files.length > MAX_FILES) {
|
||||
throw new RangeError(`Artifact transfers require 1–${MAX_FILES} files`);
|
||||
}
|
||||
const files = input.files.map(normalizedFile);
|
||||
const evidence = normalizedEvidence(input.evidence);
|
||||
const totalBytes = files.reduce((total, file) => total + file.size, 0);
|
||||
const maxBytes = input.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||
if (
|
||||
!Number.isSafeInteger(maxBytes) ||
|
||||
maxBytes <= 0 ||
|
||||
maxBytes > DEFAULT_MAX_BYTES
|
||||
) {
|
||||
throw new RangeError("Artifact transfer byte limit is invalid");
|
||||
}
|
||||
if (totalBytes > maxBytes)
|
||||
throw new RangeError(
|
||||
`Artifact transfer exceeds the ${maxBytes}-byte limit`,
|
||||
);
|
||||
const ttlMs = input.ttlMs ?? DEFAULT_TTL_MS;
|
||||
if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0 || ttlMs > MAX_TTL_MS) {
|
||||
throw new RangeError(`Artifact TTL must be between 1 and ${MAX_TTL_MS} ms`);
|
||||
}
|
||||
const now = (options.now ?? Date.now)();
|
||||
if (
|
||||
!Number.isSafeInteger(now) ||
|
||||
now < 0 ||
|
||||
now + ttlMs > Number.MAX_SAFE_INTEGER
|
||||
)
|
||||
throw new RangeError("Artifact transfer creation time is invalid");
|
||||
const random = options.crypto ?? globalThis.crypto;
|
||||
if (random === undefined)
|
||||
throw new Error("Cryptographic randomness is not available");
|
||||
const transfer: ToolboxTransfer = {
|
||||
artifactVersion: TOOLBOX_ARTIFACT_VERSION,
|
||||
token: tokenFrom(random),
|
||||
source: input.source,
|
||||
targetAppId: input.targetAppId,
|
||||
createdAt: now,
|
||||
expiresAt: now + ttlMs,
|
||||
files,
|
||||
...(evidence === undefined ? {} : { evidence }),
|
||||
};
|
||||
await (options.store ?? defaultStore(options)).put(transfer);
|
||||
return transfer;
|
||||
}
|
||||
|
||||
export async function consumeToolboxTransfer(
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
options: ToolboxTransferOptions = {},
|
||||
): Promise<ToolboxTransfer | undefined> {
|
||||
if (!TOKEN_PATTERN.test(token))
|
||||
throw new TypeError("Artifact token is invalid");
|
||||
assertAppId(expectedTargetAppId, "Target app id");
|
||||
const now = (options.now ?? Date.now)();
|
||||
const transfer = await (options.store ?? defaultStore(options)).take(
|
||||
token,
|
||||
expectedTargetAppId,
|
||||
now,
|
||||
);
|
||||
if (transfer === undefined) return undefined;
|
||||
return validatedStoredTransfer(transfer, token, expectedTargetAppId, now);
|
||||
}
|
||||
|
||||
export async function deleteExpiredToolboxTransfers(
|
||||
options: ToolboxTransferOptions = {},
|
||||
): Promise<number> {
|
||||
return (options.store ?? defaultStore(options)).deleteExpired(
|
||||
(options.now ?? Date.now)(),
|
||||
);
|
||||
}
|
||||
|
||||
export function createToolboxTransferUrl(
|
||||
target: string | URL,
|
||||
token: string,
|
||||
options: ToolboxTransferUrlOptions = {},
|
||||
): URL {
|
||||
if (!TOKEN_PATTERN.test(token))
|
||||
throw new TypeError("Artifact token is invalid");
|
||||
const location = options.location ?? globalThis.location?.href;
|
||||
if (location === undefined) {
|
||||
throw new Error("Current location is required for a safe artifact handoff");
|
||||
}
|
||||
const base = new URL(location);
|
||||
const url = new URL(target, base);
|
||||
if (url.origin !== base.origin) {
|
||||
throw new TypeError("Artifact transfers must remain on the current origin");
|
||||
}
|
||||
url.searchParams.set(TOOLBOX_TRANSFER_QUERY_PARAMETER, token);
|
||||
return url;
|
||||
}
|
||||
|
||||
export function readToolboxTransferToken(
|
||||
location: string | URL = globalThis.location.href,
|
||||
): string | undefined {
|
||||
const token = new URL(location).searchParams.get(
|
||||
TOOLBOX_TRANSFER_QUERY_PARAMETER,
|
||||
);
|
||||
if (token === null) return undefined;
|
||||
if (!TOKEN_PATTERN.test(token))
|
||||
throw new TypeError("Artifact token is invalid");
|
||||
return token;
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
export const TOOLBOX_SCHEMA_VERSION = 1 as const;
|
||||
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
|
||||
export const TOOLBOX_META_NAME = "toolbox" as const;
|
||||
export const TOOLBOX_TRANSFER_QUERY_PARAMETER = "toolbox-transfer" as const;
|
||||
export const TOOLBOX_ARTIFACT_VERSION = 1 as const;
|
||||
|
||||
export interface ToolboxAction {
|
||||
id: string;
|
||||
@@ -32,6 +34,22 @@ export interface ToolboxRequirements {
|
||||
topLevelContext?: boolean;
|
||||
}
|
||||
|
||||
export interface ToolboxFormat {
|
||||
mediaType: string;
|
||||
extensions: readonly string[];
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export interface ToolboxIoProfile {
|
||||
accepts: readonly ToolboxFormat[];
|
||||
produces: readonly ToolboxFormat[];
|
||||
}
|
||||
|
||||
export interface ToolboxCapabilityProfile {
|
||||
required: readonly string[];
|
||||
optional: readonly string[];
|
||||
}
|
||||
|
||||
export interface ToolboxSource {
|
||||
repository: string;
|
||||
license: string;
|
||||
@@ -49,6 +67,8 @@ export interface ToolboxAppManifest {
|
||||
tags: readonly string[];
|
||||
integration: ToolboxIntegration;
|
||||
requirements: ToolboxRequirements;
|
||||
io?: ToolboxIoProfile;
|
||||
capabilities?: ToolboxCapabilityProfile;
|
||||
privacy: ToolboxPrivacy;
|
||||
source?: ToolboxSource;
|
||||
actions?: readonly ToolboxAction[];
|
||||
|
||||
@@ -33,6 +33,10 @@ const app = (
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: {
|
||||
required: ["workers"],
|
||||
optional: [],
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
@@ -117,6 +121,86 @@ describe("v1 runtime parsing", () => {
|
||||
).toThrow(/absolute HTTP\(S\) URL/u);
|
||||
});
|
||||
|
||||
it("normalizes declared formats and capability profiles", () => {
|
||||
const parsed = parseToolboxApp(
|
||||
app({
|
||||
io: {
|
||||
accepts: [
|
||||
{
|
||||
mediaType: "Application/PDF",
|
||||
extensions: [".PDF"],
|
||||
label: "PDF",
|
||||
},
|
||||
],
|
||||
produces: [{ mediaType: "image/*", extensions: [".png"] }],
|
||||
},
|
||||
capabilities: {
|
||||
required: ["workers"],
|
||||
optional: ["file-system-access"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(parsed.io?.accepts[0]).toEqual({
|
||||
mediaType: "application/pdf",
|
||||
extensions: [".pdf"],
|
||||
label: "PDF",
|
||||
});
|
||||
expect(parsed.capabilities).toEqual({
|
||||
required: ["workers"],
|
||||
optional: ["file-system-access"],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed formats and duplicate capabilities", () => {
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
io: {
|
||||
accepts: [{ mediaType: "pdf", extensions: ["pdf"] }],
|
||||
produces: [],
|
||||
},
|
||||
}),
|
||||
),
|
||||
).toThrow(/mediaType|start with a dot/u);
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
capabilities: { required: ["workers"], optional: ["workers"] },
|
||||
}),
|
||||
),
|
||||
).toThrow(/already required/u);
|
||||
});
|
||||
|
||||
it("keeps worker requirements and required capabilities consistent", () => {
|
||||
const legacyManifest = app();
|
||||
delete legacyManifest.capabilities;
|
||||
expect(parseToolboxApp(legacyManifest).requirements.workers).toBe(true);
|
||||
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
),
|
||||
).toThrow(/required must include workers.*requirements\.workers is true/u);
|
||||
|
||||
expect(() =>
|
||||
parseToolboxApp(
|
||||
app({
|
||||
requirements: {
|
||||
secureContext: true,
|
||||
workers: false,
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
),
|
||||
).toThrow(
|
||||
/required must not include workers.*requirements\.workers is false/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("parses manifest references and external inline catalog entries", () => {
|
||||
const parsed = parseToolboxCatalog(
|
||||
catalog({
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import {
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
defaultToolboxPreferences,
|
||||
parseToolboxPreferences,
|
||||
readToolboxPreferences,
|
||||
writeToolboxPreferences,
|
||||
} from "../src/index.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function memoryStorage(initial?: string) {
|
||||
const values = new Map<string, string>();
|
||||
if (initial !== undefined) values.set(TOOLBOX_PREFERENCES_KEY, initial);
|
||||
return {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
};
|
||||
}
|
||||
|
||||
describe("shared toolbox preferences", () => {
|
||||
it("parses version 1 and removes duplicate ids", () => {
|
||||
expect(
|
||||
parseToolboxPreferences({
|
||||
version: 1,
|
||||
pinned: ["pdf", "pdf"],
|
||||
order: ["pdf", "xslt"],
|
||||
hidden: [],
|
||||
theme: "dark",
|
||||
}),
|
||||
).toEqual({
|
||||
version: 1,
|
||||
pinned: ["pdf"],
|
||||
order: ["pdf", "xslt"],
|
||||
hidden: [],
|
||||
theme: "dark",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns independent defaults for missing or corrupt storage", () => {
|
||||
const missing = readToolboxPreferences(memoryStorage());
|
||||
const corrupt = readToolboxPreferences(memoryStorage("not json"));
|
||||
missing.pinned.push("pdf");
|
||||
expect(corrupt).toEqual(defaultToolboxPreferences);
|
||||
expect(corrupt.pinned).toEqual([]);
|
||||
});
|
||||
|
||||
it("writes validated preferences under the compatible portal key", () => {
|
||||
const storage = memoryStorage();
|
||||
writeToolboxPreferences(
|
||||
{
|
||||
version: 1,
|
||||
pinned: ["pdf"],
|
||||
order: ["pdf"],
|
||||
hidden: [],
|
||||
theme: "light",
|
||||
},
|
||||
storage,
|
||||
);
|
||||
expect(readToolboxPreferences(storage)).toMatchObject({
|
||||
pinned: ["pdf"],
|
||||
theme: "light",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -124,6 +124,38 @@ describe("canonical schema and runtime parser parity", () => {
|
||||
source: { repository: "HTTPS:example.test", license: "MIT" },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a required worker capability paired with a worker requirement",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"an optional worker capability without a worker requirement",
|
||||
() => ({
|
||||
...validApp(),
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a legacy worker requirement without a capability profile",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(acceptedApps)("accepts %s", (_label, fixture) => {
|
||||
@@ -175,6 +207,26 @@ describe("canonical schema and runtime parser parity", () => {
|
||||
actions: [{ id: "docs", label: " ", url: "./docs" }],
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a worker requirement without a required worker capability",
|
||||
() => ({
|
||||
...validApp(),
|
||||
requirements: {
|
||||
secureContext: false,
|
||||
workers: true,
|
||||
indexedDb: false,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: { required: [], optional: ["workers"] },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a required worker capability with workers disabled",
|
||||
() => ({
|
||||
...validApp(),
|
||||
capabilities: { required: ["workers"], optional: [] },
|
||||
}),
|
||||
],
|
||||
];
|
||||
|
||||
it.each(rejectedApps)("rejects %s", (_label, fixture) => {
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import {
|
||||
consumeToolboxTransfer,
|
||||
createToolboxTransfer,
|
||||
createToolboxTransferUrl,
|
||||
readToolboxTransferToken,
|
||||
type ToolboxTransfer,
|
||||
type ToolboxTransferStore,
|
||||
} from "../src/index.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
class MemoryStore implements ToolboxTransferStore {
|
||||
readonly values = new Map<string, ToolboxTransfer>();
|
||||
|
||||
async put(transfer: ToolboxTransfer): Promise<void> {
|
||||
this.values.set(transfer.token, transfer);
|
||||
}
|
||||
|
||||
async take(
|
||||
token: string,
|
||||
expectedTargetAppId: string,
|
||||
now: number,
|
||||
): Promise<ToolboxTransfer | undefined> {
|
||||
const value = this.values.get(token);
|
||||
if (value !== undefined && value.targetAppId !== expectedTargetAppId) {
|
||||
throw new Error("Artifact transfer was addressed to another application");
|
||||
}
|
||||
this.values.delete(token);
|
||||
return value === undefined || value.expiresAt <= now ? undefined : value;
|
||||
}
|
||||
|
||||
async deleteExpired(now: number): Promise<number> {
|
||||
let count = 0;
|
||||
for (const [token, value] of this.values) {
|
||||
if (value.expiresAt <= now) {
|
||||
this.values.delete(token);
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
}
|
||||
|
||||
const deterministicCrypto = {
|
||||
getRandomValues<T extends ArrayBufferView | null>(array: T): T {
|
||||
if (array instanceof Uint8Array)
|
||||
array.forEach((_value, index) => (array[index] = index));
|
||||
return array;
|
||||
},
|
||||
};
|
||||
|
||||
describe("one-time artifact transfers", () => {
|
||||
it("stores, addresses and consumes an artifact exactly once", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"], { type: "text/plain" });
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "de.add-ideas.file-tools", appVersion: "1.0.0" },
|
||||
targetAppId: "de.add-ideas.text-tools",
|
||||
files: [
|
||||
{ blob, name: "hello.txt", mediaType: blob.type, size: blob.size },
|
||||
],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 1_000 },
|
||||
);
|
||||
expect(transfer.token).toHaveLength(22);
|
||||
const url = createToolboxTransferUrl(
|
||||
"https://tools.test/apps/text/",
|
||||
transfer.token,
|
||||
{ location: "https://tools.test/apps/file/" },
|
||||
);
|
||||
expect(readToolboxTransferToken(url)).toBe(transfer.token);
|
||||
const consumed = await consumeToolboxTransfer(
|
||||
transfer.token,
|
||||
"de.add-ideas.text-tools",
|
||||
{ store, now: () => 2_000 },
|
||||
);
|
||||
expect(await consumed?.files[0]?.blob.text()).toBe("hello");
|
||||
expect(
|
||||
await consumeToolboxTransfer(transfer.token, "de.add-ideas.text-tools", {
|
||||
store,
|
||||
now: () => 2_000,
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects expired, oversized and wrongly addressed transfers", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
ttlMs: 10,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 10 },
|
||||
);
|
||||
await expect(
|
||||
consumeToolboxTransfer(transfer.token, "target", {
|
||||
store,
|
||||
now: () => 20,
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
maxBytes: 4,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/exceeds/u);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
maxBytes: 512 * 1024 * 1024 + 1,
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/invalid/iu);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => Number.NaN },
|
||||
),
|
||||
).rejects.toThrow(/creation time/iu);
|
||||
|
||||
const addressed = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "a.bin", mediaType: "", size: blob.size }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
);
|
||||
await expect(
|
||||
consumeToolboxTransfer(addressed.token, "another-target", { store }),
|
||||
).rejects.toThrow(/another application/u);
|
||||
});
|
||||
|
||||
it("keeps opaque handoff tokens on-origin and rejects unsafe descriptors", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
await expect(
|
||||
createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [
|
||||
{
|
||||
blob,
|
||||
name: "../hello.txt",
|
||||
mediaType: "text/plain",
|
||||
size: blob.size,
|
||||
},
|
||||
],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto },
|
||||
),
|
||||
).rejects.toThrow(/name is invalid/u);
|
||||
expect(() =>
|
||||
createToolboxTransferUrl(
|
||||
"https://other.test/apps/text/",
|
||||
"AAECAwQFBgcICQoLDA0ODw",
|
||||
{ location: "https://tools.test/apps/file/" },
|
||||
),
|
||||
).toThrow(/current origin/u);
|
||||
});
|
||||
|
||||
it("revalidates same-origin storage records at the consuming trust boundary", async () => {
|
||||
const store = new MemoryStore();
|
||||
const blob = new Blob(["hello"]);
|
||||
const transfer = await createToolboxTransfer(
|
||||
{
|
||||
source: { appId: "source" },
|
||||
targetAppId: "target",
|
||||
files: [{ blob, name: "hello.txt", mediaType: "text/plain", size: 5 }],
|
||||
},
|
||||
{ store, crypto: deterministicCrypto, now: () => 100 },
|
||||
);
|
||||
store.values.set(transfer.token, {
|
||||
...transfer,
|
||||
artifactVersion: 99 as 1,
|
||||
});
|
||||
await expect(
|
||||
consumeToolboxTransfer(transfer.token, "target", {
|
||||
store,
|
||||
now: () => 200,
|
||||
}),
|
||||
).rejects.toThrow(/identity is invalid/u);
|
||||
expect(store.values.has(transfer.token)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@
|
||||
import { AppShell } from "@add-ideas/toolbox-shell-react";
|
||||
import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
|
||||
<AppShell app={manifest} appActions={<a href="/help">Help</a>}>
|
||||
<AppShell app={manifest} helpAction={{ onClick: () => setHelpOpen(true) }}>
|
||||
<App />
|
||||
</AppShell>;
|
||||
```
|
||||
@@ -13,3 +13,17 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
|
||||
app switcher is exposed as the accessible navigation landmark
|
||||
`Toolbox applications` and all destinations use ordinary document navigation.
|
||||
The fixed top row exposes a Toolbox home/brand block at the far left, a centered
|
||||
title/subtitle, and Help, Gitea source, Apps, and Personalize at the far right
|
||||
in that order. Personalize uses the portal-style panel and offers
|
||||
light/dark/system mode by default. Version, privacy, and application actions
|
||||
render in the standard footer so connected and standalone headers keep the same
|
||||
geometry.
|
||||
|
||||
`ToolboxHeader` is exported separately for custom shells. Its
|
||||
`personalizeContent` prop puts custom preferences inside the shared controlled
|
||||
popover, while `brandIconUrl` supplies a portal favicon; an inline Toolbox cube
|
||||
is the fallback. `ToolboxPersonalizePanel` shares the portal heading, optional
|
||||
storage warning, and appearance picker with custom preference content.
|
||||
Personalize and Apps popovers are mutually exclusive and close on Escape,
|
||||
outside interaction, or link selection.
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-shell-react",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||
"directory": "packages/shell-react"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||
"keywords": [
|
||||
"toolbox",
|
||||
"react",
|
||||
@@ -38,7 +38,7 @@
|
||||
"react-dom": ">=18 <20"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
||||
@@ -48,6 +48,6 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
||||
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import {
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
contextualizeToolboxLink,
|
||||
loadToolboxContext,
|
||||
readToolboxPreferences,
|
||||
resolveWebUrl,
|
||||
writeToolboxPreferences,
|
||||
type ToolboxAppManifest,
|
||||
type ToolboxContext,
|
||||
type ToolboxContextLoadOptions,
|
||||
type ToolboxError,
|
||||
type ToolboxThemeMode,
|
||||
} from "@add-ideas/toolbox-contract";
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
import {
|
||||
ToolboxHeader,
|
||||
type ToolboxHeaderAction,
|
||||
type ToolboxHeaderApp,
|
||||
} from "./ToolboxHeader.js";
|
||||
|
||||
export interface AppShellProps {
|
||||
app: ToolboxAppManifest;
|
||||
children: ReactNode;
|
||||
helpAction?: ToolboxHeaderAction;
|
||||
appActions?: ReactNode;
|
||||
className?: string;
|
||||
manifestUrl?: string | URL;
|
||||
@@ -25,9 +36,31 @@ function currentHref(): string {
|
||||
: globalThis.location.href;
|
||||
}
|
||||
|
||||
function safeHref(reference: string, base: string | URL): string | undefined {
|
||||
function safeHref(
|
||||
reference: string | URL,
|
||||
base: string | URL,
|
||||
): string | undefined {
|
||||
try {
|
||||
return resolveWebUrl(reference, base).href;
|
||||
return resolveWebUrl(String(reference), base).href;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function browserStorage(): Storage | undefined {
|
||||
try {
|
||||
return globalThis.localStorage;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function initialStoredTheme(): ToolboxThemeMode | undefined {
|
||||
const storage = browserStorage();
|
||||
if (!storage) return undefined;
|
||||
try {
|
||||
if (storage.getItem(TOOLBOX_PREFERENCES_KEY) === null) return undefined;
|
||||
return readToolboxPreferences(storage).theme;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
@@ -51,6 +84,7 @@ function privacyText(app: ToolboxAppManifest): string {
|
||||
export function AppShell({
|
||||
app,
|
||||
children,
|
||||
helpAction,
|
||||
appActions,
|
||||
className,
|
||||
manifestUrl,
|
||||
@@ -61,12 +95,29 @@ export function AppShell({
|
||||
const [contextState, setContextState] = useState<"standalone" | "connected">(
|
||||
"standalone",
|
||||
);
|
||||
const [storedTheme, setStoredTheme] = useState<ToolboxThemeMode | undefined>(
|
||||
initialStoredTheme,
|
||||
);
|
||||
const errorHandler = useRef(onContextError);
|
||||
|
||||
useEffect(() => {
|
||||
errorHandler.current = onContextError;
|
||||
}, [onContextError]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
if (event.key !== TOOLBOX_PREFERENCES_KEY) return;
|
||||
if (event.newValue === null) {
|
||||
setStoredTheme(undefined);
|
||||
return;
|
||||
}
|
||||
const storage = browserStorage();
|
||||
if (storage) setStoredTheme(readToolboxPreferences(storage).theme);
|
||||
};
|
||||
globalThis.addEventListener?.("storage", handleStorage);
|
||||
return () => globalThis.removeEventListener?.("storage", handleStorage);
|
||||
}, []);
|
||||
|
||||
const contextCatalog = contextOptions?.catalogUrl;
|
||||
const contextDocument = contextOptions?.document;
|
||||
const contextFetch = contextOptions?.fetch;
|
||||
@@ -97,9 +148,7 @@ export function AppShell({
|
||||
if (result.status === "error") errorHandler.current?.(result.error);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
controller.abort();
|
||||
};
|
||||
return () => controller.abort();
|
||||
}, [
|
||||
contextCatalog,
|
||||
contextDocument,
|
||||
@@ -112,7 +161,7 @@ export function AppShell({
|
||||
const pageUrl = contextLocation ?? currentHref();
|
||||
const appManifestUrl = useMemo(
|
||||
() =>
|
||||
manifestUrl ??
|
||||
(manifestUrl ? safeHref(manifestUrl, pageUrl) : undefined) ??
|
||||
safeHref("toolbox-app.json", pageUrl) ??
|
||||
"http://localhost/toolbox-app.json",
|
||||
[manifestUrl, pageUrl],
|
||||
@@ -120,112 +169,109 @@ export function AppShell({
|
||||
const privacyHref = app.privacy.url
|
||||
? safeHref(app.privacy.url, appManifestUrl)
|
||||
: undefined;
|
||||
const enabledApps =
|
||||
context?.catalog.apps.filter((entry) => entry.enabled) ?? [];
|
||||
const theme = context?.catalog.catalog.theme.mode ?? "system";
|
||||
const sourceAction = app.actions?.find((action) => action.id === "source");
|
||||
const sourceHref = sourceAction
|
||||
? safeHref(sourceAction.url, appManifestUrl)
|
||||
: app.source?.repository;
|
||||
const otherActions = app.actions?.filter((action) => action.id !== "source");
|
||||
const theme = storedTheme ?? context?.catalog.catalog.theme.mode ?? "system";
|
||||
const rootClassName = ["toolbox-shell", className].filter(Boolean).join(" ");
|
||||
|
||||
const switcherApps: ToolboxHeaderApp[] =
|
||||
context?.catalog.apps.flatMap<ToolboxHeaderApp>((entry) => {
|
||||
if (!entry.enabled) return [];
|
||||
if (entry.kind === "external") {
|
||||
return [
|
||||
{
|
||||
id: `external:${entry.name}:${entry.entryUrl.href}`,
|
||||
name: entry.name,
|
||||
href: entry.entryUrl.href,
|
||||
newTab: entry.launch === "new-tab",
|
||||
},
|
||||
];
|
||||
}
|
||||
const manifest = entry.app.manifest;
|
||||
return [
|
||||
{
|
||||
id: manifest.id,
|
||||
name: manifest.name,
|
||||
href: contextualizeToolboxLink(
|
||||
entry.app.entryUrl,
|
||||
context.catalog.catalogUrl,
|
||||
{ location: pageUrl },
|
||||
),
|
||||
current: manifest.id === app.id,
|
||||
},
|
||||
];
|
||||
}) ?? [];
|
||||
|
||||
function setTheme(themeChoice: ToolboxThemeMode) {
|
||||
setStoredTheme(themeChoice);
|
||||
const storage = browserStorage();
|
||||
if (!storage) return;
|
||||
try {
|
||||
writeToolboxPreferences(
|
||||
{ ...readToolboxPreferences(storage), theme: themeChoice },
|
||||
storage,
|
||||
);
|
||||
} catch {
|
||||
// Keep the in-memory theme if storage is blocked or full.
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={rootClassName}
|
||||
data-toolbox-context={contextState}
|
||||
data-toolbox-theme={theme}
|
||||
>
|
||||
<header className="toolbox-shell__header">
|
||||
<div className="toolbox-shell__bar">
|
||||
{context ? (
|
||||
<a
|
||||
className="toolbox-shell__home"
|
||||
href={context.catalog.homeUrl.href}
|
||||
>
|
||||
{context.catalog.catalog.theme.brand}
|
||||
</a>
|
||||
) : null}
|
||||
|
||||
<div className="toolbox-shell__identity">
|
||||
<img
|
||||
className="toolbox-shell__icon"
|
||||
src={safeHref(app.icon, appManifestUrl)}
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
<div>
|
||||
<h1 className="toolbox-shell__name">{app.name}</h1>
|
||||
<span className="toolbox-shell__description">
|
||||
{app.description}
|
||||
</span>
|
||||
<ToolboxHeader
|
||||
title={app.name}
|
||||
subtitle={app.description}
|
||||
theme={theme}
|
||||
onThemeChange={setTheme}
|
||||
helpAction={helpAction}
|
||||
sourceHref={sourceHref}
|
||||
sourceLabel={`Source for ${app.name} on Gitea`}
|
||||
homeHref={context?.catalog.homeUrl.href}
|
||||
homeLabel="add·ideas Toolbox"
|
||||
brandIconUrl={
|
||||
context
|
||||
? safeHref("favicon.svg", context.catalog.homeUrl.href)
|
||||
: undefined
|
||||
}
|
||||
apps={switcherApps}
|
||||
allAppsHref={context?.catalog.homeUrl.href}
|
||||
/>
|
||||
<main className="toolbox-shell__main">{children}</main>
|
||||
<footer className="toolbox-shell__footer">
|
||||
<div className="toolbox-shell__footer-inner">
|
||||
{otherActions?.length || appActions ? (
|
||||
<div className="toolbox-shell__actions">
|
||||
{otherActions?.map((action) => {
|
||||
const href = safeHref(action.url, appManifestUrl);
|
||||
return href ? (
|
||||
<a key={action.id} href={href}>
|
||||
{action.label}
|
||||
</a>
|
||||
) : null;
|
||||
})}
|
||||
{appActions}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbox-shell__actions">
|
||||
{app.actions?.map((action) => {
|
||||
const href = safeHref(action.url, appManifestUrl);
|
||||
return href ? (
|
||||
<a key={action.id} href={href}>
|
||||
{action.label}
|
||||
</a>
|
||||
) : null;
|
||||
})}
|
||||
{appActions}
|
||||
</div>
|
||||
|
||||
<div className="toolbox-shell__meta">
|
||||
) : (
|
||||
<span aria-hidden="true" />
|
||||
)}
|
||||
<div className="toolbox-shell__footer-meta">
|
||||
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
|
||||
<span aria-hidden="true">·</span>
|
||||
{privacyHref ? (
|
||||
<a href={privacyHref}>{privacyText(app)}</a>
|
||||
) : (
|
||||
<span>{privacyText(app)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{context && enabledApps.length > 0 ? (
|
||||
<details className="toolbox-shell__switcher">
|
||||
<summary>Apps</summary>
|
||||
<nav aria-label="Toolbox applications">
|
||||
<ul>
|
||||
{enabledApps.map((entry) => {
|
||||
if (entry.kind === "external") {
|
||||
return (
|
||||
<li
|
||||
key={`external:${entry.name}:${entry.entryUrl.href}`}
|
||||
>
|
||||
<a
|
||||
href={entry.entryUrl.href}
|
||||
{...(entry.launch === "new-tab"
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
>
|
||||
{entry.name}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
const manifest = entry.app.manifest;
|
||||
return (
|
||||
<li key={manifest.id}>
|
||||
<a
|
||||
href={contextualizeToolboxLink(
|
||||
entry.app.entryUrl,
|
||||
context.catalog.catalogUrl,
|
||||
{ location: pageUrl },
|
||||
)}
|
||||
aria-current={
|
||||
manifest.id === app.id ? "page" : undefined
|
||||
}
|
||||
>
|
||||
{manifest.name}
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
</header>
|
||||
<main className="toolbox-shell__main">{children}</main>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,468 @@
|
||||
import type { ToolboxThemeMode } from "@add-ideas/toolbox-contract";
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
export interface ToolboxHeaderAction {
|
||||
label?: string | undefined;
|
||||
onClick: () => void;
|
||||
title?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ToolboxHeaderApp {
|
||||
id: string;
|
||||
name: string;
|
||||
href: string;
|
||||
current?: boolean | undefined;
|
||||
newTab?: boolean | undefined;
|
||||
}
|
||||
|
||||
export interface ToolboxHeaderProps {
|
||||
title: string;
|
||||
/** @deprecated The fixed header centers text without an application icon. */
|
||||
iconUrl?: string | undefined;
|
||||
subtitle?: string | undefined;
|
||||
theme: ToolboxThemeMode;
|
||||
onThemeChange?: ((theme: ToolboxThemeMode) => void) | undefined;
|
||||
onPersonalize?: (() => void) | undefined;
|
||||
personalizeContent?: ReactNode | undefined;
|
||||
helpAction?: ToolboxHeaderAction | undefined;
|
||||
sourceHref?: string | undefined;
|
||||
sourceLabel?: string | undefined;
|
||||
homeHref?: string | undefined;
|
||||
homeLabel?: string | undefined;
|
||||
brandIconUrl?: string | undefined;
|
||||
apps?: readonly ToolboxHeaderApp[] | undefined;
|
||||
allAppsHref?: string | undefined;
|
||||
/** @deprecated Supplemental actions render outside the fixed top-bar row. */
|
||||
leadingActions?: ReactNode | undefined;
|
||||
/** @deprecated AppShell metadata now renders in its standard footer. */
|
||||
metadata?: ReactNode | undefined;
|
||||
className?: string | undefined;
|
||||
}
|
||||
|
||||
export interface ToolboxPersonalizePanelProps {
|
||||
theme: ToolboxThemeMode;
|
||||
onThemeChange: (theme: ToolboxThemeMode) => void;
|
||||
storageAvailable?: boolean | undefined;
|
||||
children?: ReactNode | undefined;
|
||||
}
|
||||
|
||||
type OpenPopover = "personalize" | "apps" | null;
|
||||
|
||||
function HelpIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M9.8 9a2.35 2.35 0 1 1 3.7 1.92c-.93.65-1.5 1.04-1.5 2.08" />
|
||||
<path d="M12 16.5h.01" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GiteaIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 640 640" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M622.7 149.8c-4.1-4.1-9.6-4-9.6-4s-117.2 6.6-177.9 8c-13.3.3-26.5.6-39.6.7v117.2l-16.6-7.9-.1-109.2c-29 .4-89.2-2.2-89.2-2.2s-141.4-7.1-156.8-8.5c-9.8-.6-22.5-2.1-39 1.5-8.7 1.8-33.5 7.4-53.8 26.9C-4.9 212.4 6.6 276.2 8 285.8c1.7 11.7 6.9 44.2 31.7 72.5 45.8 56.1 144.4 54.8 144.4 54.8s12.1 28.9 30.6 55.5c25 33.1 50.7 58.9 75.7 62 63 0 188.9-.1 188.9-.1s12 .1 28.3-10.3c14-8.5 26.5-23.4 26.5-23.4S547 483 565 451.5c5.5-9.7 10.1-19.1 14.1-28 0 0 55.2-117.1 55.2-231.1-1.1-34.5-9.6-40.6-11.6-42.6ZM125.6 353.9c-25.9-8.5-36.9-18.7-36.9-18.7S69.6 321.8 60 295.4c-16.5-44.2-1.4-71.2-1.4-71.2s8.4-22.5 38.5-30c13.8-3.7 31-3.1 31-3.1s7.1 59.4 15.7 94.2c7.2 29.2 24.8 77.7 24.8 77.7s-26.1-3.1-43-9.1Zm300.3 107.6s-6.1 14.5-19.6 15.4c-5.8.4-10.3-1.2-10.3-1.2s-.3-.1-5.3-2.1l-112.9-55s-10.9-5.7-12.8-15.6c-2.2-8.1 2.7-18.1 2.7-18.1L322 273s4.8-9.7 12.2-13c8.1-3.8 18.2 1.3 18.2 1.3L467.4 315s12.6 5.7 15.3 16.2c1.9 7.4-.5 14-1.8 17.2-6.3 15.4-55 113.1-55 113.1Z"
|
||||
/>
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M326.8 380.1c-8.2.1-15.4 5.8-17.3 13.8s2 16.3 9.1 20c7.7 4 17.5 1.8 22.7-5.4 5.1-7.1 4.3-16.9-1.8-23.1l24-49.1c5.8.4 9.9-4.1 9.9-4.1 4.2 1.8 8.6 3.8 13.2 6.1 4.8 2.4 9.3 4.9 13.4 7.3 5.1 3 11.6 8.7 8.3 20.4-2.3 7.6-18.4 40.6-18.4 40.6-8.1-.2-15.3 5-17.7 12.5-2.6 8.1 1.1 17.3 8.9 21.3s17.4 1.7 22.5-5.3c5-6.8 4.6-16.3-1.1-22.6 7-13.8 19.1-41.7 19.1-41.7.9-1.7 5.7-10.3 2.7-21.3-2.5-11.4-12.6-16.7-12.6-16.7-12.2-7.9-29.2-15.2-29.2-15.2s0-4.1-1.1-7.1c-1.1-3.1-2.8-5.1-3.9-6.3l14.1-29-12.2-6.1-14.5 29.5c-6.7-.1-12.9 3.5-16.1 9.4-3.4 6.3-2.7 14.1 1.9 19.8Z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonalizeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function AppsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||
<rect x="3" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="3" width="7" height="7" rx="1" />
|
||||
<rect x="3" y="14" width="7" height="7" rx="1" />
|
||||
<rect x="14" y="14" width="7" height="7" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolboxMark() {
|
||||
return (
|
||||
<svg viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path
|
||||
d="M7 14.5 24 5l17 9.5v19L24 43 7 33.5z"
|
||||
fill="currentColor"
|
||||
opacity=".12"
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="m8 15 16 9 16-9M24 24v18" />
|
||||
<path d="m16 10.5 16 9v9L24 33l-8-4.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) {
|
||||
const [failedIconUrl, setFailedIconUrl] = useState<string>();
|
||||
|
||||
if (!iconUrl || failedIconUrl === iconUrl) return <ToolboxMark />;
|
||||
return <img src={iconUrl} alt="" onError={() => setFailedIconUrl(iconUrl)} />;
|
||||
}
|
||||
|
||||
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
|
||||
|
||||
export function ToolboxPersonalizePanel({
|
||||
theme,
|
||||
onThemeChange,
|
||||
storageAvailable,
|
||||
children,
|
||||
}: ToolboxPersonalizePanelProps) {
|
||||
const headingId = useId();
|
||||
|
||||
return (
|
||||
<section
|
||||
className="toolbox-shell__preferences-content"
|
||||
aria-labelledby={headingId}
|
||||
>
|
||||
<div className="toolbox-shell__preferences-heading">
|
||||
<p className="toolbox-shell__preferences-kicker">On this device</p>
|
||||
<h2 id={headingId}>Personalize your toolbox</h2>
|
||||
</div>
|
||||
|
||||
{storageAvailable === false ? (
|
||||
<p className="toolbox-shell__preferences-warning" role="status">
|
||||
Browser storage is unavailable. Changes will last only until this page
|
||||
closes.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<fieldset className="toolbox-shell__theme-choice">
|
||||
<legend>Appearance</legend>
|
||||
<div className="toolbox-shell__theme-options">
|
||||
{THEMES.map((option) => (
|
||||
<button
|
||||
key={option}
|
||||
type="button"
|
||||
aria-pressed={theme === option}
|
||||
onClick={() => onThemeChange(option)}
|
||||
>
|
||||
{option.slice(0, 1).toUpperCase() + option.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{children ?? (
|
||||
<p className="toolbox-shell__preferences-copy">
|
||||
The Toolbox stores your appearance choice in this browser and does not
|
||||
transmit it.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ToolboxHeader({
|
||||
title,
|
||||
subtitle,
|
||||
theme,
|
||||
onThemeChange,
|
||||
onPersonalize,
|
||||
personalizeContent,
|
||||
helpAction,
|
||||
sourceHref,
|
||||
sourceLabel = "Source on Gitea",
|
||||
homeHref,
|
||||
homeLabel = "add·ideas Toolbox",
|
||||
brandIconUrl,
|
||||
apps = [],
|
||||
allAppsHref,
|
||||
leadingActions,
|
||||
metadata,
|
||||
className,
|
||||
}: ToolboxHeaderProps) {
|
||||
const [openPopover, setOpenPopover] = useState<OpenPopover>(null);
|
||||
const personalizeButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const appsButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const popoverRef = useRef<HTMLElement | null>(null);
|
||||
const personalizeId = useId();
|
||||
const appsId = useId();
|
||||
const customPersonalizeContent = personalizeContent != null;
|
||||
const hasPersonalizePopover =
|
||||
customPersonalizeContent ||
|
||||
(onPersonalize === undefined && onThemeChange !== undefined);
|
||||
const personalizeAvailable =
|
||||
hasPersonalizePopover || onPersonalize !== undefined;
|
||||
const visiblePopover =
|
||||
openPopover === "personalize" && !hasPersonalizePopover
|
||||
? null
|
||||
: openPopover;
|
||||
|
||||
useEffect(() => {
|
||||
if (visiblePopover === null) return;
|
||||
|
||||
const firstControl = popoverRef.current?.querySelector<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(firstControl ?? popoverRef.current)?.focus();
|
||||
|
||||
const closeFromOutside = (event: PointerEvent) => {
|
||||
const trigger =
|
||||
visiblePopover === "personalize"
|
||||
? personalizeButtonRef.current
|
||||
: appsButtonRef.current;
|
||||
if (!(event.target instanceof Node)) return;
|
||||
if (
|
||||
trigger?.contains(event.target) ||
|
||||
popoverRef.current?.contains(event.target)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setOpenPopover(null);
|
||||
};
|
||||
const closeFromKeyboard = (event: KeyboardEvent) => {
|
||||
if (event.key !== "Escape") return;
|
||||
event.preventDefault();
|
||||
const trigger =
|
||||
visiblePopover === "personalize"
|
||||
? personalizeButtonRef.current
|
||||
: appsButtonRef.current;
|
||||
setOpenPopover(null);
|
||||
trigger?.focus();
|
||||
};
|
||||
|
||||
document.addEventListener("pointerdown", closeFromOutside);
|
||||
document.addEventListener("keydown", closeFromKeyboard);
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", closeFromOutside);
|
||||
document.removeEventListener("keydown", closeFromKeyboard);
|
||||
};
|
||||
}, [visiblePopover]);
|
||||
|
||||
useEffect(() => {
|
||||
if (openPopover !== "personalize" || hasPersonalizePopover) return;
|
||||
const timeout = globalThis.setTimeout(() => setOpenPopover(null), 0);
|
||||
return () => globalThis.clearTimeout(timeout);
|
||||
}, [hasPersonalizePopover, openPopover]);
|
||||
|
||||
const closeOnLinkSelection = (event: MouseEvent<HTMLElement>) => {
|
||||
if ((event.target as Element).closest?.("a")) setOpenPopover(null);
|
||||
};
|
||||
|
||||
const brandContent = (
|
||||
<>
|
||||
<span className="toolbox-shell__brand-mark">
|
||||
<ToolboxBrandMark iconUrl={brandIconUrl} />
|
||||
</span>
|
||||
<span className="toolbox-shell__brand-label">{homeLabel}</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<header
|
||||
className={["toolbox-shell__header", className].filter(Boolean).join(" ")}
|
||||
data-toolbox-theme={theme}
|
||||
>
|
||||
<div className="toolbox-shell__bar">
|
||||
{homeHref ? (
|
||||
<a
|
||||
className="toolbox-shell__brand"
|
||||
href={homeHref}
|
||||
aria-label={homeLabel}
|
||||
onClick={() => setOpenPopover(null)}
|
||||
>
|
||||
{brandContent}
|
||||
</a>
|
||||
) : (
|
||||
<div className="toolbox-shell__brand">{brandContent}</div>
|
||||
)}
|
||||
|
||||
<div className="toolbox-shell__identity">
|
||||
<h1 className="toolbox-shell__name">{title}</h1>
|
||||
{subtitle ? (
|
||||
<span className="toolbox-shell__description">{subtitle}</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="toolbox-shell__controls"
|
||||
role="group"
|
||||
aria-label="Toolbox controls"
|
||||
>
|
||||
<button
|
||||
className="toolbox-shell__icon-button"
|
||||
type="button"
|
||||
aria-label={helpAction?.label ?? "Help"}
|
||||
title={
|
||||
helpAction?.title ??
|
||||
helpAction?.label ??
|
||||
(helpAction ? "Help" : "Help unavailable")
|
||||
}
|
||||
disabled={!helpAction}
|
||||
onClick={() => {
|
||||
setOpenPopover(null);
|
||||
helpAction?.onClick();
|
||||
}}
|
||||
>
|
||||
<HelpIcon />
|
||||
</button>
|
||||
|
||||
{sourceHref ? (
|
||||
<a
|
||||
className="toolbox-shell__icon-button toolbox-shell__source"
|
||||
href={sourceHref}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
aria-label={sourceLabel}
|
||||
title={sourceLabel}
|
||||
onClick={() => setOpenPopover(null)}
|
||||
>
|
||||
<GiteaIcon />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
className="toolbox-shell__icon-button"
|
||||
type="button"
|
||||
aria-label="Source unavailable"
|
||||
title="Source unavailable"
|
||||
disabled
|
||||
>
|
||||
<GiteaIcon />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={appsButtonRef}
|
||||
className="toolbox-shell__menu-button"
|
||||
type="button"
|
||||
aria-label="Apps"
|
||||
title="Apps"
|
||||
aria-expanded={visiblePopover === "apps"}
|
||||
aria-controls={visiblePopover === "apps" ? appsId : undefined}
|
||||
onClick={() =>
|
||||
setOpenPopover((current) => (current === "apps" ? null : "apps"))
|
||||
}
|
||||
>
|
||||
<AppsIcon />
|
||||
<span>Apps</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
ref={personalizeButtonRef}
|
||||
className="toolbox-shell__menu-button"
|
||||
type="button"
|
||||
aria-label="Personalize"
|
||||
title={
|
||||
personalizeAvailable
|
||||
? "Personalize"
|
||||
: "Personalization unavailable"
|
||||
}
|
||||
aria-haspopup={hasPersonalizePopover ? "dialog" : undefined}
|
||||
aria-expanded={
|
||||
hasPersonalizePopover
|
||||
? visiblePopover === "personalize"
|
||||
: undefined
|
||||
}
|
||||
aria-controls={
|
||||
hasPersonalizePopover && visiblePopover === "personalize"
|
||||
? personalizeId
|
||||
: undefined
|
||||
}
|
||||
disabled={!personalizeAvailable}
|
||||
onClick={() => {
|
||||
if (hasPersonalizePopover) {
|
||||
setOpenPopover((current) =>
|
||||
current === "personalize" ? null : "personalize",
|
||||
);
|
||||
} else {
|
||||
onPersonalize?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PersonalizeIcon />
|
||||
<span>Personalize</span>
|
||||
</button>
|
||||
|
||||
{visiblePopover === "personalize" ? (
|
||||
<section
|
||||
ref={popoverRef}
|
||||
id={personalizeId}
|
||||
className="toolbox-shell__popover toolbox-shell__personalize-popover"
|
||||
role="dialog"
|
||||
aria-label="Personalize Toolbox"
|
||||
tabIndex={-1}
|
||||
onClick={closeOnLinkSelection}
|
||||
>
|
||||
{customPersonalizeContent ? (
|
||||
personalizeContent
|
||||
) : (
|
||||
<ToolboxPersonalizePanel
|
||||
theme={theme}
|
||||
onThemeChange={(option) => onThemeChange?.(option)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{visiblePopover === "apps" ? (
|
||||
<nav
|
||||
ref={popoverRef}
|
||||
id={appsId}
|
||||
className="toolbox-shell__popover toolbox-shell__switcher"
|
||||
aria-label="Toolbox applications"
|
||||
tabIndex={-1}
|
||||
onClick={closeOnLinkSelection}
|
||||
>
|
||||
{allAppsHref ? (
|
||||
<a className="toolbox-shell__all-apps" href={allAppsHref}>
|
||||
All apps
|
||||
</a>
|
||||
) : null}
|
||||
{apps.length > 0 ? (
|
||||
<ul>
|
||||
{apps.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<a
|
||||
href={entry.href}
|
||||
aria-current={entry.current ? "page" : undefined}
|
||||
{...(entry.newTab
|
||||
? { target: "_blank", rel: "noreferrer" }
|
||||
: {})}
|
||||
>
|
||||
{entry.name}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p>Open this app from Toolbox to switch tools here.</p>
|
||||
)}
|
||||
</nav>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{leadingActions || metadata ? (
|
||||
<div className="toolbox-shell__supplemental">
|
||||
{leadingActions}
|
||||
{metadata ? (
|
||||
<div className="toolbox-shell__supplemental-meta">{metadata}</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -1,2 +1,9 @@
|
||||
export { AppShell } from "./AppShell.js";
|
||||
export type { AppShellProps } from "./AppShell.js";
|
||||
export { ToolboxHeader, ToolboxPersonalizePanel } from "./ToolboxHeader.js";
|
||||
export type {
|
||||
ToolboxHeaderAction,
|
||||
ToolboxHeaderApp,
|
||||
ToolboxHeaderProps,
|
||||
ToolboxPersonalizePanelProps,
|
||||
} from "./ToolboxHeader.js";
|
||||
|
||||
+518
-123
@@ -1,42 +1,67 @@
|
||||
:root {
|
||||
--toolbox-font:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
Inter, Avenir, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", sans-serif;
|
||||
--toolbox-background: #f5f7fa;
|
||||
--toolbox-background: #f6f7fb;
|
||||
--toolbox-surface: #ffffff;
|
||||
--toolbox-text: #172033;
|
||||
--toolbox-muted: #657086;
|
||||
--toolbox-border: #dce2ea;
|
||||
--toolbox-accent: #3156d3;
|
||||
--toolbox-surface-soft: #eff2f8;
|
||||
--toolbox-text: #17203b;
|
||||
--toolbox-muted: #667085;
|
||||
--toolbox-border: #dfe3ec;
|
||||
--toolbox-accent: #28366d;
|
||||
--toolbox-accent-hover: #1c2858;
|
||||
--toolbox-accent-soft: #e9edff;
|
||||
--toolbox-accent-contrast: #ffffff;
|
||||
--toolbox-radius: 0.65rem;
|
||||
--toolbox-shadow: 0 1px 2px rgb(18 28 45 / 8%);
|
||||
--toolbox-focus: #63cdbc;
|
||||
--toolbox-danger: #b4233a;
|
||||
--toolbox-radius: 0.7rem;
|
||||
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
|
||||
}
|
||||
|
||||
[data-toolbox-theme="light"] {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
[data-toolbox-theme="dark"] {
|
||||
--toolbox-background: #111722;
|
||||
--toolbox-surface: #192130;
|
||||
--toolbox-text: #edf1f7;
|
||||
--toolbox-muted: #aab4c5;
|
||||
--toolbox-border: #303b4d;
|
||||
--toolbox-accent: #9db2ff;
|
||||
--toolbox-accent-contrast: #111722;
|
||||
color-scheme: dark;
|
||||
--toolbox-background: #0f1422;
|
||||
--toolbox-surface: #171d2d;
|
||||
--toolbox-surface-soft: #20283a;
|
||||
--toolbox-text: #edf1fb;
|
||||
--toolbox-muted: #a9b3ca;
|
||||
--toolbox-border: #303a50;
|
||||
--toolbox-accent: #a9b7ff;
|
||||
--toolbox-accent-hover: #c3ccff;
|
||||
--toolbox-accent-soft: #252f55;
|
||||
--toolbox-accent-contrast: #11182a;
|
||||
--toolbox-focus: #66cdbd;
|
||||
--toolbox-danger: #ff8fa3;
|
||||
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-toolbox-theme="system"] {
|
||||
--toolbox-background: #111722;
|
||||
--toolbox-surface: #192130;
|
||||
--toolbox-text: #edf1f7;
|
||||
--toolbox-muted: #aab4c5;
|
||||
--toolbox-border: #303b4d;
|
||||
--toolbox-accent: #9db2ff;
|
||||
--toolbox-accent-contrast: #111722;
|
||||
color-scheme: dark;
|
||||
--toolbox-background: #0f1422;
|
||||
--toolbox-surface: #171d2d;
|
||||
--toolbox-surface-soft: #20283a;
|
||||
--toolbox-text: #edf1fb;
|
||||
--toolbox-muted: #a9b3ca;
|
||||
--toolbox-border: #303a50;
|
||||
--toolbox-accent: #a9b7ff;
|
||||
--toolbox-accent-hover: #c3ccff;
|
||||
--toolbox-accent-soft: #252f55;
|
||||
--toolbox-accent-contrast: #11182a;
|
||||
--toolbox-focus: #66cdbd;
|
||||
--toolbox-danger: #ff8fa3;
|
||||
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
|
||||
}
|
||||
}
|
||||
|
||||
.toolbox-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--toolbox-text);
|
||||
background: var(--toolbox-background);
|
||||
font-family: var(--toolbox-font);
|
||||
@@ -44,116 +69,345 @@
|
||||
|
||||
.toolbox-shell *,
|
||||
.toolbox-shell *::before,
|
||||
.toolbox-shell *::after {
|
||||
.toolbox-shell *::after,
|
||||
.toolbox-shell__header *,
|
||||
.toolbox-shell__header *::before,
|
||||
.toolbox-shell__header *::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.toolbox-shell__header {
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: var(--toolbox-shadow);
|
||||
position: sticky;
|
||||
z-index: 40;
|
||||
top: 0;
|
||||
color: var(--toolbox-text);
|
||||
border-bottom: 1px solid
|
||||
color-mix(in srgb, var(--toolbox-border) 75%, transparent);
|
||||
background: color-mix(in srgb, var(--toolbox-surface) 92%, transparent);
|
||||
box-shadow: 0 1px 0 rgb(24 34 68 / 3%);
|
||||
font-family: var(--toolbox-font);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.toolbox-shell__bar {
|
||||
display: flex;
|
||||
min-height: 4.25rem;
|
||||
width: min(100%, 90rem);
|
||||
height: 4.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax(
|
||||
18.75rem,
|
||||
1fr
|
||||
);
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
max-width: 90rem;
|
||||
margin: 0 auto;
|
||||
padding: 0.75rem 1rem;
|
||||
padding: 0.65rem 1rem;
|
||||
}
|
||||
|
||||
.toolbox-shell a {
|
||||
.toolbox-shell__controls {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.toolbox-shell__header a {
|
||||
color: var(--toolbox-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbox-shell a:hover {
|
||||
text-decoration: underline;
|
||||
.toolbox-shell__header a:hover {
|
||||
color: var(--toolbox-accent-hover);
|
||||
}
|
||||
|
||||
.toolbox-shell__home {
|
||||
flex: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbox-shell__identity {
|
||||
display: flex;
|
||||
min-width: 12rem;
|
||||
.toolbox-shell__icon-button,
|
||||
.toolbox-shell__menu-button,
|
||||
.toolbox-shell__supplemental a {
|
||||
min-height: 2.5rem;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__identity > div {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon {
|
||||
flex: none;
|
||||
border-radius: 0.35rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.toolbox-shell__description {
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.8rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.toolbox-shell__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toolbox-shell__meta {
|
||||
display: grid;
|
||||
flex: none;
|
||||
gap: 0.15rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.75rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher {
|
||||
position: relative;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher summary {
|
||||
padding: 0.45rem 0.7rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: var(--toolbox-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher nav {
|
||||
position: absolute;
|
||||
top: calc(100% + 0.5rem);
|
||||
right: 0;
|
||||
width: max-content;
|
||||
min-width: 12rem;
|
||||
padding: 0.4rem;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.68rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: var(--toolbox-radius);
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: 0 0.75rem 2rem rgb(18 28 45 / 14%);
|
||||
color: var(--toolbox-text);
|
||||
font: inherit;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 650;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button {
|
||||
width: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button:hover,
|
||||
.toolbox-shell__menu-button:hover,
|
||||
.toolbox-shell__supplemental a:hover {
|
||||
border-color: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-accent) 45%,
|
||||
var(--toolbox-border)
|
||||
);
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button:disabled,
|
||||
.toolbox-shell__menu-button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.48;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button:focus-visible,
|
||||
.toolbox-shell__menu-button:focus-visible,
|
||||
.toolbox-shell__popover a:focus-visible,
|
||||
.toolbox-shell__popover button:focus-visible,
|
||||
.toolbox-shell__brand:focus-visible {
|
||||
outline: 3px solid
|
||||
color-mix(in srgb, var(--toolbox-focus) 72%, var(--toolbox-accent));
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button svg,
|
||||
.toolbox-shell__menu-button svg {
|
||||
width: 1.05rem;
|
||||
height: 1.05rem;
|
||||
flex: none;
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.8;
|
||||
}
|
||||
|
||||
.toolbox-shell__source svg {
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
fill: currentColor;
|
||||
stroke: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__source {
|
||||
color: #609926 !important;
|
||||
}
|
||||
|
||||
.toolbox-shell__identity {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 0.12rem;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.toolbox-shell__name {
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-text);
|
||||
font-family: var(--toolbox-font);
|
||||
font-size: 1rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.2;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbox-shell__description {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.toolbox-shell__brand {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-self: start;
|
||||
gap: 0.58rem;
|
||||
padding: 0.28rem 0.35rem;
|
||||
border-radius: var(--toolbox-radius);
|
||||
color: var(--toolbox-text) !important;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 750;
|
||||
line-height: 1.1;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
a.toolbox-shell__brand:hover {
|
||||
background: var(--toolbox-accent-soft);
|
||||
color: var(--toolbox-accent) !important;
|
||||
}
|
||||
|
||||
.toolbox-shell__brand-mark {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: inline-grid;
|
||||
flex: none;
|
||||
place-items: center;
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.toolbox-shell__brand-mark img,
|
||||
.toolbox-shell__brand-mark svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.toolbox-shell__brand-mark svg {
|
||||
fill: none;
|
||||
stroke: currentColor;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 3;
|
||||
}
|
||||
|
||||
.toolbox-shell__popover {
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
top: calc(100% + 0.7rem);
|
||||
right: 0;
|
||||
left: auto;
|
||||
width: max-content;
|
||||
min-width: 13rem;
|
||||
max-width: min(22rem, calc(100vw - 1.5rem));
|
||||
max-height: calc(100vh - 5.5rem);
|
||||
overflow: auto;
|
||||
padding: 0.55rem;
|
||||
border: 1px solid var(--toolbox-border);
|
||||
border-radius: 0.85rem;
|
||||
background: var(--toolbox-surface);
|
||||
box-shadow: var(--toolbox-shadow);
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
.toolbox-shell__personalize-popover {
|
||||
width: min(26rem, calc(100vw - 1.5rem));
|
||||
max-width: min(26rem, calc(100vw - 1.5rem));
|
||||
}
|
||||
|
||||
.toolbox-shell__popover > strong {
|
||||
display: block;
|
||||
padding: 0.35rem 0.4rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.toolbox-shell__popover > small,
|
||||
.toolbox-shell__switcher > p {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.4rem 0.25rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.73rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-content {
|
||||
padding: 0.35rem;
|
||||
color: var(--toolbox-text);
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-heading {
|
||||
margin-bottom: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-content h2 {
|
||||
margin: 0;
|
||||
color: var(--toolbox-text);
|
||||
font-family: var(--toolbox-font);
|
||||
font-size: 1rem;
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-content .toolbox-shell__preferences-kicker {
|
||||
margin: 0 0 0.3rem;
|
||||
padding: 0;
|
||||
color: var(--toolbox-accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.4;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbox-shell__theme-choice {
|
||||
margin: 1rem 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.toolbox-shell__theme-choice legend {
|
||||
margin-bottom: 0.6rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.toolbox-shell__theme-options {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 0.45rem;
|
||||
padding: 0.35rem;
|
||||
border-radius: 0.75rem;
|
||||
background: var(--toolbox-surface-soft);
|
||||
}
|
||||
|
||||
.toolbox-shell__theme-options button {
|
||||
padding: 0.62rem;
|
||||
border: 0;
|
||||
border-radius: 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--toolbox-text);
|
||||
font: inherit;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.toolbox-shell__theme-options button[aria-pressed="true"] {
|
||||
background: var(--toolbox-surface);
|
||||
color: var(--toolbox-accent);
|
||||
box-shadow: 0 2px 8px rgb(20 30 60 / 9%);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-content .toolbox-shell__preferences-copy {
|
||||
margin: 0.9rem 0 0;
|
||||
padding: 0;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.toolbox-shell__preferences-content .toolbox-shell__preferences-warning {
|
||||
margin: 0 0 0.75rem;
|
||||
padding: 0.7rem 0.8rem;
|
||||
border-radius: 0.6rem;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--toolbox-danger) 8%,
|
||||
var(--toolbox-surface)
|
||||
);
|
||||
color: var(--toolbox-danger);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher ul {
|
||||
@@ -166,8 +420,13 @@
|
||||
|
||||
.toolbox-shell__switcher a {
|
||||
display: block;
|
||||
padding: 0.45rem 0.6rem;
|
||||
border-radius: 0.4rem;
|
||||
padding: 0.55rem 0.65rem;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher a:hover {
|
||||
background: var(--toolbox-accent-soft);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.toolbox-shell__switcher a[aria-current="page"] {
|
||||
@@ -175,28 +434,164 @@
|
||||
background: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
.toolbox-shell__all-apps {
|
||||
margin-bottom: 0.35rem;
|
||||
border-bottom: 1px solid var(--toolbox-border);
|
||||
border-radius: 0 !important;
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.toolbox-shell__supplemental {
|
||||
min-height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.65rem;
|
||||
padding: 0.3rem 1rem 0.45rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__supplemental-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__main {
|
||||
width: min(100%, 90rem);
|
||||
display: block;
|
||||
flex: 1 0 auto;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 52rem) {
|
||||
.toolbox-shell__footer {
|
||||
width: min(100%, 90rem);
|
||||
margin: auto auto 0;
|
||||
padding: 0.35rem 1rem 1.25rem;
|
||||
color: var(--toolbox-muted);
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__footer-inner {
|
||||
min-height: 2.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem 1.25rem;
|
||||
padding-top: 0.7rem;
|
||||
border-top: 1px solid var(--toolbox-border);
|
||||
}
|
||||
|
||||
.toolbox-shell__footer-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 0.45rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.toolbox-shell__footer a {
|
||||
color: var(--toolbox-accent);
|
||||
}
|
||||
|
||||
@media (max-width: 72rem) {
|
||||
.toolbox-shell__bar {
|
||||
flex-wrap: wrap;
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr) minmax(0, min(31rem, calc(100% - 24rem)))
|
||||
minmax(0, 1fr);
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__identity {
|
||||
flex: 1;
|
||||
.toolbox-shell__menu-button {
|
||||
width: 2.5rem;
|
||||
min-width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0.45rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__actions {
|
||||
order: 3;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbox-shell__meta {
|
||||
.toolbox-shell__menu-button > span,
|
||||
.toolbox-shell__description {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 40rem) {
|
||||
.toolbox-shell__bar {
|
||||
gap: 0.45rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__controls {
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__name {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__footer-inner {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toolbox-shell__footer-meta {
|
||||
justify-content: flex-start;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 32rem) {
|
||||
.toolbox-shell__brand-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 27rem) {
|
||||
.toolbox-shell__bar {
|
||||
height: 4.1rem;
|
||||
grid-template-columns:
|
||||
minmax(0, 1fr) minmax(0, calc(100vw - 17.9rem))
|
||||
minmax(0, 1fr);
|
||||
gap: 0.2rem;
|
||||
padding-inline: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__controls {
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__icon-button,
|
||||
.toolbox-shell__menu-button {
|
||||
width: 2rem;
|
||||
min-width: 2rem;
|
||||
height: 2rem;
|
||||
min-height: 2rem;
|
||||
padding: 0.3rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__brand {
|
||||
padding-inline: 0.1rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__brand-mark {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__name {
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.toolbox-shell__popover {
|
||||
max-width: calc(100vw - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ToolboxAppManifest } from "@add-ideas/toolbox-contract";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within,
|
||||
} from "@testing-library/react";
|
||||
import {
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
type ToolboxAppManifest,
|
||||
} from "@add-ideas/toolbox-contract";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { AppShell } from "../src/index.js";
|
||||
import {
|
||||
AppShell,
|
||||
ToolboxHeader,
|
||||
ToolboxPersonalizePanel,
|
||||
} from "../src/index.js";
|
||||
|
||||
const currentApp: ToolboxAppManifest = {
|
||||
schemaVersion: 1,
|
||||
@@ -25,6 +38,10 @@ const currentApp: ToolboxAppManifest = {
|
||||
indexedDb: true,
|
||||
crossOriginIsolated: false,
|
||||
},
|
||||
capabilities: {
|
||||
required: ["workers"],
|
||||
optional: [],
|
||||
},
|
||||
privacy: {
|
||||
processing: "local",
|
||||
fileUploads: false,
|
||||
@@ -76,11 +93,15 @@ function catalogFetch() {
|
||||
}
|
||||
|
||||
describe("AppShell", () => {
|
||||
beforeEach(() => localStorage.clear());
|
||||
|
||||
it("renders identity, privacy, version, actions, and content standalone", async () => {
|
||||
const onHelp = vi.fn();
|
||||
const fetch = vi.fn();
|
||||
render(
|
||||
<AppShell
|
||||
app={currentApp}
|
||||
helpAction={{ onClick: onHelp }}
|
||||
appActions={<a href="/help">Help</a>}
|
||||
contextOptions={{
|
||||
location: "https://tools.example.test/pdf/",
|
||||
@@ -105,9 +126,50 @@ describe("AppShell", () => {
|
||||
expect(
|
||||
document.querySelector("[data-toolbox-context='standalone']"),
|
||||
).toBeInTheDocument();
|
||||
const controls = screen.getByLabelText("Toolbox controls");
|
||||
expect(
|
||||
Array.from(controls.children, (element) =>
|
||||
element.getAttribute("aria-label"),
|
||||
),
|
||||
).toEqual([
|
||||
"Help",
|
||||
"Source for PDF Workbench on Gitea",
|
||||
"Apps",
|
||||
"Personalize",
|
||||
]);
|
||||
expect(
|
||||
Array.from(
|
||||
document.querySelector(".toolbox-shell__bar")?.children ?? [],
|
||||
(element) => element.className,
|
||||
),
|
||||
).toEqual([
|
||||
"toolbox-shell__brand",
|
||||
"toolbox-shell__identity",
|
||||
"toolbox-shell__controls",
|
||||
]);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||
expect(
|
||||
screen.getByRole("navigation", { name: "Toolbox applications" }),
|
||||
).toHaveTextContent("Open this app from Toolbox");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /Source for PDF Workbench/u }),
|
||||
).toHaveAttribute("href", "https://git.example.test/pdf-tools");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Help" }));
|
||||
expect(onHelp).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Personalize" }),
|
||||
).toHaveTextContent("Personalize");
|
||||
expect(screen.getByRole("button", { name: "Apps" })).toHaveTextContent(
|
||||
"Apps",
|
||||
);
|
||||
expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
|
||||
expect(screen.getByText("add·ideas Toolbox").closest("a")).toBeNull();
|
||||
expect(document.querySelector(".toolbox-shell__footer")).toContainElement(
|
||||
screen.getByLabelText("Version 1.2.3"),
|
||||
);
|
||||
});
|
||||
|
||||
it("loads same-origin context and creates full-page contextual switch links", async () => {
|
||||
@@ -126,7 +188,15 @@ describe("AppShell", () => {
|
||||
</AppShell>,
|
||||
);
|
||||
|
||||
const navigation = await screen.findByRole("navigation", {
|
||||
await waitFor(
|
||||
() =>
|
||||
expect(
|
||||
document.querySelector("[data-toolbox-context='connected']"),
|
||||
).toBeInTheDocument(),
|
||||
{ timeout: 4_000 },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Toolbox applications",
|
||||
});
|
||||
expect(navigation).toBeInTheDocument();
|
||||
@@ -138,10 +208,14 @@ describe("AppShell", () => {
|
||||
expect(destination.searchParams.get("toolbox")).toBe(
|
||||
"https://tools.example.test/toolbox.catalog.json",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute(
|
||||
"href",
|
||||
"https://tools.example.test/",
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "add·ideas Toolbox" }),
|
||||
).toHaveAttribute("href", "https://tools.example.test/");
|
||||
expect(
|
||||
screen
|
||||
.getByRole("link", { name: "add·ideas Toolbox" })
|
||||
.querySelector("img"),
|
||||
).toHaveAttribute("src", "https://tools.example.test/favicon.svg");
|
||||
expect(
|
||||
document.querySelector("[data-toolbox-context='connected']"),
|
||||
).toBeInTheDocument();
|
||||
@@ -169,9 +243,10 @@ describe("AppShell", () => {
|
||||
code: "cross-origin",
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
||||
).not.toBeInTheDocument();
|
||||
screen.getByRole("navigation", { name: "Toolbox applications" }),
|
||||
).toHaveTextContent("Open this app from Toolbox");
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -199,4 +274,213 @@ describe("AppShell", () => {
|
||||
document.querySelector("[data-toolbox-context='standalone']"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses and updates the shared light, dark, and system preference", async () => {
|
||||
localStorage.setItem(
|
||||
TOOLBOX_PREFERENCES_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
pinned: [],
|
||||
order: [],
|
||||
hidden: [],
|
||||
theme: "dark",
|
||||
}),
|
||||
);
|
||||
render(
|
||||
<AppShell
|
||||
app={currentApp}
|
||||
contextOptions={{ location: "https://tools.example.test/pdf/" }}
|
||||
>
|
||||
PDF
|
||||
</AppShell>,
|
||||
);
|
||||
|
||||
const shell = document.querySelector(".toolbox-shell");
|
||||
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("On this device")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("group", { name: "Appearance" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute(
|
||||
"aria-pressed",
|
||||
"true",
|
||||
);
|
||||
expect(screen.queryByText("Pinned")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "Export JSON" }),
|
||||
).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Light" }));
|
||||
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
|
||||
expect(
|
||||
JSON.parse(localStorage.getItem(TOOLBOX_PREFERENCES_KEY) ?? "{}"),
|
||||
).toMatchObject({ theme: "light" });
|
||||
});
|
||||
|
||||
it("shares the portal-style personalization frame and extension slot", () => {
|
||||
const onThemeChange = vi.fn();
|
||||
render(
|
||||
<ToolboxPersonalizePanel
|
||||
theme="system"
|
||||
onThemeChange={onThemeChange}
|
||||
storageAvailable={false}
|
||||
>
|
||||
<p>Portal preference actions</p>
|
||||
</ToolboxPersonalizePanel>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "Personalize your toolbox" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Browser storage is unavailable",
|
||||
);
|
||||
expect(screen.getByText("Portal preference actions")).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dark" }));
|
||||
expect(onThemeChange).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("resolves manifest-relative header links without rendering an app icon", async () => {
|
||||
render(
|
||||
<AppShell
|
||||
app={{
|
||||
...currentApp,
|
||||
actions: [
|
||||
{
|
||||
id: "source",
|
||||
label: "Source",
|
||||
url: "./source/",
|
||||
},
|
||||
],
|
||||
}}
|
||||
manifestUrl="./toolbox-app.json"
|
||||
contextOptions={{
|
||||
location: "https://tools.example.test/nested/pdf/index.html",
|
||||
}}
|
||||
>
|
||||
PDF
|
||||
</AppShell>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Source for PDF Workbench on Gitea" }),
|
||||
).toHaveAttribute("href", "https://tools.example.test/nested/pdf/source/");
|
||||
expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
|
||||
});
|
||||
|
||||
it("uses one accessible controlled popover for Personalize and Apps", () => {
|
||||
const onHelp = vi.fn();
|
||||
render(
|
||||
<ToolboxHeader
|
||||
title="PDF Workbench"
|
||||
subtitle="Local PDF tools"
|
||||
theme="system"
|
||||
personalizeContent={<a href="#preferences">Full preferences</a>}
|
||||
helpAction={{ onClick: onHelp }}
|
||||
sourceHref="https://git.example.test/pdf-tools"
|
||||
apps={[
|
||||
{
|
||||
id: "xslt",
|
||||
name: "XSLT Workbench",
|
||||
href: "#xslt",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const personalize = screen.getByRole("button", { name: "Personalize" });
|
||||
const apps = screen.getByRole("button", { name: "Apps" });
|
||||
expect(
|
||||
screen.getByRole("group", { name: "Toolbox controls" }),
|
||||
).toBeInTheDocument();
|
||||
expect(personalize).toHaveAttribute("aria-label", "Personalize");
|
||||
expect(apps).toHaveAttribute("aria-label", "Apps");
|
||||
expect(apps).not.toHaveAttribute("aria-haspopup");
|
||||
|
||||
fireEvent.click(personalize);
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).toHaveTextContent("Full preferences");
|
||||
expect(personalize).toHaveAttribute("aria-expanded", "true");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Full preferences" }),
|
||||
).toHaveFocus();
|
||||
|
||||
fireEvent.click(apps);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).not.toBeInTheDocument();
|
||||
const navigation = screen.getByRole("navigation", {
|
||||
name: "Toolbox applications",
|
||||
});
|
||||
expect(navigation).toBeInTheDocument();
|
||||
expect(
|
||||
within(navigation).getByRole("link", { name: "XSLT Workbench" }),
|
||||
).toHaveFocus();
|
||||
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(apps).toHaveFocus();
|
||||
|
||||
fireEvent.click(personalize);
|
||||
fireEvent.pointerDown(document.body);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(apps);
|
||||
fireEvent.click(
|
||||
within(
|
||||
screen.getByRole("navigation", { name: "Toolbox applications" }),
|
||||
).getByRole("link", { name: "XSLT Workbench" }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Toolbox applications" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the legacy onPersonalize callback when no popover content exists", () => {
|
||||
const onPersonalize = vi.fn();
|
||||
const onThemeChange = vi.fn();
|
||||
render(
|
||||
<ToolboxHeader
|
||||
title="PDF Workbench"
|
||||
theme="system"
|
||||
onPersonalize={onPersonalize}
|
||||
onThemeChange={onThemeChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
||||
expect(onPersonalize).toHaveBeenCalledOnce();
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("closes Personalize if its content becomes unavailable", () => {
|
||||
const { rerender } = render(
|
||||
<ToolboxHeader
|
||||
title="PDF Workbench"
|
||||
theme="system"
|
||||
onThemeChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
|
||||
expect(
|
||||
screen.getByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
rerender(<ToolboxHeader title="PDF Workbench" theme="system" />);
|
||||
expect(
|
||||
screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-testkit",
|
||||
"version": "0.1.1",
|
||||
"version": "0.3.0",
|
||||
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "git+https://git.add-ideas.de/zemion/toolbox-sdk.git",
|
||||
"url": "git+https://git.add-ideas.de/lotobo/toolbox-sdk.git",
|
||||
"directory": "packages/testkit"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk",
|
||||
"homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
|
||||
"keywords": [
|
||||
"toolbox",
|
||||
"manifest",
|
||||
@@ -37,7 +37,7 @@
|
||||
},
|
||||
"types": "./dist/index.d.ts",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",
|
||||
@@ -47,6 +47,6 @@
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/"
|
||||
"registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
|
||||
"title": "Toolbox application manifest v1",
|
||||
"type": "object",
|
||||
"required": [
|
||||
@@ -116,6 +116,24 @@
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"io": {
|
||||
"type": "object",
|
||||
"required": ["accepts", "produces"],
|
||||
"properties": {
|
||||
"accepts": { "$ref": "#/$defs/formats" },
|
||||
"produces": { "$ref": "#/$defs/formats" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"required": ["required", "optional"],
|
||||
"properties": {
|
||||
"required": { "$ref": "#/$defs/identifierList" },
|
||||
"optional": { "$ref": "#/$defs/identifierList" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
},
|
||||
"source": {
|
||||
"type": "object",
|
||||
"required": ["repository", "license"],
|
||||
@@ -157,6 +175,60 @@
|
||||
"uniqueItems": true
|
||||
}
|
||||
},
|
||||
"allOf": [
|
||||
{
|
||||
"if": {
|
||||
"required": ["requirements", "capabilities"],
|
||||
"properties": {
|
||||
"requirements": {
|
||||
"type": "object",
|
||||
"required": ["workers"],
|
||||
"properties": { "workers": { "const": true } }
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"required": ["required"],
|
||||
"properties": {
|
||||
"required": {
|
||||
"type": "array",
|
||||
"contains": { "const": "workers" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"if": {
|
||||
"required": ["capabilities"],
|
||||
"properties": {
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"required": ["required"],
|
||||
"properties": {
|
||||
"required": {
|
||||
"type": "array",
|
||||
"contains": { "const": "workers" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"then": {
|
||||
"properties": {
|
||||
"requirements": {
|
||||
"type": "object",
|
||||
"required": ["workers"],
|
||||
"properties": { "workers": { "const": true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"$defs": {
|
||||
"urlReference": {
|
||||
"type": "string",
|
||||
@@ -218,6 +290,37 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/nonEmptyString"
|
||||
}
|
||||
},
|
||||
"identifierList": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-z0-9]+(?:[._-][a-z0-9]+)*$"
|
||||
}
|
||||
},
|
||||
"formats": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["mediaType", "extensions"],
|
||||
"properties": {
|
||||
"mediaType": {
|
||||
"type": "string",
|
||||
"pattern": "^(?:\\*|[A-Za-z0-9!#$&^_.+-]+)/(?:\\*|[A-Za-z0-9!#$&^_.+-]+)$"
|
||||
},
|
||||
"extensions": {
|
||||
"type": "array",
|
||||
"uniqueItems": true,
|
||||
"items": {
|
||||
"type": "string",
|
||||
"pattern": "^\\.[A-Za-z0-9][A-Za-z0-9._+-]*$"
|
||||
}
|
||||
},
|
||||
"label": { "$ref": "#/$defs/nonEmptyString" }
|
||||
},
|
||||
"additionalProperties": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://git.add-ideas.de/zemion/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||
"$id": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-catalog.v1.schema.json",
|
||||
"title": "Toolbox catalog v1",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"],
|
||||
|
||||
Reference in New Issue
Block a user