feat: add shared toolbox header and appearance

This commit is contained in:
2026-07-23 00:11:13 +02:00
parent 5e206baf42
commit a058858b50
17 changed files with 982 additions and 216 deletions

View File

@@ -13,6 +13,10 @@ const app = parseToolboxApp(await response.json());
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)
for the v1 document formats and full API.

View File

@@ -1,6 +1,6 @@
{
"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.",
"license": "Apache-2.0",
"repository": {

View File

@@ -53,6 +53,18 @@ export {
loadToolboxCatalog,
loadToolboxContext,
} from "./load.js";
export {
TOOLBOX_PREFERENCES_KEY,
defaultToolboxPreferences,
parseToolboxPreferences,
readToolboxPreferences,
writeToolboxPreferences,
} from "./preferences.js";
export type {
ToolboxPreferences,
ToolboxPreferenceStorage,
ToolboxThemeMode,
} from "./preferences.js";
export type {
ToolboxContextLoadOptions,
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 "@add-ideas/toolbox-shell-react/styles.css";
<AppShell app={manifest} appActions={<a href="/help">Help</a>}>
<AppShell app={manifest} helpAction={{ onClick: () => setHelpOpen(true) }}>
<App />
</AppShell>;
```
@@ -13,3 +13,6 @@ import "@add-ideas/toolbox-shell-react/styles.css";
standalone mode if context is absent, unavailable, cross-origin, or invalid. Its
app switcher is exposed as the accessible navigation landmark
`Toolbox applications` and all destinations use ordinary document navigation.
The 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",
"version": "0.1.1",
"version": "0.2.0",
"description": "A lightweight React application shell for toolbox-compatible browser applications.",
"license": "Apache-2.0",
"repository": {
@@ -38,7 +38,7 @@
"react-dom": ">=18 <20"
},
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.1"
"@add-ideas/toolbox-contract": "0.2.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.copyFileSync('src/styles.css','dist/styles.css');fs.copyFileSync('../../LICENSE','LICENSE')\"",

View File

@@ -1,17 +1,28 @@
import {
TOOLBOX_PREFERENCES_KEY,
contextualizeToolboxLink,
loadToolboxContext,
readToolboxPreferences,
resolveWebUrl,
writeToolboxPreferences,
type ToolboxAppManifest,
type ToolboxContext,
type ToolboxContextLoadOptions,
type ToolboxError,
type ToolboxThemeMode,
} from "@add-ideas/toolbox-contract";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import {
ToolboxHeader,
type ToolboxHeaderAction,
type ToolboxHeaderApp,
} from "./ToolboxHeader.js";
export interface AppShellProps {
app: ToolboxAppManifest;
children: ReactNode;
helpAction?: ToolboxHeaderAction;
appActions?: ReactNode;
className?: string;
manifestUrl?: string | URL;
@@ -25,9 +36,31 @@ function currentHref(): string {
: globalThis.location.href;
}
function safeHref(reference: string, base: string | URL): string | undefined {
function safeHref(
reference: string | URL,
base: string | URL,
): string | undefined {
try {
return resolveWebUrl(reference, base).href;
return resolveWebUrl(String(reference), base).href;
} catch {
return undefined;
}
}
function browserStorage(): Storage | undefined {
try {
return globalThis.localStorage;
} catch {
return undefined;
}
}
function initialStoredTheme(): ToolboxThemeMode | undefined {
const storage = browserStorage();
if (!storage) return undefined;
try {
if (storage.getItem(TOOLBOX_PREFERENCES_KEY) === null) return undefined;
return readToolboxPreferences(storage).theme;
} catch {
return undefined;
}
@@ -51,6 +84,7 @@ function privacyText(app: ToolboxAppManifest): string {
export function AppShell({
app,
children,
helpAction,
appActions,
className,
manifestUrl,
@@ -61,12 +95,29 @@ export function AppShell({
const [contextState, setContextState] = useState<"standalone" | "connected">(
"standalone",
);
const [storedTheme, setStoredTheme] = useState<ToolboxThemeMode | undefined>(
initialStoredTheme,
);
const errorHandler = useRef(onContextError);
useEffect(() => {
errorHandler.current = onContextError;
}, [onContextError]);
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (event.key !== TOOLBOX_PREFERENCES_KEY) return;
if (event.newValue === null) {
setStoredTheme(undefined);
return;
}
const storage = browserStorage();
if (storage) setStoredTheme(readToolboxPreferences(storage).theme);
};
globalThis.addEventListener?.("storage", handleStorage);
return () => globalThis.removeEventListener?.("storage", handleStorage);
}, []);
const contextCatalog = contextOptions?.catalogUrl;
const contextDocument = contextOptions?.document;
const contextFetch = contextOptions?.fetch;
@@ -97,9 +148,7 @@ export function AppShell({
if (result.status === "error") errorHandler.current?.(result.error);
}
});
return () => {
controller.abort();
};
return () => controller.abort();
}, [
contextCatalog,
contextDocument,
@@ -112,7 +161,7 @@ export function AppShell({
const pageUrl = contextLocation ?? currentHref();
const appManifestUrl = useMemo(
() =>
manifestUrl ??
(manifestUrl ? safeHref(manifestUrl, pageUrl) : undefined) ??
safeHref("toolbox-app.json", pageUrl) ??
"http://localhost/toolbox-app.json",
[manifestUrl, pageUrl],
@@ -120,111 +169,102 @@ export function AppShell({
const privacyHref = app.privacy.url
? safeHref(app.privacy.url, appManifestUrl)
: undefined;
const enabledApps =
context?.catalog.apps.filter((entry) => entry.enabled) ?? [];
const theme = context?.catalog.catalog.theme.mode ?? "system";
const sourceAction = app.actions?.find((action) => action.id === "source");
const sourceHref = sourceAction
? safeHref(sourceAction.url, appManifestUrl)
: app.source?.repository;
const otherActions = app.actions?.filter((action) => action.id !== "source");
const theme = storedTheme ?? context?.catalog.catalog.theme.mode ?? "system";
const rootClassName = ["toolbox-shell", className].filter(Boolean).join(" ");
const switcherApps: ToolboxHeaderApp[] =
context?.catalog.apps.flatMap<ToolboxHeaderApp>((entry) => {
if (!entry.enabled) return [];
if (entry.kind === "external") {
return [
{
id: `external:${entry.name}:${entry.entryUrl.href}`,
name: entry.name,
href: entry.entryUrl.href,
newTab: entry.launch === "new-tab",
},
];
}
const manifest = entry.app.manifest;
return [
{
id: manifest.id,
name: manifest.name,
href: contextualizeToolboxLink(
entry.app.entryUrl,
context.catalog.catalogUrl,
{ location: pageUrl },
),
current: manifest.id === app.id,
},
];
}) ?? [];
function setTheme(themeChoice: ToolboxThemeMode) {
setStoredTheme(themeChoice);
const storage = browserStorage();
if (!storage) return;
try {
writeToolboxPreferences(
{ ...readToolboxPreferences(storage), theme: themeChoice },
storage,
);
} catch {
// Keep the in-memory theme if storage is blocked or full.
}
}
return (
<div
className={rootClassName}
data-toolbox-context={contextState}
data-toolbox-theme={theme}
>
<header className="toolbox-shell__header">
<div className="toolbox-shell__bar">
{context ? (
<a
className="toolbox-shell__home"
href={context.catalog.homeUrl.href}
>
{context.catalog.catalog.theme.brand}
</a>
) : null}
<div className="toolbox-shell__identity">
<img
className="toolbox-shell__icon"
src={safeHref(app.icon, appManifestUrl)}
alt=""
width="32"
height="32"
/>
<div>
<h1 className="toolbox-shell__name">{app.name}</h1>
<span className="toolbox-shell__description">
{app.description}
</span>
<ToolboxHeader
title={app.name}
iconUrl={safeHref(app.icon, appManifestUrl)}
subtitle={app.description}
theme={theme}
onThemeChange={setTheme}
helpAction={helpAction}
sourceHref={sourceHref}
sourceLabel={`Source for ${app.name} on Gitea`}
homeHref={context?.catalog.homeUrl.href}
homeLabel={context?.catalog.catalog.theme.brand}
apps={switcherApps}
allAppsHref={context?.catalog.homeUrl.href}
leadingActions={
otherActions || appActions ? (
<div className="toolbox-shell__actions">
{otherActions?.map((action) => {
const href = safeHref(action.url, appManifestUrl);
return href ? (
<a key={action.id} href={href}>
{action.label}
</a>
) : null;
})}
{appActions}
</div>
</div>
<div className="toolbox-shell__actions">
{app.actions?.map((action) => {
const href = safeHref(action.url, appManifestUrl);
return href ? (
<a key={action.id} href={href}>
{action.label}
</a>
) : null;
})}
{appActions}
</div>
<div className="toolbox-shell__meta">
) : undefined
}
metadata={
<>
<span aria-label={`Version ${app.version}`}>v{app.version}</span>
<span aria-hidden="true">·</span>
{privacyHref ? (
<a href={privacyHref}>{privacyText(app)}</a>
) : (
<span>{privacyText(app)}</span>
)}
</div>
{context && enabledApps.length > 0 ? (
<details className="toolbox-shell__switcher">
<summary>Apps</summary>
<nav aria-label="Toolbox applications">
<ul>
{enabledApps.map((entry) => {
if (entry.kind === "external") {
return (
<li
key={`external:${entry.name}:${entry.entryUrl.href}`}
>
<a
href={entry.entryUrl.href}
{...(entry.launch === "new-tab"
? { target: "_blank", rel: "noreferrer" }
: {})}
>
{entry.name}
</a>
</li>
);
}
const manifest = entry.app.manifest;
return (
<li key={manifest.id}>
<a
href={contextualizeToolboxLink(
entry.app.entryUrl,
context.catalog.catalogUrl,
{ location: pageUrl },
)}
aria-current={
manifest.id === app.id ? "page" : undefined
}
>
{manifest.name}
</a>
</li>
);
})}
</ul>
</nav>
</details>
) : null}
</div>
</header>
</>
}
/>
<main className="toolbox-shell__main">{children}</main>
</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 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 {
--toolbox-font:
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
Inter, Avenir, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
"Segoe UI", sans-serif;
--toolbox-background: #f5f7fa;
--toolbox-background: #f6f7fb;
--toolbox-surface: #ffffff;
--toolbox-text: #172033;
--toolbox-muted: #657086;
--toolbox-border: #dce2ea;
--toolbox-accent: #3156d3;
--toolbox-surface-soft: #eff2f8;
--toolbox-text: #17203b;
--toolbox-muted: #667085;
--toolbox-border: #dfe3ec;
--toolbox-accent: #28366d;
--toolbox-accent-hover: #1c2858;
--toolbox-accent-soft: #e9edff;
--toolbox-accent-contrast: #ffffff;
--toolbox-radius: 0.65rem;
--toolbox-shadow: 0 1px 2px rgb(18 28 45 / 8%);
--toolbox-focus: #63cdbc;
--toolbox-radius: 0.7rem;
--toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%);
}
[data-toolbox-theme="light"] {
color-scheme: light;
}
[data-toolbox-theme="dark"] {
--toolbox-background: #111722;
--toolbox-surface: #192130;
--toolbox-text: #edf1f7;
--toolbox-muted: #aab4c5;
--toolbox-border: #303b4d;
--toolbox-accent: #9db2ff;
--toolbox-accent-contrast: #111722;
color-scheme: dark;
--toolbox-background: #0f1422;
--toolbox-surface: #171d2d;
--toolbox-surface-soft: #20283a;
--toolbox-text: #edf1fb;
--toolbox-muted: #a9b3ca;
--toolbox-border: #303a50;
--toolbox-accent: #a9b7ff;
--toolbox-accent-hover: #c3ccff;
--toolbox-accent-soft: #252f55;
--toolbox-accent-contrast: #11182a;
--toolbox-focus: #66cdbd;
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
@media (prefers-color-scheme: dark) {
[data-toolbox-theme="system"] {
--toolbox-background: #111722;
--toolbox-surface: #192130;
--toolbox-text: #edf1f7;
--toolbox-muted: #aab4c5;
--toolbox-border: #303b4d;
--toolbox-accent: #9db2ff;
--toolbox-accent-contrast: #111722;
color-scheme: dark;
--toolbox-background: #0f1422;
--toolbox-surface: #171d2d;
--toolbox-surface-soft: #20283a;
--toolbox-text: #edf1fb;
--toolbox-muted: #a9b3ca;
--toolbox-border: #303a50;
--toolbox-accent: #a9b7ff;
--toolbox-accent-hover: #c3ccff;
--toolbox-accent-soft: #252f55;
--toolbox-accent-contrast: #11182a;
--toolbox-focus: #66cdbd;
--toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%);
}
}
@@ -44,116 +64,261 @@
.toolbox-shell *,
.toolbox-shell *::before,
.toolbox-shell *::after {
.toolbox-shell *::after,
.toolbox-shell__header *,
.toolbox-shell__header *::before,
.toolbox-shell__header *::after {
box-sizing: border-box;
}
.toolbox-shell__header {
position: relative;
z-index: 10;
border-bottom: 1px solid var(--toolbox-border);
background: var(--toolbox-surface);
box-shadow: var(--toolbox-shadow);
position: sticky;
z-index: 40;
top: 0;
color: var(--toolbox-text);
border-bottom: 1px solid
color-mix(in srgb, var(--toolbox-border) 75%, transparent);
background: color-mix(in srgb, var(--toolbox-surface) 92%, transparent);
box-shadow: 0 1px 0 rgb(24 34 68 / 3%);
font-family: var(--toolbox-font);
backdrop-filter: blur(16px);
}
.toolbox-shell__bar {
display: flex;
min-height: 4.25rem;
width: min(100%, 90rem);
min-height: 4.35rem;
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 1rem;
max-width: 90rem;
margin: 0 auto;
padding: 0.75rem 1rem;
padding: 0.65rem 1rem;
}
.toolbox-shell a {
.toolbox-shell__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);
text-decoration: none;
}
.toolbox-shell a:hover {
text-decoration: underline;
.toolbox-shell__header a:hover {
color: var(--toolbox-accent-hover);
}
.toolbox-shell__home {
flex: none;
font-weight: 700;
max-width: 9rem;
margin-right: 0.15rem;
overflow: hidden;
font-weight: 750;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__identity {
min-width: 0;
display: flex;
min-width: 12rem;
align-items: center;
justify-content: center;
gap: 0.65rem;
text-align: center;
}
.toolbox-shell__identity > div {
min-width: 0;
display: grid;
gap: 0.1rem;
gap: 0.08rem;
}
.toolbox-shell__icon {
width: 2rem;
height: 2rem;
flex: none;
border-radius: 0.35rem;
border-radius: 0.45rem;
object-fit: contain;
}
.toolbox-shell__name {
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 {
display: block;
max-width: 27rem;
overflow: hidden;
color: var(--toolbox-muted);
font-size: 0.8rem;
font-size: 0.73rem;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbox-shell__actions {
display: flex;
.toolbox-shell__icon-button,
.toolbox-shell__menu-button,
.toolbox-shell__actions a {
min-height: 2.35rem;
display: inline-flex;
align-items: center;
gap: 0.75rem;
margin-left: auto;
}
.toolbox-shell__meta {
display: grid;
flex: none;
gap: 0.15rem;
color: var(--toolbox-muted);
font-size: 0.75rem;
text-align: right;
}
.toolbox-shell__switcher {
position: relative;
flex: none;
}
.toolbox-shell__switcher summary {
padding: 0.45rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
cursor: pointer;
font-weight: 600;
list-style: none;
}
.toolbox-shell__switcher summary::-webkit-details-marker {
display: none;
}
.toolbox-shell__switcher nav {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
width: max-content;
min-width: 12rem;
padding: 0.4rem;
justify-content: center;
gap: 0.45rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: var(--toolbox-surface);
box-shadow: 0 0.75rem 2rem rgb(18 28 45 / 14%);
color: var(--toolbox-text);
font: inherit;
font-size: 0.82rem;
font-weight: 650;
line-height: 1;
text-decoration: none;
cursor: pointer;
}
.toolbox-shell__icon-button {
width: 2.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 {
@@ -166,8 +331,13 @@
.toolbox-shell__switcher a {
display: block;
padding: 0.45rem 0.6rem;
border-radius: 0.4rem;
padding: 0.55rem 0.65rem;
border-radius: 0.5rem;
}
.toolbox-shell__switcher a:hover {
background: var(--toolbox-accent-soft);
text-decoration: none;
}
.toolbox-shell__switcher a[aria-current="page"] {
@@ -175,28 +345,83 @@
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 {
width: min(100%, 90rem);
margin: 0 auto;
padding: 1rem;
}
@media (max-width: 52rem) {
@media (max-width: 48rem) {
.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 {
flex: 1;
gap: 0.4rem;
}
.toolbox-shell__actions {
order: 3;
width: 100%;
margin-left: 0;
.toolbox-shell__icon {
width: 1.7rem;
height: 1.7rem;
}
.toolbox-shell__name {
max-width: 36vw;
font-size: 0.92rem;
}
.toolbox-shell__meta {
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 type { ToolboxAppManifest } from "@add-ideas/toolbox-contract";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } 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";
@@ -76,11 +79,15 @@ function catalogFetch() {
}
describe("AppShell", () => {
beforeEach(() => localStorage.clear());
it("renders identity, privacy, version, actions, and content standalone", async () => {
const onHelp = vi.fn();
const fetch = vi.fn();
render(
<AppShell
app={currentApp}
helpAction={{ onClick: onHelp }}
appActions={<a href="/help">Help</a>}
contextOptions={{
location: "https://tools.example.test/pdf/",
@@ -107,7 +114,14 @@ describe("AppShell", () => {
).toBeInTheDocument();
expect(
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 () => {
@@ -171,7 +185,7 @@ describe("AppShell", () => {
expect(fetch).not.toHaveBeenCalled();
expect(
screen.queryByRole("navigation", { name: "Toolbox applications" }),
).not.toBeInTheDocument();
).toHaveTextContent("Open this app from Toolbox");
});
it.each([
@@ -199,4 +213,53 @@ describe("AppShell", () => {
document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument();
});
it("uses and updates the shared light, dark, and system preference", async () => {
localStorage.setItem(
TOOLBOX_PREFERENCES_KEY,
JSON.stringify({
version: 1,
pinned: [],
order: [],
hidden: [],
theme: "dark",
}),
);
render(
<AppShell
app={currentApp}
contextOptions={{ location: "https://tools.example.test/pdf/" }}
>
PDF
</AppShell>,
);
const shell = document.querySelector(".toolbox-shell");
expect(shell).toHaveAttribute("data-toolbox-theme", "dark");
fireEvent.click(screen.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",
"version": "0.1.1",
"version": "0.2.0",
"description": "Manifest, asset, and nested-deployment smoke checks for built toolbox applications.",
"license": "Apache-2.0",
"repository": {
@@ -37,7 +37,7 @@
},
"types": "./dist/index.d.ts",
"dependencies": {
"@add-ideas/toolbox-contract": "0.1.1"
"@add-ideas/toolbox-contract": "0.2.0"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",