5 Commits
Author SHA1 Message Date
zemion bc91659a42 Release Toolbox SDK 0.3.0
Verify / verify (push) Canceled after 0s
2026-09-02 00:58:50 +02:00
zemion ef2dab4b46 chore: migrate SDK identity to LocalToolBox 2026-07-27 15:36:07 +02:00
zemion 53c40a61ba fix: reverse Toolbox header orientation 2026-07-23 01:47:55 +02:00
zemion c296281f89 fix: stabilize Toolbox navigation layout 2026-07-23 01:10:18 +02:00
zemion a058858b50 feat: add shared toolbox header and appearance 2026-07-23 00:11:13 +02:00
30 changed files with 2943 additions and 273 deletions
+35
View File
@@ -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
View File
@@ -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/
+81
View File
@@ -3,6 +3,87 @@
All notable changes to the independently versioned Toolbox SDK packages are All notable changes to the independently versioned Toolbox SDK packages are
recorded here. The packages currently share one release version. 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 ## 0.1.1 — 2026-07-20
### Fixed ### Fixed
+67 -12
View File
@@ -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 catalog URL, the same app gains a toolbox home link and an app switcher without
becoming coupled to a portal or client-side router. 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 - `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
discovery/loading, resolved URLs, and contextual link helpers. 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 ```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, "schemaVersion": 1,
"id": "de.add-ideas.pdf-tools", "id": "de.add-ideas.pdf-tools",
"name": "PDF Workbench", "name": "PDF Workbench",
@@ -57,24 +57,41 @@ Deploy `toolbox-app.json` beside the application entry. The canonical schema is
"indexedDb": true, "indexedDb": true,
"crossOriginIsolated": false "crossOriginIsolated": false
}, },
"io": {
"accepts": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }],
"produces": [{ "mediaType": "application/pdf", "extensions": [".pdf"] }]
},
"capabilities": {
"required": ["workers"],
"optional": ["file-system-access"]
},
"privacy": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": false,
"telemetry": false "telemetry": false
}, },
"source": { "source": {
"repository": "https://git.add-ideas.de/zemion/pdf-tools", "repository": "https://git.add-ideas.de/lotobo/pdf-tools",
"license": "AGPL-3.0-only" "license": "AGPL-3.0-only"
} }
} }
``` ```
`source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, `source`, `privacy.label`, `privacy.url`, `requirements.topLevelContext`, `io`,
`actions`, and `assets` are optional v1 additions. `toolbox-check` verifies the `capabilities`, `actions`, and `assets` are optional v1 additions. `io`
entry, icon, and every declared asset. Runtime parsers validate every known 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 field, require `schemaVersion: 1`, and deliberately discard unknown fields so
future optional additions do not break v1 consumers. 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: For typed source definitions, use the literal-preserving identity helper:
```ts ```ts
@@ -88,6 +105,24 @@ export const manifest = defineToolboxApp({
Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is Use `parseToolboxApp(unknownValue)` at trust boundaries; `defineToolboxApp()` is
compile-time only and does not replace runtime parsing. 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 ## Catalog v1
The canonical schema is The canonical schema is
@@ -160,23 +195,41 @@ import "@add-ideas/toolbox-shell-react/styles.css";
<AppShell <AppShell
app={manifest} app={manifest}
manifestUrl="/toolbox-app.json" manifestUrl="/toolbox-app.json"
appActions={<a href="./help/">Help</a>} helpAction={{ onClick: () => setHelpOpen(true) }}
onContextError={(error) => console.warn(error)} onContextError={(error) => console.warn(error)}
> >
<Application /> <Application />
</AppShell>; </AppShell>;
``` ```
The shell always renders app identity as an `h1`, version, derived privacy The shell's fixed top row renders the Toolbox brand/home at the far left, a
facts, manifest actions, and `appActions`. With valid context it additionally geometrically centered text identity, and Help, source, Apps, and Personalize
renders the catalog brand/home and a switcher at the accessible navigation controls at the far right in that order. Personalize and Apps have labeled
landmark `Toolbox applications`. Destinations are ordinary links, so switching 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 performs full-page navigation. On missing, cross-origin, invalid, or unavailable
context the shell quietly remains usable in standalone mode; `onContextError` is context the shell quietly remains usable in standalone mode; `onContextError` is
optional observability. 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 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 ## Validate a build
@@ -210,6 +263,8 @@ The main exports are:
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`. `fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and - URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
`contextualizeToolboxLink()`. `contextualizeToolboxLink()`.
- Preferences: `parseToolboxPreferences()`, `readToolboxPreferences()`,
`writeToolboxPreferences()`, and `TOOLBOX_PREFERENCES_KEY`.
See each emitted `.d.ts` file for the complete signatures. See each emitted `.d.ts` file for the complete signatures.
+2 -2
View File
@@ -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, "schemaVersion": 1,
"id": "de.add-ideas.example-tool", "id": "de.add-ideas.example-tool",
"name": "Example Tool", "name": "Example Tool",
@@ -26,7 +26,7 @@
"telemetry": false "telemetry": false
}, },
"source": { "source": {
"repository": "https://git.add-ideas.de/zemion/toolbox-sdk", "repository": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"license": "Apache-2.0" "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, "schemaVersion": 1,
"id": "de.add-ideas.example-toolbox", "id": "de.add-ideas.example-toolbox",
"name": "Example Toolbox", "name": "Example Toolbox",
+1 -1
View File
@@ -27,7 +27,7 @@ export const toolboxApp = defineToolboxApp({
telemetry: false, telemetry: false,
}, },
source: { source: {
repository: "https://git.add-ideas.de/zemion/toolbox-sdk", repository: "https://git.add-ideas.de/lotobo/toolbox-sdk",
license: "Apache-2.0", license: "Apache-2.0",
}, },
}); });
+7 -7
View File
@@ -1,12 +1,12 @@
{ {
"name": "@add-ideas/toolbox-sdk-workspace", "name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.1", "version": "0.3.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@add-ideas/toolbox-sdk-workspace", "name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.1", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
@@ -3946,7 +3946,7 @@
}, },
"packages/contract": { "packages/contract": {
"name": "@add-ideas/toolbox-contract", "name": "@add-ideas/toolbox-contract",
"version": "0.1.1", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=20" "node": ">=20"
@@ -3954,10 +3954,10 @@
}, },
"packages/shell-react": { "packages/shell-react": {
"name": "@add-ideas/toolbox-shell-react", "name": "@add-ideas/toolbox-shell-react",
"version": "0.1.1", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -3966,10 +3966,10 @@
}, },
"packages/testkit": { "packages/testkit": {
"name": "@add-ideas/toolbox-testkit", "name": "@add-ideas/toolbox-testkit",
"version": "0.1.1", "version": "0.3.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "@add-ideas/toolbox-sdk-workspace", "name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.1", "version": "0.3.0",
"private": true, "private": true,
"description": "A small, framework-neutral toolbox contract with a React application shell.", "description": "A small, framework-neutral toolbox contract with a React application shell.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "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", "type": "module",
"workspaces": [ "workspaces": [
+28 -1
View File
@@ -13,7 +13,34 @@ const app = parseToolboxApp(await response.json());
const result = await loadToolboxContext(); 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. for the v1 document formats and full API.
The canonical schemas are also published at The canonical schemas are also published at
+4 -4
View File
@@ -1,14 +1,14 @@
{ {
"name": "@add-ideas/toolbox-contract", "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.", "description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "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" "directory": "packages/contract"
}, },
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk", "homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [ "keywords": [
"toolbox", "toolbox",
"manifest", "manifest",
@@ -43,6 +43,6 @@
}, },
"publishConfig": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/" "registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
} }
} }
+35
View File
@@ -1,6 +1,8 @@
export { export {
TOOLBOX_META_NAME, TOOLBOX_META_NAME,
TOOLBOX_QUERY_PARAMETER, TOOLBOX_QUERY_PARAMETER,
TOOLBOX_TRANSFER_QUERY_PARAMETER,
TOOLBOX_ARTIFACT_VERSION,
TOOLBOX_SCHEMA_VERSION, TOOLBOX_SCHEMA_VERSION,
ToolboxError, ToolboxError,
ToolboxValidationError, ToolboxValidationError,
@@ -26,6 +28,9 @@ export type {
ToolboxDiscoverySource, ToolboxDiscoverySource,
ToolboxErrorCode, ToolboxErrorCode,
ToolboxIntegration, ToolboxIntegration,
ToolboxFormat,
ToolboxIoProfile,
ToolboxCapabilityProfile,
ToolboxLaunchMode, ToolboxLaunchMode,
ToolboxPrivacy, ToolboxPrivacy,
ToolboxRequirements, ToolboxRequirements,
@@ -53,6 +58,36 @@ export {
loadToolboxCatalog, loadToolboxCatalog,
loadToolboxContext, loadToolboxContext,
} from "./load.js"; } 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 { export type {
ToolboxContextLoadOptions, ToolboxContextLoadOptions,
ToolboxFetch, ToolboxFetch,
+128
View File
@@ -9,6 +9,9 @@ import {
type ToolboxCatalogManifestEntry, type ToolboxCatalogManifestEntry,
type ToolboxCatalogTheme, type ToolboxCatalogTheme,
type ToolboxIntegration, type ToolboxIntegration,
type ToolboxIoProfile,
type ToolboxFormat,
type ToolboxCapabilityProfile,
type ToolboxLaunchMode, type ToolboxLaunchMode,
type ToolboxPrivacy, type ToolboxPrivacy,
type ToolboxRequirements, type ToolboxRequirements,
@@ -21,6 +24,9 @@ type UnknownRecord = Record<string, unknown>;
const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/; const ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
const WEB_PROTOCOLS = new Set(["http:", "https:"]); const WEB_PROTOCOLS = new Set(["http:", "https:"]);
const REFERENCE_BASE = "https://toolbox.invalid/"; 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( function recordAt(
value: unknown, 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( function parsePrivacy(
value: unknown, value: unknown,
path: string, path: string,
@@ -384,6 +491,25 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
"$.requirements", "$.requirements",
issues, 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 privacy = parsePrivacy(object.privacy, "$.privacy", issues);
const source = const source =
"source" in object "source" in object
@@ -424,6 +550,8 @@ export function parseToolboxApp(value: unknown): ToolboxAppManifest {
tags, tags,
integration, integration,
requirements, requirements,
...(io === undefined ? {} : { io }),
...(capabilities === undefined ? {} : { capabilities }),
privacy, privacy,
...(source === undefined ? {} : { source }), ...(source === undefined ? {} : { source }),
...(actions === undefined ? {} : { actions }), ...(actions === undefined ? {} : { actions }),
+94
View File
@@ -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)),
);
}
+477
View File
@@ -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;
}
+20
View File
@@ -1,6 +1,8 @@
export const TOOLBOX_SCHEMA_VERSION = 1 as const; export const TOOLBOX_SCHEMA_VERSION = 1 as const;
export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const; export const TOOLBOX_QUERY_PARAMETER = "toolbox" as const;
export const TOOLBOX_META_NAME = "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 { export interface ToolboxAction {
id: string; id: string;
@@ -32,6 +34,22 @@ export interface ToolboxRequirements {
topLevelContext?: boolean; 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 { export interface ToolboxSource {
repository: string; repository: string;
license: string; license: string;
@@ -49,6 +67,8 @@ export interface ToolboxAppManifest {
tags: readonly string[]; tags: readonly string[];
integration: ToolboxIntegration; integration: ToolboxIntegration;
requirements: ToolboxRequirements; requirements: ToolboxRequirements;
io?: ToolboxIoProfile;
capabilities?: ToolboxCapabilityProfile;
privacy: ToolboxPrivacy; privacy: ToolboxPrivacy;
source?: ToolboxSource; source?: ToolboxSource;
actions?: readonly ToolboxAction[]; actions?: readonly ToolboxAction[];
+84
View File
@@ -33,6 +33,10 @@ const app = (
indexedDb: true, indexedDb: true,
crossOriginIsolated: false, crossOriginIsolated: false,
}, },
capabilities: {
required: ["workers"],
optional: [],
},
privacy: { privacy: {
processing: "local", processing: "local",
fileUploads: false, fileUploads: false,
@@ -117,6 +121,86 @@ describe("v1 runtime parsing", () => {
).toThrow(/absolute HTTP\(S\) URL/u); ).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", () => { it("parses manifest references and external inline catalog entries", () => {
const parsed = parseToolboxCatalog( const parsed = parseToolboxCatalog(
catalog({ 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" }, 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) => { it.each(acceptedApps)("accepts %s", (_label, fixture) => {
@@ -175,6 +207,26 @@ describe("canonical schema and runtime parser parity", () => {
actions: [{ id: "docs", label: " ", url: "./docs" }], 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) => { it.each(rejectedApps)("rejects %s", (_label, fixture) => {
+202
View File
@@ -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);
});
});
+15 -1
View File
@@ -4,7 +4,7 @@
import { AppShell } from "@add-ideas/toolbox-shell-react"; import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css"; 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 /> <App />
</AppShell>; </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 standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
app switcher is exposed as the accessible navigation landmark app switcher is exposed as the accessible navigation landmark
`Toolbox applications` and all destinations use ordinary document navigation. `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.
+5 -5
View File
@@ -1,14 +1,14 @@
{ {
"name": "@add-ideas/toolbox-shell-react", "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.", "description": "A lightweight React application shell for toolbox-compatible browser applications.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "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" "directory": "packages/shell-react"
}, },
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk", "homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [ "keywords": [
"toolbox", "toolbox",
"react", "react",
@@ -38,7 +38,7 @@
"react-dom": ">=18 <20" "react-dom": ">=18 <20"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"scripts": { "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')\"", "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": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/" "registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
} }
} }
+141 -95
View File
@@ -1,17 +1,28 @@
import { import {
TOOLBOX_PREFERENCES_KEY,
contextualizeToolboxLink, contextualizeToolboxLink,
loadToolboxContext, loadToolboxContext,
readToolboxPreferences,
resolveWebUrl, resolveWebUrl,
writeToolboxPreferences,
type ToolboxAppManifest, type ToolboxAppManifest,
type ToolboxContext, type ToolboxContext,
type ToolboxContextLoadOptions, type ToolboxContextLoadOptions,
type ToolboxError, type ToolboxError,
type ToolboxThemeMode,
} from "@add-ideas/toolbox-contract"; } from "@add-ideas/toolbox-contract";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ToolboxHeader,
type ToolboxHeaderAction,
type ToolboxHeaderApp,
} from "./ToolboxHeader.js";
export interface AppShellProps { export interface AppShellProps {
app: ToolboxAppManifest; app: ToolboxAppManifest;
children: ReactNode; children: ReactNode;
helpAction?: ToolboxHeaderAction;
appActions?: ReactNode; appActions?: ReactNode;
className?: string; className?: string;
manifestUrl?: string | URL; manifestUrl?: string | URL;
@@ -25,9 +36,31 @@ function currentHref(): string {
: globalThis.location.href; : globalThis.location.href;
} }
function safeHref(reference: string, base: string | URL): string | undefined { function safeHref(
reference: string | URL,
base: string | URL,
): string | undefined {
try { 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 { } catch {
return undefined; return undefined;
} }
@@ -51,6 +84,7 @@ function privacyText(app: ToolboxAppManifest): string {
export function AppShell({ export function AppShell({
app, app,
children, children,
helpAction,
appActions, appActions,
className, className,
manifestUrl, manifestUrl,
@@ -61,12 +95,29 @@ export function AppShell({
const [contextState, setContextState] = useState<"standalone" | "connected">( const [contextState, setContextState] = useState<"standalone" | "connected">(
"standalone", "standalone",
); );
const [storedTheme, setStoredTheme] = useState<ToolboxThemeMode | undefined>(
initialStoredTheme,
);
const errorHandler = useRef(onContextError); const errorHandler = useRef(onContextError);
useEffect(() => { useEffect(() => {
errorHandler.current = onContextError; errorHandler.current = onContextError;
}, [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 contextCatalog = contextOptions?.catalogUrl;
const contextDocument = contextOptions?.document; const contextDocument = contextOptions?.document;
const contextFetch = contextOptions?.fetch; const contextFetch = contextOptions?.fetch;
@@ -97,9 +148,7 @@ export function AppShell({
if (result.status === "error") errorHandler.current?.(result.error); if (result.status === "error") errorHandler.current?.(result.error);
} }
}); });
return () => { return () => controller.abort();
controller.abort();
};
}, [ }, [
contextCatalog, contextCatalog,
contextDocument, contextDocument,
@@ -112,7 +161,7 @@ export function AppShell({
const pageUrl = contextLocation ?? currentHref(); const pageUrl = contextLocation ?? currentHref();
const appManifestUrl = useMemo( const appManifestUrl = useMemo(
() => () =>
manifestUrl ?? (manifestUrl ? safeHref(manifestUrl, pageUrl) : undefined) ??
safeHref("toolbox-app.json", pageUrl) ?? safeHref("toolbox-app.json", pageUrl) ??
"http://localhost/toolbox-app.json", "http://localhost/toolbox-app.json",
[manifestUrl, pageUrl], [manifestUrl, pageUrl],
@@ -120,112 +169,109 @@ export function AppShell({
const privacyHref = app.privacy.url const privacyHref = app.privacy.url
? safeHref(app.privacy.url, appManifestUrl) ? safeHref(app.privacy.url, appManifestUrl)
: undefined; : undefined;
const enabledApps = const sourceAction = app.actions?.find((action) => action.id === "source");
context?.catalog.apps.filter((entry) => entry.enabled) ?? []; const sourceHref = sourceAction
const theme = context?.catalog.catalog.theme.mode ?? "system"; ? 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 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 ( return (
<div <div
className={rootClassName} className={rootClassName}
data-toolbox-context={contextState} data-toolbox-context={contextState}
data-toolbox-theme={theme} data-toolbox-theme={theme}
> >
<header className="toolbox-shell__header"> <ToolboxHeader
<div className="toolbox-shell__bar"> title={app.name}
{context ? ( subtitle={app.description}
<a theme={theme}
className="toolbox-shell__home" onThemeChange={setTheme}
href={context.catalog.homeUrl.href} helpAction={helpAction}
> sourceHref={sourceHref}
{context.catalog.catalog.theme.brand} sourceLabel={`Source for ${app.name} on Gitea`}
</a> homeHref={context?.catalog.homeUrl.href}
) : null} homeLabel="add·ideas Toolbox"
brandIconUrl={
<div className="toolbox-shell__identity"> context
<img ? safeHref("favicon.svg", context.catalog.homeUrl.href)
className="toolbox-shell__icon" : undefined
src={safeHref(app.icon, appManifestUrl)} }
alt="" apps={switcherApps}
width="32" allAppsHref={context?.catalog.homeUrl.href}
height="32" />
/> <main className="toolbox-shell__main">{children}</main>
<div> <footer className="toolbox-shell__footer">
<h1 className="toolbox-shell__name">{app.name}</h1> <div className="toolbox-shell__footer-inner">
<span className="toolbox-shell__description"> {otherActions?.length || appActions ? (
{app.description} <div className="toolbox-shell__actions">
</span> {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> ) : (
<span aria-hidden="true" />
<div className="toolbox-shell__actions"> )}
{app.actions?.map((action) => { <div className="toolbox-shell__footer-meta">
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-label={`Version ${app.version}`}>v{app.version}</span> <span aria-label={`Version ${app.version}`}>v{app.version}</span>
<span aria-hidden="true">·</span>
{privacyHref ? ( {privacyHref ? (
<a href={privacyHref}>{privacyText(app)}</a> <a href={privacyHref}>{privacyText(app)}</a>
) : ( ) : (
<span>{privacyText(app)}</span> <span>{privacyText(app)}</span>
)} )}
</div> </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> </div>
</header> </footer>
<main className="toolbox-shell__main">{children}</main>
</div> </div>
); );
} }
+468
View File
@@ -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>
);
}
+7
View File
@@ -1,2 +1,9 @@
export { AppShell } from "./AppShell.js"; export { AppShell } from "./AppShell.js";
export type { AppShellProps } 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
View File
@@ -1,42 +1,67 @@
:root { :root {
--toolbox-font: --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; "Segoe UI", sans-serif;
--toolbox-background: #f5f7fa; --toolbox-background: #f6f7fb;
--toolbox-surface: #ffffff; --toolbox-surface: #ffffff;
--toolbox-text: #172033; --toolbox-surface-soft: #eff2f8;
--toolbox-muted: #657086; --toolbox-text: #17203b;
--toolbox-border: #dce2ea; --toolbox-muted: #667085;
--toolbox-accent: #3156d3; --toolbox-border: #dfe3ec;
--toolbox-accent: #28366d;
--toolbox-accent-hover: #1c2858;
--toolbox-accent-soft: #e9edff;
--toolbox-accent-contrast: #ffffff; --toolbox-accent-contrast: #ffffff;
--toolbox-radius: 0.65rem; --toolbox-focus: #63cdbc;
--toolbox-shadow: 0 1px 2px rgb(18 28 45 / 8%); --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"] { [data-toolbox-theme="dark"] {
--toolbox-background: #111722; color-scheme: dark;
--toolbox-surface: #192130; --toolbox-background: #0f1422;
--toolbox-text: #edf1f7; --toolbox-surface: #171d2d;
--toolbox-muted: #aab4c5; --toolbox-surface-soft: #20283a;
--toolbox-border: #303b4d; --toolbox-text: #edf1fb;
--toolbox-accent: #9db2ff; --toolbox-muted: #a9b3ca;
--toolbox-accent-contrast: #111722; --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) { @media (prefers-color-scheme: dark) {
[data-toolbox-theme="system"] { [data-toolbox-theme="system"] {
--toolbox-background: #111722; color-scheme: dark;
--toolbox-surface: #192130; --toolbox-background: #0f1422;
--toolbox-text: #edf1f7; --toolbox-surface: #171d2d;
--toolbox-muted: #aab4c5; --toolbox-surface-soft: #20283a;
--toolbox-border: #303b4d; --toolbox-text: #edf1fb;
--toolbox-accent: #9db2ff; --toolbox-muted: #a9b3ca;
--toolbox-accent-contrast: #111722; --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 { .toolbox-shell {
min-height: 100vh; min-height: 100vh;
display: flex;
flex-direction: column;
color: var(--toolbox-text); color: var(--toolbox-text);
background: var(--toolbox-background); background: var(--toolbox-background);
font-family: var(--toolbox-font); font-family: var(--toolbox-font);
@@ -44,116 +69,345 @@
.toolbox-shell *, .toolbox-shell *,
.toolbox-shell *::before, .toolbox-shell *::before,
.toolbox-shell *::after { .toolbox-shell *::after,
.toolbox-shell__header *,
.toolbox-shell__header *::before,
.toolbox-shell__header *::after {
box-sizing: border-box; box-sizing: border-box;
} }
.toolbox-shell__header { .toolbox-shell__header {
position: relative; position: sticky;
z-index: 10; z-index: 40;
border-bottom: 1px solid var(--toolbox-border); top: 0;
background: var(--toolbox-surface); color: var(--toolbox-text);
box-shadow: var(--toolbox-shadow); 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 { .toolbox-shell__bar {
display: flex; width: min(100%, 90rem);
min-height: 4.25rem; height: 4.5rem;
display: grid;
grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax(
18.75rem,
1fr
);
align-items: center; align-items: center;
gap: 1rem; gap: 1rem;
max-width: 90rem;
margin: 0 auto; 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); color: var(--toolbox-accent);
text-decoration: none; text-decoration: none;
} }
.toolbox-shell a:hover { .toolbox-shell__header a:hover {
text-decoration: underline; color: var(--toolbox-accent-hover);
} }
.toolbox-shell__home { .toolbox-shell__icon-button,
flex: none; .toolbox-shell__menu-button,
font-weight: 700; .toolbox-shell__supplemental a {
} min-height: 2.5rem;
display: inline-flex;
.toolbox-shell__identity {
display: flex;
min-width: 12rem;
align-items: center; align-items: center;
gap: 0.65rem; justify-content: center;
} gap: 0.45rem;
padding: 0.45rem 0.68rem;
.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;
border: 1px solid var(--toolbox-border); border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius); border-radius: var(--toolbox-radius);
background: var(--toolbox-surface); 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 { .toolbox-shell__switcher ul {
@@ -166,8 +420,13 @@
.toolbox-shell__switcher a { .toolbox-shell__switcher a {
display: block; display: block;
padding: 0.45rem 0.6rem; padding: 0.55rem 0.65rem;
border-radius: 0.4rem; border-radius: 0.5rem;
}
.toolbox-shell__switcher a:hover {
background: var(--toolbox-accent-soft);
text-decoration: none;
} }
.toolbox-shell__switcher a[aria-current="page"] { .toolbox-shell__switcher a[aria-current="page"] {
@@ -175,28 +434,164 @@
background: var(--toolbox-accent); 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 { .toolbox-shell__main {
width: min(100%, 90rem); width: min(100%, 90rem);
display: block;
flex: 1 0 auto;
margin: 0 auto; margin: 0 auto;
padding: 1rem; 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 { .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 { .toolbox-shell__menu-button {
flex: 1; width: 2.5rem;
min-width: 2.5rem;
height: 2.5rem;
padding: 0.45rem;
} }
.toolbox-shell__actions { .toolbox-shell__menu-button > span,
order: 3; .toolbox-shell__description {
width: 100%;
margin-left: 0;
}
.toolbox-shell__meta {
display: none; 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);
}
}
+295 -11
View File
@@ -1,8 +1,21 @@
import { render, screen, waitFor } from "@testing-library/react"; import {
import type { ToolboxAppManifest } from "@add-ideas/toolbox-contract"; fireEvent,
import { describe, expect, it, vi } from "vitest"; 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 = { const currentApp: ToolboxAppManifest = {
schemaVersion: 1, schemaVersion: 1,
@@ -25,6 +38,10 @@ const currentApp: ToolboxAppManifest = {
indexedDb: true, indexedDb: true,
crossOriginIsolated: false, crossOriginIsolated: false,
}, },
capabilities: {
required: ["workers"],
optional: [],
},
privacy: { privacy: {
processing: "local", processing: "local",
fileUploads: false, fileUploads: false,
@@ -76,11 +93,15 @@ function catalogFetch() {
} }
describe("AppShell", () => { describe("AppShell", () => {
beforeEach(() => localStorage.clear());
it("renders identity, privacy, version, actions, and content standalone", async () => { it("renders identity, privacy, version, actions, and content standalone", async () => {
const onHelp = vi.fn();
const fetch = vi.fn(); const fetch = vi.fn();
render( render(
<AppShell <AppShell
app={currentApp} app={currentApp}
helpAction={{ onClick: onHelp }}
appActions={<a href="/help">Help</a>} appActions={<a href="/help">Help</a>}
contextOptions={{ contextOptions={{
location: "https://tools.example.test/pdf/", location: "https://tools.example.test/pdf/",
@@ -105,9 +126,50 @@ describe("AppShell", () => {
expect( expect(
document.querySelector("[data-toolbox-context='standalone']"), document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument(); ).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( expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }), screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument(); ).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 () => { it("loads same-origin context and creates full-page contextual switch links", async () => {
@@ -126,7 +188,15 @@ describe("AppShell", () => {
</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", name: "Toolbox applications",
}); });
expect(navigation).toBeInTheDocument(); expect(navigation).toBeInTheDocument();
@@ -138,10 +208,14 @@ describe("AppShell", () => {
expect(destination.searchParams.get("toolbox")).toBe( expect(destination.searchParams.get("toolbox")).toBe(
"https://tools.example.test/toolbox.catalog.json", "https://tools.example.test/toolbox.catalog.json",
); );
expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute( expect(
"href", screen.getByRole("link", { name: "add·ideas Toolbox" }),
"https://tools.example.test/", ).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( expect(
document.querySelector("[data-toolbox-context='connected']"), document.querySelector("[data-toolbox-context='connected']"),
).toBeInTheDocument(); ).toBeInTheDocument();
@@ -169,9 +243,10 @@ describe("AppShell", () => {
code: "cross-origin", code: "cross-origin",
}); });
expect(fetch).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled();
fireEvent.click(screen.getByRole("button", { name: "Apps" }));
expect( expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }), screen.getByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument(); ).toHaveTextContent("Open this app from Toolbox");
}); });
it.each([ it.each([
@@ -199,4 +274,213 @@ describe("AppShell", () => {
document.querySelector("[data-toolbox-context='standalone']"), document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument(); ).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();
});
}); });
+5 -5
View File
@@ -1,14 +1,14 @@
{ {
"name": "@add-ideas/toolbox-testkit", "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.", "description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
"license": "Apache-2.0", "license": "Apache-2.0",
"repository": { "repository": {
"type": "git", "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" "directory": "packages/testkit"
}, },
"homepage": "https://git.add-ideas.de/zemion/toolbox-sdk", "homepage": "https://git.add-ideas.de/lotobo/toolbox-sdk",
"keywords": [ "keywords": [
"toolbox", "toolbox",
"manifest", "manifest",
@@ -37,7 +37,7 @@
}, },
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"scripts": { "scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"", "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": { "publishConfig": {
"access": "public", "access": "public",
"registry": "https://git.add-ideas.de/api/packages/zemion/npm/" "registry": "https://git.add-ideas.de/api/packages/lotobo/npm/"
} }
} }
+104 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "Toolbox application manifest v1",
"type": "object", "type": "object",
"required": [ "required": [
@@ -116,6 +116,24 @@
}, },
"additionalProperties": true "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": { "source": {
"type": "object", "type": "object",
"required": ["repository", "license"], "required": ["repository", "license"],
@@ -157,6 +175,60 @@
"uniqueItems": true "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": { "$defs": {
"urlReference": { "urlReference": {
"type": "string", "type": "string",
@@ -218,6 +290,37 @@
"items": { "items": {
"$ref": "#/$defs/nonEmptyString" "$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 "additionalProperties": true
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$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", "title": "Toolbox catalog v1",
"type": "object", "type": "object",
"required": ["schemaVersion", "id", "name", "home", "theme", "apps"], "required": ["schemaVersion", "id", "name", "home", "theme", "apps"],