1 Commits

Author SHA1 Message Date
a058858b50 feat: add shared toolbox header and appearance 2026-07-23 00:11:13 +02:00
17 changed files with 982 additions and 216 deletions

View File

@@ -3,6 +3,24 @@
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.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

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.2.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.
@@ -160,23 +160,26 @@ 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 always renders a centered app identity, version, derived privacy
facts, manifest actions, and `appActions`. With valid context it additionally facts, standard Help and source controls, Personalize, Apps, manifest actions,
renders the catalog brand/home and a switcher at the accessible navigation and legacy `appActions`. With valid context it additionally renders the catalog
landmark `Toolbox applications`. Destinations are ordinary links, so switching brand/home and available app destinations. Destinations are ordinary links, so
performs full-page navigation. On missing, cross-origin, invalid, or unavailable switching performs full-page navigation. On missing, cross-origin, invalid, or
context the shell quietly remains usable in standalone mode; `onContextError` is unavailable context the shell quietly remains usable in standalone mode;
optional observability. `onContextError` is optional observability.
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 +213,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.

14
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "@add-ideas/toolbox-sdk-workspace", "name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.1", "version": "0.2.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.2.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.2.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.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.2.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.2.0",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.1.1" "@add-ideas/toolbox-contract": "0.2.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-sdk-workspace", "name": "@add-ideas/toolbox-sdk-workspace",
"version": "0.1.1", "version": "0.2.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",

View File

@@ -13,6 +13,10 @@ const app = parseToolboxApp(await response.json());
const result = await loadToolboxContext(); const result = await loadToolboxContext();
``` ```
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.
See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme) See the workspace [README](https://git.add-ideas.de/zemion/toolbox-sdk#readme)
for the v1 document formats and full API. for the v1 document formats and full API.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-contract", "name": "@add-ideas/toolbox-contract",
"version": "0.1.1", "version": "0.2.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": {

View File

@@ -53,6 +53,18 @@ export {
loadToolboxCatalog, loadToolboxCatalog,
loadToolboxContext, loadToolboxContext,
} from "./load.js"; } from "./load.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,

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)),
);
}

View File

