diff --git a/CHANGELOG.md b/CHANGELOG.md index a80d270..e3668c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,22 @@ All notable changes to the independently versioned Toolbox SDK packages are recorded here. The packages currently share one release version. +## 0.2.2 — 2026-07-23 + +### Added + +- `ToolboxPersonalizePanel` for the portal-style personalization heading, + storage warning, appearance picker, and portal extension content. + +### Changed + +- The invariant top row now fixes the Toolbox brand/home at the far left and + Help, source, Apps, and Personalize at the far right in that order while the + text identity remains geometrically centered. +- Default app personalization now matches the central portal's panel styling; + portal-only catalogue counts and import/export actions remain extensible + content. + ## 0.2.1 — 2026-07-23 ### Added diff --git a/README.md b/README.md index e0f6c61..2e0ada7 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.2.1` contains three publish-ready packages: +Version `0.2.2` contains three publish-ready packages: - `@add-ideas/toolbox-contract` — types, strict v1 runtime parsing, context discovery/loading, resolved URLs, and contextual link helpers. @@ -167,11 +167,11 @@ import "@add-ideas/toolbox-shell-react/styles.css"; ; ``` -The shell's fixed top row renders Personalize, Apps, source, and Help controls -in that order, a geometrically centered text identity, and the Toolbox -brand/home at the far right. Personalize and Apps have labeled desktop controls -and retain their accessible names when compact. The mutually exclusive popovers -close on Escape, outside interaction, and link selection. +The shell's fixed top row renders the Toolbox brand/home at the far left, a +geometrically centered text identity, and Help, source, Apps, and Personalize +controls at the far right in that order. Personalize and Apps have labeled +desktop controls and retain their accessible names when compact. The mutually +exclusive popovers close on Escape, outside interaction, and link selection. `AppShell` moves version, derived privacy facts, manifest actions, and legacy `appActions` into its standard footer, keeping the top row stable in connected @@ -183,7 +183,9 @@ optional observability. For a custom portal-style header, `ToolboxHeader` accepts `personalizeContent` for content hosted inside the shared controlled popover and `brandIconUrl` for -the Toolbox mark. `onPersonalize` remains available for callback-only +the Toolbox mark. `ToolboxPersonalizePanel` provides the shared portal-style +heading, storage warning, and appearance picker, with a child slot for +portal-specific controls. `onPersonalize` remains available for callback-only integrations. The deprecated `iconUrl`, `leadingActions`, and `metadata` props are retained for source compatibility; the centered app icon is intentionally no longer rendered. diff --git a/package-lock.json b/package-lock.json index 0d77dbf..580a56c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.1", + "version": "0.2.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "workspaces": [ "packages/*" @@ -3946,7 +3946,7 @@ }, "packages/contract": { "name": "@add-ideas/toolbox-contract", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "engines": { "node": ">=20" @@ -3954,10 +3954,10 @@ }, "packages/shell-react": { "name": "@add-ideas/toolbox-shell-react", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.1" + "@add-ideas/toolbox-contract": "0.2.2" }, "peerDependencies": { "react": ">=18 <20", @@ -3966,10 +3966,10 @@ }, "packages/testkit": { "name": "@add-ideas/toolbox-testkit", - "version": "0.2.1", + "version": "0.2.2", "license": "Apache-2.0", "dependencies": { - "@add-ideas/toolbox-contract": "0.2.1" + "@add-ideas/toolbox-contract": "0.2.2" }, "bin": { "toolbox-check": "dist/cli.js" diff --git a/package.json b/package.json index cc5e57c..afb4bd1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-sdk-workspace", - "version": "0.2.1", + "version": "0.2.2", "private": true, "description": "A small, framework-neutral toolbox contract with a React application shell.", "license": "Apache-2.0", diff --git a/packages/contract/package.json b/packages/contract/package.json index c86e49d..5fb6c3d 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -1,6 +1,6 @@ { "name": "@add-ideas/toolbox-contract", - "version": "0.2.1", + "version": "0.2.2", "description": "Runtime-validated manifests, catalogs, discovery, and URL helpers for toolbox applications.", "license": "Apache-2.0", "repository": { diff --git a/packages/shell-react/README.md b/packages/shell-react/README.md index 0c838a7..ab0f636 100644 --- a/packages/shell-react/README.md +++ b/packages/shell-react/README.md @@ -13,14 +13,17 @@ 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 fixed top row exposes Personalize, Apps, Gitea source, and Help in that -order, a centered title/subtitle, and a Toolbox home/brand block. Personalize -offers light/dark/system mode by default. Version, privacy, and application -actions render in the standard footer so connected and standalone headers keep -the same geometry. +The fixed top row exposes a Toolbox home/brand block at the far left, a centered +title/subtitle, and Help, Gitea source, Apps, and Personalize at the far right +in that order. Personalize uses the portal-style panel and offers +light/dark/system mode by default. Version, privacy, and application actions +render in the standard footer so connected and standalone headers keep the same +geometry. `ToolboxHeader` is exported separately for custom shells. Its `personalizeContent` prop puts custom preferences inside the shared controlled popover, while `brandIconUrl` supplies a portal favicon; an inline Toolbox cube -is the fallback. Personalize and Apps popovers are mutually exclusive and close -on Escape, outside interaction, or link selection. +is the fallback. `ToolboxPersonalizePanel` shares the portal heading, optional +storage warning, and appearance picker with custom preference content. +Personalize and Apps popovers are mutually exclusive and close on Escape, +outside interaction, or link selection. diff --git a/packages/shell-react/package.json b/packages/shell-react/package.json index 1cb11f5..6a9c8ee 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.2.1", + "version": "0.2.2", "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.2.1" + "@add-ideas/toolbox-contract": "0.2.2" }, "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/ToolboxHeader.tsx b/packages/shell-react/src/ToolboxHeader.tsx index befe5e7..847e189 100644 --- a/packages/shell-react/src/ToolboxHeader.tsx +++ b/packages/shell-react/src/ToolboxHeader.tsx @@ -46,6 +46,13 @@ export interface ToolboxHeaderProps { className?: string | undefined; } +export interface ToolboxPersonalizePanelProps { + theme: ToolboxThemeMode; + onThemeChange: (theme: ToolboxThemeMode) => void; + storageAvailable?: boolean | undefined; + children?: ReactNode | undefined; +} + type OpenPopover = "personalize" | "apps" | null; function HelpIcon() { @@ -117,6 +124,57 @@ function ToolboxBrandMark({ iconUrl }: { iconUrl?: string | undefined }) { const THEMES: readonly ToolboxThemeMode[] = ["system", "light", "dark"]; +export function ToolboxPersonalizePanel({ + theme, + onThemeChange, + storageAvailable, + children, +}: ToolboxPersonalizePanelProps) { + const headingId = useId(); + + return ( +
+
+

