diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e550ed..423db77 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/README.md b/README.md index 1cd6011..9261fe4 100644 --- a/README.md +++ b/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"; Help} + helpAction={{ onClick: () => setHelpOpen(true) }} onContextError={(error) => console.warn(error)} > ; ``` -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. diff --git a/package-lock.json b/package-lock.json index 12832e3..c0c7947 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index b61238e..c82c966 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/contract/README.md b/packages/contract/README.md index 9e39c93..d1487cb 100644 --- a/packages/contract/README.md +++ b/packages/contract/README.md @@ -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. diff --git a/packages/contract/package.json b/packages/contract/package.json index 05052e8..9ca0324 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -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": { diff --git a/packages/contract/src/index.ts b/packages/contract/src/index.ts index 2cdf448..5bed3a4 100644 --- a/packages/contract/src/index.ts +++ b/packages/contract/src/index.ts @@ -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, diff --git a/packages/contract/src/preferences.ts b/packages/contract/src/preferences.ts new file mode 100644 index 0000000..42d14ed --- /dev/null +++ b/packages/contract/src/preferences.ts @@ -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 = { + 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; + 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)), + ); +} diff --git a/packages/contract/test/preferences.test.ts b/packages/contract/test/preferences.test.ts new file mode 100644 index 0000000..a88b274 --- /dev/null +++ b/packages/contract/test/preferences.test.ts @@ -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(); + 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", + }); + }); +}); diff --git a/packages/shell-react/README.md b/packages/shell-react/README.md index 3927689..f437779 100644 --- a/packages/shell-react/README.md +++ b/packages/shell-react/README.md @@ -4,7 +4,7 @@ import { AppShell } from "@add-ideas/toolbox-shell-react"; import "@add-ideas/toolbox-shell-react/styles.css"; -Help}> + setHelpOpen(true) }}> ; ``` @@ -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. diff --git a/packages/shell-react/package.json b/packages/shell-react/package.json index 1bcf687..89212b8 100644 --- a/packages/shell-react/package.json +++ b/packages/shell-react/package.json @@ -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')\"", diff --git a/packages/shell-react/src/AppShell.tsx b/packages/shell-react/src/AppShell.tsx index 1294d7d..5d9d69c 100644 --- a/packages/shell-react/src/AppShell.tsx +++ b/packages/shell-react/src/AppShell.tsx @@ -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( + 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((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 ( - - - {context ? ( - - {context.catalog.catalog.theme.brand} - - ) : null} - - - - - {app.name} - - {app.description} - + + {otherActions?.map((action) => { + const href = safeHref(action.url, appManifestUrl); + return href ? ( + + {action.label} + + ) : null; + })} + {appActions} - - - - {app.actions?.map((action) => { - const href = safeHref(action.url, appManifestUrl); - return href ? ( - - {action.label} - - ) : null; - })} - {appActions} - - - + ) : undefined + } + metadata={ + <> v{app.version} + ยท {privacyHref ? ( {privacyText(app)} ) : ( {privacyText(app)} )} - - - {context && enabledApps.length > 0 ? ( - - Apps - - - {enabledApps.map((entry) => { - if (entry.kind === "external") { - return ( - - - {entry.name} - - - ); - } - const manifest = entry.app.manifest; - return ( - - - {manifest.name} - - - ); - })} - - - - ) : null} - - + > + } + /> {children} ); diff --git a/packages/shell-react/src/ToolboxHeader.tsx b/packages/shell-react/src/ToolboxHeader.tsx new file mode 100644 index 0000000..89ac4db --- /dev/null +++ b/packages/shell-react/src/ToolboxHeader.tsx @@ -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 ( + + + + + + ); +} + +function GiteaIcon() { + return ( + + + + + ); +} + +function PersonalizeIcon() { + return ( + + + + + ); +} + +function AppsIcon() { + return ( + + + + + + + ); +} + +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 ( + + + + {homeHref ? ( + + {homeLabel} + + ) : null} + {helpAction ? ( + + + + ) : null} + {sourceHref ? ( + + + + ) : null} + {leadingActions} + + + + {iconUrl ? ( + + ) : null} + + {title} + {subtitle ? ( + {subtitle} + ) : null} + + + + + {onPersonalize ? ( + + + Personalize + + ) : ( + + + + Personalize + + + Appearance + + {THEMES.map((option) => ( + onThemeChange?.(option)} + > + {option.slice(0, 1).toUpperCase() + option.slice(1)} + + ))} + + Saved only in this browser. + + + )} + + + + + Apps + + + {allAppsHref ? ( + + All apps + + ) : null} + {apps.length > 0 ? ( + + {apps.map((entry) => ( + + + {entry.name} + + + ))} + + ) : ( + Open this app from Toolbox to switch tools here. + )} + + + + + {metadata ? {metadata} : null} + + ); +} diff --git a/packages/shell-react/src/index.ts b/packages/shell-react/src/index.ts index 2cdf94c..258edcd 100644 --- a/packages/shell-react/src/index.ts +++ b/packages/shell-react/src/index.ts @@ -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"; diff --git a/packages/shell-react/src/styles.css b/packages/shell-react/src/styles.css index 6b308e9..91cd6df 100644 --- a/packages/shell-react/src/styles.css +++ b/packages/shell-react/src/styles.css @@ -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; + } } diff --git a/packages/shell-react/test/AppShell.test.tsx b/packages/shell-react/test/AppShell.test.tsx index a31ae41..b040eba 100644 --- a/packages/shell-react/test/AppShell.test.tsx +++ b/packages/shell-react/test/AppShell.test.tsx @@ -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( Help} 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( + + PDF + , + ); + + 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( + + PDF + , + ); + + expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute( + "src", + "https://tools.example.test/nested/pdf/icon.svg", + ); + }); }); diff --git a/packages/testkit/package.json b/packages/testkit/package.json index 69fbea8..48a108e 100644 --- a/packages/testkit/package.json +++ b/packages/testkit/package.json @@ -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')\"",
Open this app from Toolbox to switch tools here.