@@ -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",
});
});
});

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,6 @@ 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 shared top bar also exposes Help, Gitea source, light/dark/system
personalization, and Apps controls. `ToolboxHeader` is exported separately for
custom shells.

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-shell-react", "name": "@add-ideas/toolbox-shell-react",
"version": "0.1.1", "version": "0.2.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": {
@@ -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.2.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')\"",

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,46 +169,79 @@ 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 ? ( iconUrl={safeHref(app.icon, appManifestUrl)}
<a subtitle={app.description}
className="toolbox-shell__home" theme={theme}
href={context.catalog.homeUrl.href} onThemeChange={setTheme}
> helpAction={helpAction}
{context.catalog.catalog.theme.brand} sourceHref={sourceHref}
</a> sourceLabel={`Source for ${app.name} on Gitea`}
) : null} homeHref={context?.catalog.homeUrl.href}
homeLabel={context?.catalog.catalog.theme.brand}
<div className="toolbox-shell__identity"> apps={switcherApps}
<img allAppsHref={context?.catalog.homeUrl.href}
className="toolbox-shell__icon" leadingActions={
src={safeHref(app.icon, appManifestUrl)} otherActions || appActions ? (
alt=""
width="32"
height="32"
/>
<div>
<h1 className="toolbox-shell__name">{app.name}</h1>
<span className="toolbox-shell__description">
{app.description}
</span>
</div>
</div>
<div className="toolbox-shell__actions"> <div className="toolbox-shell__actions">
{app.actions?.map((action) => { {otherActions?.map((action) => {
const href = safeHref(action.url, appManifestUrl); const href = safeHref(action.url, appManifestUrl);
return href ? ( return href ? (
<a key={action.id} href={href}> <a key={action.id} href={href}>
@@ -169,62 +251,20 @@ export function AppShell({
})} })}
{appActions} {appActions}
</div> </div>
) : undefined
<div className="toolbox-shell__meta"> }
metadata={
<>
<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> </>
{context && enabledApps.length > 0 ? (
<details className="toolbox-shell__switcher">
<summary>Apps</summary>
<nav aria-label="Toolbox applications">
<ul>
{enabledApps.map((entry) => {
if (entry.kind === "external") {
return (
<li
key={`external:${entry.name}:${entry.entryUrl.href}`}
>
<a
href={entry.entryUrl.href}
{...(entry.launch === "new-tab"
? { target: "_blank", rel: "noreferrer" }
: {})}
>
{entry.name}
</a>
</li>
);
} }
const manifest = entry.app.manifest; />
return (
<li key={manifest.id}>
<a
href={contextualizeToolboxLink(
entry.app.entryUrl,
context.catalog.catalogUrl,
{ location: pageUrl },
)}
aria-current={
manifest.id === app.id ? "page" : undefined
}
>
{manifest.name}
</a>
</li>
);
})}
</ul>
</nav>
</details>
) : null}
</div>
</header>
<main className="toolbox-shell__main">{children}</main> <main className="toolbox-shell__main">{children}</main>
</div> </div>
); );

View File

@@ -0,0 +1,233 @@
import type { ToolboxThemeMode } from "@add-ideas/toolbox-contract";
import 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;
iconUrl?: string | undefined;
subtitle?: string | undefined;
theme: ToolboxThemeMode;
onThemeChange?: ((theme: ToolboxThemeMode) => void) | undefined;
onPersonalize?: (() => void) | undefined;
helpAction?: ToolboxHeaderAction | undefined;
sourceHref?: string | undefined;
sourceLabel?: string | undefined;
homeHref?: string | undefined;
homeLabel?: string | undefined;
apps?: readonly ToolboxHeaderApp[] | undefined;
allAppsHref?: string | undefined;
leadingActions?: ReactNode | undefined;
metadata?: ReactNode | undefined;
className?: string | undefined;
}
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>
);
}
const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"];
export function ToolboxHeader({
title,
iconUrl,
subtitle,
theme,
onThemeChange,
onPersonalize,
helpAction,
sourceHref,
sourceLabel = "Source on Gitea",
homeHref,
homeLabel = "Toolbox",
apps = [],
allAppsHref,
leadingActions,
metadata,
className,
}: ToolboxHeaderProps) {
return (
<header
className={["toolbox-shell__header", className].filter(Boolean).join(" ")}
data-toolbox-theme={theme}
>
<div className="toolbox-shell__bar">
<div className="toolbox-shell__side toolbox-shell__side--left">
{homeHref ? (
<a className="toolbox-shell__home" href={homeHref}>
{homeLabel}
</a>
) : null}
{helpAction ? (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction.label ?? "Help"}
title={helpAction.title ?? helpAction.label ?? "Help"}
onClick={helpAction.onClick}
>
<HelpIcon />
</button>
) : null}
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
href={sourceHref}
target="_blank"
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
>
<GiteaIcon />
</a>
) : null}
{leadingActions}
</div>
<div className="toolbox-shell__identity">
{iconUrl ? (
<img
className="toolbox-shell__icon"
src={iconUrl}
alt=""
width="32"
height="32"
/>
) : null}
<div>
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
</div>
<div className="toolbox-shell__side toolbox-shell__side--right">
{onPersonalize ? (
<button
className="toolbox-shell__menu-button"
type="button"
onClick={onPersonalize}
>
<PersonalizeIcon />
<span>Personalize</span>
</button>
) : (
<details className="toolbox-shell__menu toolbox-shell__personalize">
<summary className="toolbox-shell__menu-button">
<PersonalizeIcon />
<span>Personalize</span>
</summary>
<div className="toolbox-shell__popover">
<strong>Appearance</strong>
<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>
<small>Saved only in this browser.</small>
</div>
</details>
)}
<details className="toolbox-shell__menu toolbox-shell__switcher">
<summary className="toolbox-shell__menu-button">
<AppsIcon />
<span>Apps</span>
</summary>
<nav
className="toolbox-shell__popover"
aria-label="Toolbox applications"
>
{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>
</details>
</div>
</div>
{metadata ? <div className="toolbox-shell__meta">{metadata}</div> : null}
</header>
);
}

