fix: reverse Toolbox header orientation

This commit is contained in:
2026-07-23 01:47:55 +02:00
parent c296281f89
commit 53c40a61ba
12 changed files with 331 additions and 138 deletions
+1 -1
View File
@@ -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": {
+10 -7
View File
@@ -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.
+2 -2
View File
@@ -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')\"",
+140 -94
View File
@@ -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 (
<section
className="toolbox-shell__preferences-content"
aria-labelledby={headingId}
>
<div className="toolbox-shell__preferences-heading">
<p className="toolbox-shell__preferences-kicker">On this device</p>
<h2 id={headingId}>Personalize your toolbox</h2>
</div>
{storageAvailable === false ? (
<p className="toolbox-shell__preferences-warning" role="status">
Browser storage is unavailable. Changes will last only until this page
closes.
</p>
) : null}
<fieldset className="toolbox-shell__theme-choice">
<legend>Appearance</legend>
<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>
</fieldset>
{children ?? (
<p className="toolbox-shell__preferences-copy">
The Toolbox stores your appearance choice in this browser and does not
transmit it.
</p>
)}
</section>
);
}
export function ToolboxHeader({
title,
subtitle,
@@ -219,11 +277,89 @@ export function ToolboxHeader({
data-toolbox-theme={theme}
>
<div className="toolbox-shell__bar">
{homeHref ? (
<a
className="toolbox-shell__brand"
href={homeHref}
aria-label={homeLabel}
onClick={() => setOpenPopover(null)}
>
{brandContent}
</a>
) : (
<div className="toolbox-shell__brand">{brandContent}</div>
)}
<div className="toolbox-shell__identity">
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
<div
className="toolbox-shell__controls"
role="group"
aria-label="Toolbox controls"
>
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction?.label ?? "Help"}
title={
helpAction?.title ??
helpAction?.label ??
(helpAction ? "Help" : "Help unavailable")
}
disabled={!helpAction}
onClick={() => {
setOpenPopover(null);
helpAction?.onClick();
}}
>
<HelpIcon />
</button>
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
href={sourceHref}
target="_blank"
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</button>
)}
<button
ref={appsButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Apps"
title="Apps"
aria-expanded={visiblePopover === "apps"}
aria-controls={visiblePopover === "apps" ? appsId : undefined}
onClick={() =>
setOpenPopover((current) => (current === "apps" ? null : "apps"))
}
>
<AppsIcon />
<span>Apps</span>
</button>
<button
ref={personalizeButtonRef}
className="toolbox-shell__menu-button"
@@ -260,64 +396,6 @@ export function ToolboxHeader({
<span>Personalize</span>
</button>
<button
ref={appsButtonRef}
className="toolbox-shell__menu-button"
type="button"
aria-label="Apps"
title="Apps"
aria-expanded={visiblePopover === "apps"}
aria-controls={visiblePopover === "apps" ? appsId : undefined}
onClick={() =>
setOpenPopover((current) => (current === "apps" ? null : "apps"))
}
>
<AppsIcon />
<span>Apps</span>
</button>
{sourceHref ? (
<a
className="toolbox-shell__icon-button toolbox-shell__source"
href={sourceHref}
target="_blank"
rel="noreferrer"
aria-label={sourceLabel}
title={sourceLabel}
onClick={() => setOpenPopover(null)}
>
<GiteaIcon />
</a>
) : (
<button
className="toolbox-shell__icon-button"
type="button"
aria-label="Source unavailable"
title="Source unavailable"
disabled
>
<GiteaIcon />
</button>
)}
<button
className="toolbox-shell__icon-button"
type="button"
aria-label={helpAction?.label ?? "Help"}
title={
helpAction?.title ??
helpAction?.label ??
(helpAction ? "Help" : "Help unavailable")
}
disabled={!helpAction}
onClick={() => {
setOpenPopover(null);
helpAction?.onClick();
}}
>
<HelpIcon />
</button>
{visiblePopover === "personalize" ? (
<section
ref={popoverRef}
@@ -331,22 +409,10 @@ export function ToolboxHeader({
{customPersonalizeContent ? (
personalizeContent
) : (
<>
<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>
</>
<ToolboxPersonalizePanel
theme={theme}
onThemeChange={(option) => onThemeChange?.(option)}
/>
)}
</section>
) : null}
@@ -387,26 +453,6 @@ export function ToolboxHeader({
</nav>
) : null}
</div>
<div className="toolbox-shell__identity">
<h1 className="toolbox-shell__name">{title}</h1>
{subtitle ? (
<span className="toolbox-shell__description">{subtitle}</span>
) : null}
</div>
{homeHref ? (
<a
className="toolbox-shell__brand"
href={homeHref}
aria-label={homeLabel}
onClick={() => setOpenPopover(null)}
>
{brandContent}
</a>
) : (
<div className="toolbox-shell__brand">{brandContent}</div>
)}
</div>
{leadingActions || metadata ? (
+2 -1
View File
@@ -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";
+85 -12
View File
@@ -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 {
+56 -4
View File
@@ -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(
<ToolboxPersonalizePanel
theme="system"
onThemeChange={onThemeChange}
storageAvailable={false}
>
<p>Portal preference actions</p>
</ToolboxPersonalizePanel>,
);
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(
<AppShell
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@add-ideas/toolbox-testkit",
"version": "0.2.1",
"version": "0.2.2",
"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.2.1"
"@add-ideas/toolbox-contract": "0.2.2"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",