On this device

+

Personalize your toolbox

+
+ + {storageAvailable === false ? ( +

+ Browser storage is unavailable. Changes will last only until this page + closes. +

+ ) : null} + +
+ Appearance +
+ {THEMES.map((option) => ( + + ))} +
+
+ + {children ?? ( +

+ The Toolbox stores your appearance choice in this browser and does not + transmit it. +

+ )} +
+ ); +} + export function ToolboxHeader({ title, subtitle, @@ -219,11 +277,89 @@ export function ToolboxHeader({ data-toolbox-theme={theme} >
+ {homeHref ? ( + setOpenPopover(null)} + > + {brandContent} + + ) : ( +
{brandContent}
+ )} + +
+

{title}

+ {subtitle ? ( + {subtitle} + ) : null} +
+
+ + + {sourceHref ? ( + setOpenPopover(null)} + > + + + ) : ( + + )} + + + - - - {sourceHref ? ( - setOpenPopover(null)} - > - - - ) : ( - - )} - - - {visiblePopover === "personalize" ? (
- Appearance -
- {THEMES.map((option) => ( - - ))} -
- Saved only in this browser. - + onThemeChange?.(option)} + /> )}
) : null} @@ -387,26 +453,6 @@ export function ToolboxHeader({ ) : null}
- -
-

{title}