View File

@@ -1,2 +1,8 @@
export { AppShell } from "./AppShell.js"; export { AppShell } from "./AppShell.js";
export type { AppShellProps } from "./AppShell.js"; export type { AppShellProps } from "./AppShell.js";
export { ToolboxHeader } from "./ToolboxHeader.js";
export type {
ToolboxHeaderAction,
ToolboxHeaderApp,
ToolboxHeaderProps,
} from "./ToolboxHeader.js";

View File

@@ -1,37 +1,57 @@
: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-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-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-shadow: 0 14px 42px rgb(0 0 0 / 24%);
} }
} }
@@ -44,116 +64,261 @@
.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; min-height: 4.35rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 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__side {
min-width: 0;
display: flex;
align-items: center;
gap: 0.45rem;
}
.toolbox-shell__side--right {
justify-content: flex-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__home {
flex: none; max-width: 9rem;
font-weight: 700; margin-right: 0.15rem;
overflow: hidden;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
} }
.toolbox-shell__identity { .toolbox-shell__identity {
min-width: 0;
display: flex; display: flex;
min-width: 12rem;
align-items: center; align-items: center;
justify-content: center;
gap: 0.65rem; gap: 0.65rem;
text-align: center;
} }
.toolbox-shell__identity > div { .toolbox-shell__identity > div {
min-width: 0;
display: grid; display: grid;
gap: 0.1rem; gap: 0.08rem;
} }
.toolbox-shell__icon { .toolbox-shell__icon {
width: 2rem;
height: 2rem;
flex: none; flex: none;
border-radius: 0.35rem; border-radius: 0.45rem;
object-fit: contain;
} }
.toolbox-shell__name { .toolbox-shell__name {
margin: 0; margin: 0;
overflow: hidden;
color: var(--toolbox-text);
font-family: var(--toolbox-font);
font-size: 1rem; font-size: 1rem;
font-weight: 750;
line-height: 1.2; line-height: 1.2;
text-overflow: ellipsis;
white-space: nowrap;
} }
.toolbox-shell__description { .toolbox-shell__description {
display: block;
max-width: 27rem;
overflow: hidden;
color: var(--toolbox-muted); color: var(--toolbox-muted);
font-size: 0.8rem; font-size: 0.73rem;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
} }
.toolbox-shell__actions { .toolbox-shell__icon-button,
display: flex; .toolbox-shell__menu-button,
.toolbox-shell__actions a {
min-height: 2.35rem;
display: inline-flex;
align-items: center; align-items: center;
gap: 0.75rem; justify-content: center;
margin-left: auto; gap: 0.45rem;
} padding: 0.45rem 0.65rem;
.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.35rem;
padding: 0.45rem;
border-radius: 50%;
}
.toolbox-shell__icon-button:hover,
.toolbox-shell__menu-button:hover,
.toolbox-shell__actions 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:focus-visible,
.toolbox-shell__menu-button:focus-visible,
.toolbox-shell__popover a:focus-visible,
.toolbox-shell__popover button: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__actions {
min-width: 0;
display: flex;
align-items: center;
gap: 0.4rem;
}
.toolbox-shell__menu {
position: relative;
}
.toolbox-shell__menu > summary {
list-style: none;
}
.toolbox-shell__menu > summary::-webkit-details-marker {
display: none;
}
.toolbox-shell__popover {
position: absolute;
top: calc(100% + 0.55rem);
right: 0;
width: max-content;
min-width: 13rem;
max-width: min(22rem, calc(100vw - 1.5rem));
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__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__popover 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__theme-options {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.25rem;
padding: 0.25rem;
border-radius: 0.65rem;
background: var(--toolbox-surface-soft);
}
.toolbox-shell__theme-options button {
padding: 0.5rem 0.45rem;
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 1px 5px rgb(24 34 68 / 12%);
font-weight: 750;
} }
.toolbox-shell__switcher ul { .toolbox-shell__switcher ul {
@@ -166,8 +331,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 +345,83 @@
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__meta {
min-height: 1.65rem;
display: flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
padding: 0.25rem 1rem 0.45rem;
color: var(--toolbox-muted);
font-size: 0.7rem;
text-align: center;
}
.toolbox-shell__main { .toolbox-shell__main {
width: min(100%, 90rem); width: min(100%, 90rem);
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
} }
@media (max-width: 52rem) { @media (max-width: 48rem) {
.toolbox-shell__bar { .toolbox-shell__bar {
flex-wrap: wrap; min-height: 3.8rem;
gap: 0.5rem;
padding-inline: 0.7rem;
}
.toolbox-shell__home,
.toolbox-shell__description,
.toolbox-shell__menu-button > span {
display: none;
}
.toolbox-shell__menu-button {
width: 2.35rem;
padding: 0.45rem;
border-radius: 50%;
}
.toolbox-shell__side {
gap: 0.3rem;
} }
.toolbox-shell__identity { .toolbox-shell__identity {
flex: 1; gap: 0.4rem;
} }
.toolbox-shell__actions { .toolbox-shell__icon {
order: 3; width: 1.7rem;
width: 100%; height: 1.7rem;
margin-left: 0; }
.toolbox-shell__name {
max-width: 36vw;
font-size: 0.92rem;
} }
.toolbox-shell__meta { .toolbox-shell__meta {
display: none; display: none;
} }
.toolbox-shell__actions {
display: none;
}
}
@media (max-width: 27rem) {
.toolbox-shell__source {
display: none;
}
.toolbox-shell__name {
max-width: 30vw;
}
} }

View File

@@ -1,6 +1,9 @@
import { render, screen, waitFor } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type { ToolboxAppManifest } from "@add-ideas/toolbox-contract"; import {
import { describe, expect, it, vi } from "vitest"; 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 } from "../src/index.js";
@@ -76,11 +79,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/",
@@ -107,7 +114,14 @@ describe("AppShell", () => {
).toBeInTheDocument(); ).toBeInTheDocument();
expect( expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }), screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument(); ).toHaveTextContent("Open this app from Toolbox");
expect(
screen.getByRole("link", { name: /Source for PDF Workbench/u }),
).toHaveAttribute("href", "https://git.example.test/pdf-tools");
fireEvent.click(screen.getByRole("button", { name: "Help" }));
expect(onHelp).toHaveBeenCalledOnce();
expect(screen.getByText("Personalize")).toBeInTheDocument();
expect(screen.getByText("Apps")).toBeInTheDocument();
}); });
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 () => {
@@ -171,7 +185,7 @@ describe("AppShell", () => {
expect(fetch).not.toHaveBeenCalled(); expect(fetch).not.toHaveBeenCalled();
expect( expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }), screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument(); ).toHaveTextContent("Open this app from Toolbox");
}); });
it.each([ it.each([
@@ -199,4 +213,53 @@ 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.getByText("Personalize"));
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("resolves a relative explicit manifest URL against the deployed page", async () => {
render(
<AppShell
app={currentApp}
manifestUrl="./toolbox-app.json"
contextOptions={{
location: "https://tools.example.test/nested/pdf/index.html",
}}
>
PDF
</AppShell>,
);
expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute(
"src",
"https://tools.example.test/nested/pdf/icon.svg",
);
});
}); });

View File

@@ -1,6 +1,6 @@
{ {
"name": "@add-ideas/toolbox-testkit", "name": "@add-ideas/toolbox-testkit",
"version": "0.1.1", "version": "0.2.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": {
@@ -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.2.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')\"",