feat: add shared toolbox header and appearance
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -3,6 +3,24 @@
|
||||
All notable changes to the independently versioned Toolbox SDK packages are
|
||||
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
|
||||
|
||||
### Fixed
|
||||
|
||||
25
README.md
25
README.md
@@ -5,7 +5,7 @@ tools. An app always works by itself. When it receives a trusted same-origin
|
||||
catalog URL, the same app gains a toolbox home link and an app switcher without
|
||||
becoming coupled to a portal or client-side router.
|
||||
|
||||
Version `0.1.0` contains three publish-ready packages:
|
||||
Version `0.2.0` contains three publish-ready packages:
|
||||
|
||||
- `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context
|
||||
discovery/loading, resolved URLs, and contextual link helpers.
|
||||
@@ -160,23 +160,26 @@ import "@add-ideas/toolbox-shell-react/styles.css";
|
||||
<AppShell
|
||||
app={manifest}
|
||||
manifestUrl="/toolbox-app.json"
|
||||
appActions={<a href="./help/">Help</a>}
|
||||
helpAction={{ onClick: () => setHelpOpen(true) }}
|
||||
onContextError={(error) => console.warn(error)}
|
||||
>
|
||||
<Application />
|
||||
</AppShell>;
|
||||
```
|
||||
|
||||
The shell always renders app identity as an `h1`, version, derived privacy
|
||||
facts, manifest actions, and `appActions`. With valid context it additionally
|
||||
renders the catalog brand/home and a switcher at the accessible navigation
|
||||
landmark `Toolbox applications`. Destinations are ordinary links, so switching
|
||||
performs full-page navigation. On missing, cross-origin, invalid, or unavailable
|
||||
context the shell quietly remains usable in standalone mode; `onContextError` is
|
||||
optional observability.
|
||||
The shell always renders a centered app identity, version, derived privacy
|
||||
facts, standard Help and source controls, Personalize, Apps, manifest actions,
|
||||
and legacy `appActions`. With valid context it additionally renders the catalog
|
||||
brand/home and available app destinations. Destinations are ordinary links, so
|
||||
switching performs full-page navigation. On missing, cross-origin, invalid, or
|
||||
unavailable context the shell quietly remains usable in standalone mode;
|
||||
`onContextError` is optional observability.
|
||||
|
||||
CSS custom properties prefixed with `--toolbox-` are the v1 theme surface. The
|
||||
catalog `light`, `dark`, or `system` mode selects the built-in palette.
|
||||
user's `light`, `dark`, or `system` preference is shared with same-origin apps
|
||||
under `TOOLBOX_PREFERENCES_KEY`; the catalog mode is used until a preference is
|
||||
saved. `ToolboxHeader` is also exported for portal-like pages that do not need
|
||||
the complete application shell.
|
||||
|
||||
## Validate a build
|
||||
|
||||
@@ -210,6 +213,8 @@ The main exports are:
|
||||
`fetchToolboxCatalog()`, `loadToolboxCatalog()`, and `loadToolboxContext()`.
|
||||
- URLs: `resolveWebUrl()`, `resolveToolboxApp()`, `requireSameOrigin()`, and
|
||||
`contextualizeToolboxLink()`.
|
||||
- Preferences: `parseToolboxPreferences()`, `readToolboxPreferences()`,
|
||||
`writeToolboxPreferences()`, and `TOOLBOX_PREFERENCES_KEY`.
|
||||
|
||||
See each emitted `.d.ts` file for the complete signatures.
|
||||
|
||||
|
||||
14
package-lock.json
generated
14
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
@@ -3946,7 +3946,7 @@
|
||||
},
|
||||
"packages/contract": {
|
||||
"name": "@add-ideas/toolbox-contract",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -3954,10 +3954,10 @@
|
||||
},
|
||||
"packages/shell-react": {
|
||||
"name": "@add-ideas/toolbox-shell-react",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.2.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -3966,10 +3966,10 @@
|
||||
},
|
||||
"packages/testkit": {
|
||||
"name": "@add-ideas/toolbox-testkit",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.1.1"
|
||||
"@add-ideas/toolbox-contract": "0.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@add-ideas/toolbox-sdk-workspace",
|
||||
"version": "0.1.1",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"description": "A small, framework-neutral toolbox contract with a React application shell.",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
packages/contract/src/preferences.ts
Normal file
94
packages/contract/src/preferences.ts
Normal 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)),
|
||||
);
|
||||
}
|
||||
63
packages/contract/test/preferences.test.ts
Normal file
63
packages/contract/test/preferences.test.ts
Normal 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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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')\"",
|
||||
|
||||
@@ -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,46 +169,79 @@ 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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">
|
||||
{app.actions?.map((action) => {
|
||||
{otherActions?.map((action) => {
|
||||
const href = safeHref(action.url, appManifestUrl);
|
||||
return href ? (
|
||||
<a key={action.id} href={href}>
|
||||
@@ -169,62 +251,20 @@ export function AppShell({
|
||||
})}
|
||||
{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>
|
||||
);
|
||||
|
||||
233
packages/shell-react/src/ToolboxHeader.tsx
Normal file
233
packages/shell-react/src/ToolboxHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')\"",
|
||||
|
||||
Reference in New Issue
Block a user