- {subtitle ? ( - {subtitle} - ) : null} -
- - {homeHref ? ( - setOpenPopover(null)} - > - {brandContent} - - ) : ( -
{brandContent}
- )}
{leadingActions || metadata ? ( diff --git a/packages/shell-react/src/index.ts b/packages/shell-react/src/index.ts index 258edcd..0d6e7b5 100644 --- a/packages/shell-react/src/index.ts +++ b/packages/shell-react/src/index.ts @@ -1,8 +1,9 @@ export { AppShell } from "./AppShell.js"; export type { AppShellProps } from "./AppShell.js"; -export { ToolboxHeader } from "./ToolboxHeader.js"; +export { ToolboxHeader, ToolboxPersonalizePanel } from "./ToolboxHeader.js"; export type { ToolboxHeaderAction, ToolboxHeaderApp, ToolboxHeaderProps, + ToolboxPersonalizePanelProps, } from "./ToolboxHeader.js"; diff --git a/packages/shell-react/src/styles.css b/packages/shell-react/src/styles.css index c037f04..764ce47 100644 --- a/packages/shell-react/src/styles.css +++ b/packages/shell-react/src/styles.css @@ -13,6 +13,7 @@ --toolbox-accent-soft: #e9edff; --toolbox-accent-contrast: #ffffff; --toolbox-focus: #63cdbc; + --toolbox-danger: #b4233a; --toolbox-radius: 0.7rem; --toolbox-shadow: 0 10px 34px rgb(24 34 68 / 9%); } @@ -34,6 +35,7 @@ --toolbox-accent-soft: #252f55; --toolbox-accent-contrast: #11182a; --toolbox-focus: #66cdbd; + --toolbox-danger: #ff8fa3; --toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%); } @@ -51,6 +53,7 @@ --toolbox-accent-soft: #252f55; --toolbox-accent-contrast: #11182a; --toolbox-focus: #66cdbd; + --toolbox-danger: #ff8fa3; --toolbox-shadow: 0 14px 42px rgb(0 0 0 / 24%); } } @@ -106,7 +109,7 @@ display: flex; align-items: center; gap: 0.4rem; - justify-self: start; + justify-self: end; } .toolbox-shell__header a { @@ -233,7 +236,7 @@ min-width: 0; display: inline-flex; align-items: center; - justify-self: end; + justify-self: start; gap: 0.58rem; padding: 0.28rem 0.35rem; border-radius: var(--toolbox-radius); @@ -278,7 +281,8 @@ a.toolbox-shell__brand:hover { position: absolute; z-index: 1; top: calc(100% + 0.7rem); - left: 0; + right: 0; + left: auto; width: max-content; min-width: 13rem; max-width: min(22rem, calc(100vw - 1.5rem)); @@ -292,7 +296,12 @@ a.toolbox-shell__brand:hover { color: var(--toolbox-text); } -.toolbox-shell__popover strong { +.toolbox-shell__personalize-popover { + width: min(26rem, calc(100vw - 1.5rem)); + max-width: min(26rem, calc(100vw - 1.5rem)); +} + +.toolbox-shell__popover > strong { display: block; padding: 0.35rem 0.4rem 0.5rem; font-size: 0.75rem; @@ -300,8 +309,8 @@ a.toolbox-shell__brand:hover { letter-spacing: 0.08em; } -.toolbox-shell__popover small, -.toolbox-shell__popover p { +.toolbox-shell__popover > small, +.toolbox-shell__switcher > p { display: block; margin: 0; padding: 0.55rem 0.4rem 0.25rem; @@ -310,17 +319,59 @@ a.toolbox-shell__brand:hover { line-height: 1.4; } +.toolbox-shell__preferences-content { + padding: 0.35rem; + color: var(--toolbox-text); +} + +.toolbox-shell__preferences-heading { + margin-bottom: 0.9rem; +} + +.toolbox-shell__preferences-content h2 { + margin: 0; + color: var(--toolbox-text); + font-family: var(--toolbox-font); + font-size: 1rem; + letter-spacing: -0.025em; +} + +.toolbox-shell__preferences-content .toolbox-shell__preferences-kicker { + margin: 0 0 0.3rem; + padding: 0; + color: var(--toolbox-accent); + font-size: 0.68rem; + font-weight: 750; + letter-spacing: 0.1em; + line-height: 1.4; + text-transform: uppercase; +} + +.toolbox-shell__theme-choice { + margin: 1rem 0; + padding: 0; + border: 0; +} + +.toolbox-shell__theme-choice legend { + margin-bottom: 0.6rem; + color: var(--toolbox-muted); + font-size: 0.78rem; + font-weight: 700; + text-transform: uppercase; +} + .toolbox-shell__theme-options { display: grid; grid-template-columns: repeat(3, 1fr); - gap: 0.25rem; - padding: 0.25rem; - border-radius: 0.65rem; + gap: 0.45rem; + padding: 0.35rem; + border-radius: 0.75rem; background: var(--toolbox-surface-soft); } .toolbox-shell__theme-options button { - padding: 0.5rem 0.45rem; + padding: 0.62rem; border: 0; border-radius: 0.5rem; background: transparent; @@ -333,8 +384,30 @@ a.toolbox-shell__brand:hover { .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; + box-shadow: 0 2px 8px rgb(20 30 60 / 9%); + font-weight: 700; +} + +.toolbox-shell__preferences-content .toolbox-shell__preferences-copy { + margin: 0.9rem 0 0; + padding: 0; + color: var(--toolbox-muted); + font-size: 0.78rem; + line-height: 1.55; +} + +.toolbox-shell__preferences-content .toolbox-shell__preferences-warning { + margin: 0 0 0.75rem; + padding: 0.7rem 0.8rem; + border-radius: 0.6rem; + background: color-mix( + in srgb, + var(--toolbox-danger) 8%, + var(--toolbox-surface) + ); + color: var(--toolbox-danger); + font-size: 0.82rem; + line-height: 1.45; } .toolbox-shell__switcher ul { diff --git a/packages/shell-react/test/AppShell.test.tsx b/packages/shell-react/test/AppShell.test.tsx index ff1d9c0..b4c84bb 100644 --- a/packages/shell-react/test/AppShell.test.tsx +++ b/packages/shell-react/test/AppShell.test.tsx @@ -11,7 +11,11 @@ import { } from "@add-ideas/toolbox-contract"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { AppShell, ToolboxHeader } from "../src/index.js"; +import { + AppShell, + ToolboxHeader, + ToolboxPersonalizePanel, +} from "../src/index.js"; const currentApp: ToolboxAppManifest = { schemaVersion: 1, @@ -124,10 +128,20 @@ describe("AppShell", () => { element.getAttribute("aria-label"), ), ).toEqual([ - "Personalize", - "Apps", - "Source for PDF Workbench on Gitea", "Help", + "Source for PDF Workbench on Gitea", + "Apps", + "Personalize", + ]); + expect( + Array.from( + document.querySelector(".toolbox-shell__bar")?.children ?? [], + (element) => element.className, + ), + ).toEqual([ + "toolbox-shell__brand", + "toolbox-shell__identity", + "toolbox-shell__controls", ]); fireEvent.click(screen.getByRole("button", { name: "Apps" })); expect( @@ -278,6 +292,21 @@ describe("AppShell", () => { const shell = document.querySelector(".toolbox-shell"); expect(shell).toHaveAttribute("data-toolbox-theme", "dark"); fireEvent.click(screen.getByRole("button", { name: "Personalize" })); + expect( + screen.getByRole("heading", { name: "Personalize your toolbox" }), + ).toBeInTheDocument(); + expect(screen.getByText("On this device")).toBeInTheDocument(); + expect( + screen.getByRole("group", { name: "Appearance" }), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Dark" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.queryByText("Pinned")).not.toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: "Export JSON" }), + ).not.toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Light" })); expect(shell).toHaveAttribute("data-toolbox-theme", "light"); expect( @@ -285,6 +314,29 @@ describe("AppShell", () => { ).toMatchObject({ theme: "light" }); }); + it("shares the portal-style personalization frame and extension slot", () => { + const onThemeChange = vi.fn(); + render( + +

Portal preference actions

+
, + ); + + expect( + screen.getByRole("heading", { name: "Personalize your toolbox" }), + ).toBeInTheDocument(); + expect(screen.getByRole("status")).toHaveTextContent( + "Browser storage is unavailable", + ); + expect(screen.getByText("Portal preference actions")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Dark" })); + expect(onThemeChange).toHaveBeenCalledWith("dark"); + }); + it("resolves manifest-relative header links without rendering an app icon", async () => { render(