-
- {homeHref ? (
-
- {homeLabel}
-
- ) : null}
- {helpAction ? (
-
- ) : null}
+
+
+
+
+
{sourceHref ? (
setOpenPopover(null)}
>
- ) : null}
- {leadingActions}
-
-
-
- {iconUrl ? (
-

- ) : null}
-
-
{title}
- {subtitle ? (
- {subtitle}
- ) : null}
-
-
-
-
- {onPersonalize ? (
-
) : (
-
-
-
- Personalize
-
-
-
Appearance
-
- {THEMES.map((option) => (
-
- ))}
-
-
Saved only in this browser.
-
-
+
)}
-
-
-
- Apps
-
+
+
+ {visiblePopover === "personalize" ? (
+
+ {customPersonalizeContent ? (
+ personalizeContent
+ ) : (
+ <>
+ Appearance
+
+ {THEMES.map((option) => (
+
+ ))}
+
+ Saved only in this browser.
+ >
+ )}
+
+ ) : null}
+
+ {visiblePopover === "apps" ? (
-
+ ) : null}
+
+
+
{title}
+ {subtitle ? (
+ {subtitle}
+ ) : null}
+
+
+ {homeHref ? (
+
setOpenPopover(null)}
+ >
+ {brandContent}
+
+ ) : (
+
{brandContent}
+ )}
- {metadata ?
{metadata}
: null}
+
+ {leadingActions || metadata ? (
+
+ {leadingActions}
+ {metadata ? (
+
{metadata}
+ ) : null}
+
+ ) : null}
);
}
diff --git a/packages/shell-react/src/styles.css b/packages/shell-react/src/styles.css
index 91cd6df..c037f04 100644
--- a/packages/shell-react/src/styles.css
+++ b/packages/shell-react/src/styles.css
@@ -57,6 +57,8 @@
.toolbox-shell {
min-height: 100vh;
+ display: flex;
+ flex-direction: column;
color: var(--toolbox-text);
background: var(--toolbox-background);
font-family: var(--toolbox-font);
@@ -86,24 +88,25 @@
.toolbox-shell__bar {
width: min(100%, 90rem);
- min-height: 4.35rem;
+ height: 4.5rem;
display: grid;
- grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
+ grid-template-columns: minmax(18.75rem, 1fr) minmax(0, 31rem) minmax(
+ 18.75rem,
+ 1fr
+ );
align-items: center;
gap: 1rem;
margin: 0 auto;
padding: 0.65rem 1rem;
}
-.toolbox-shell__side {
+.toolbox-shell__controls {
+ position: relative;
min-width: 0;
display: flex;
align-items: center;
- gap: 0.45rem;
-}
-
-.toolbox-shell__side--right {
- justify-content: flex-end;
+ gap: 0.4rem;
+ justify-self: start;
}
.toolbox-shell__header a {
@@ -115,70 +118,15 @@
color: var(--toolbox-accent-hover);
}
-.toolbox-shell__home {
- 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;
- align-items: center;
- justify-content: center;
- gap: 0.65rem;
- text-align: center;
-}
-
-.toolbox-shell__identity > div {
- min-width: 0;
- display: grid;
- gap: 0.08rem;
-}
-
-.toolbox-shell__icon {
- width: 2rem;
- height: 2rem;
- flex: none;
- 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.73rem;
- line-height: 1.25;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
.toolbox-shell__icon-button,
.toolbox-shell__menu-button,
-.toolbox-shell__actions a {
- min-height: 2.35rem;
+.toolbox-shell__supplemental a {
+ min-height: 2.5rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
- padding: 0.45rem 0.65rem;
+ padding: 0.45rem 0.68rem;
border: 1px solid var(--toolbox-border);
border-radius: var(--toolbox-radius);
background: var(--toolbox-surface);
@@ -192,14 +140,15 @@
}
.toolbox-shell__icon-button {
- width: 2.35rem;
+ width: 2.5rem;
+ min-width: 2.5rem;
+ height: 2.5rem;
padding: 0.45rem;
- border-radius: 50%;
}
.toolbox-shell__icon-button:hover,
.toolbox-shell__menu-button:hover,
-.toolbox-shell__actions a:hover {
+.toolbox-shell__supplemental a:hover {
border-color: color-mix(
in srgb,
var(--toolbox-accent) 45%,
@@ -210,10 +159,17 @@
text-decoration: none;
}
+.toolbox-shell__icon-button:disabled,
+.toolbox-shell__menu-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.48;
+}
+
.toolbox-shell__icon-button:focus-visible,
.toolbox-shell__menu-button:focus-visible,
.toolbox-shell__popover a:focus-visible,
-.toolbox-shell__popover button:focus-visible {
+.toolbox-shell__popover button:focus-visible,
+.toolbox-shell__brand:focus-visible {
outline: 3px solid
color-mix(in srgb, var(--toolbox-focus) 72%, var(--toolbox-accent));
outline-offset: 2px;
@@ -242,32 +198,92 @@
color: #609926 !important;
}
-.toolbox-shell__actions {
+.toolbox-shell__identity {
min-width: 0;
- display: flex;
+ display: grid;
+ gap: 0.12rem;
+ justify-items: center;
+ text-align: center;
+}
+
+.toolbox-shell__name {
+ width: 100%;
+ 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 {
+ width: 100%;
+ overflow: hidden;
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+ line-height: 1.25;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.toolbox-shell__brand {
+ min-width: 0;
+ display: inline-flex;
align-items: center;
- gap: 0.4rem;
+ justify-self: end;
+ gap: 0.58rem;
+ padding: 0.28rem 0.35rem;
+ border-radius: var(--toolbox-radius);
+ color: var(--toolbox-text) !important;
+ font-size: 0.82rem;
+ font-weight: 750;
+ line-height: 1.1;
+ white-space: nowrap;
}
-.toolbox-shell__menu {
- position: relative;
+a.toolbox-shell__brand:hover {
+ background: var(--toolbox-accent-soft);
+ color: var(--toolbox-accent) !important;
}
-.toolbox-shell__menu > summary {
- list-style: none;
+.toolbox-shell__brand-mark {
+ width: 2rem;
+ height: 2rem;
+ display: inline-grid;
+ flex: none;
+ place-items: center;
+ color: var(--toolbox-accent);
}
-.toolbox-shell__menu > summary::-webkit-details-marker {
- display: none;
+.toolbox-shell__brand-mark img,
+.toolbox-shell__brand-mark svg {
+ width: 100%;
+ height: 100%;
+ display: block;
+ object-fit: contain;
+}
+
+.toolbox-shell__brand-mark svg {
+ fill: none;
+ stroke: currentColor;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ stroke-width: 3;
}
.toolbox-shell__popover {
position: absolute;
- top: calc(100% + 0.55rem);
- right: 0;
+ z-index: 1;
+ top: calc(100% + 0.7rem);
+ left: 0;
width: max-content;
min-width: 13rem;
max-width: min(22rem, calc(100vw - 1.5rem));
+ max-height: calc(100vh - 5.5rem);
+ overflow: auto;
padding: 0.55rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.85rem;
@@ -352,76 +368,157 @@
font-weight: 750;
}
-.toolbox-shell__meta {
- min-height: 1.65rem;
+.toolbox-shell__supplemental {
+ min-height: 2rem;
display: flex;
align-items: center;
justify-content: center;
- gap: 0.45rem;
- padding: 0.25rem 1rem 0.45rem;
+ gap: 0.65rem;
+ padding: 0.3rem 1rem 0.45rem;
color: var(--toolbox-muted);
- font-size: 0.7rem;
- text-align: center;
+ font-size: 0.72rem;
+}
+
+.toolbox-shell__supplemental-meta {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+}
+
+.toolbox-shell__actions {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 0.4rem;
}
.toolbox-shell__main {
width: min(100%, 90rem);
+ display: block;
+ flex: 1 0 auto;
margin: 0 auto;
padding: 1rem;
}
-@media (max-width: 48rem) {
- .toolbox-shell__bar {
- min-height: 3.8rem;
- gap: 0.5rem;
- padding-inline: 0.7rem;
- }
+.toolbox-shell__footer {
+ width: min(100%, 90rem);
+ margin: auto auto 0;
+ padding: 0.35rem 1rem 1.25rem;
+ color: var(--toolbox-muted);
+ font-size: 0.72rem;
+}
- .toolbox-shell__home,
- .toolbox-shell__description,
- .toolbox-shell__menu-button > span {
- display: none;
+.toolbox-shell__footer-inner {
+ min-height: 2.25rem;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.75rem 1.25rem;
+ padding-top: 0.7rem;
+ border-top: 1px solid var(--toolbox-border);
+}
+
+.toolbox-shell__footer-meta {
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 0.45rem;
+ text-align: right;
+}
+
+.toolbox-shell__footer a {
+ color: var(--toolbox-accent);
+}
+
+@media (max-width: 72rem) {
+ .toolbox-shell__bar {
+ grid-template-columns:
+ minmax(0, 1fr) minmax(0, min(31rem, calc(100% - 24rem)))
+ minmax(0, 1fr);
+ gap: 0.7rem;
}
.toolbox-shell__menu-button {
- width: 2.35rem;
+ width: 2.5rem;
+ min-width: 2.5rem;
+ height: 2.5rem;
padding: 0.45rem;
- border-radius: 50%;
}
- .toolbox-shell__side {
- gap: 0.3rem;
+ .toolbox-shell__menu-button > span,
+ .toolbox-shell__description {
+ display: none;
+ }
+}
+
+@media (max-width: 40rem) {
+ .toolbox-shell__bar {
+ gap: 0.45rem;
+ padding-inline: 0.65rem;
}
- .toolbox-shell__identity {
- gap: 0.4rem;
- }
-
- .toolbox-shell__icon {
- width: 1.7rem;
- height: 1.7rem;
+ .toolbox-shell__controls {
+ gap: 0.28rem;
}
.toolbox-shell__name {
- max-width: 36vw;
- font-size: 0.92rem;
+ font-size: 0.9rem;
}
- .toolbox-shell__meta {
- display: none;
+ .toolbox-shell__footer-inner {
+ align-items: flex-start;
+ flex-direction: column;
}
- .toolbox-shell__actions {
+ .toolbox-shell__footer-meta {
+ justify-content: flex-start;
+ text-align: left;
+ }
+}
+
+@media (max-width: 32rem) {
+ .toolbox-shell__brand-label {
display: none;
}
}
@media (max-width: 27rem) {
- .toolbox-shell__source {
- display: none;
+ .toolbox-shell__bar {
+ height: 4.1rem;
+ grid-template-columns:
+ minmax(0, 1fr) minmax(0, calc(100vw - 17.9rem))
+ minmax(0, 1fr);
+ gap: 0.2rem;
+ padding-inline: 0.5rem;
+ }
+
+ .toolbox-shell__controls {
+ gap: 0.1rem;
+ }
+
+ .toolbox-shell__icon-button,
+ .toolbox-shell__menu-button {
+ width: 2rem;
+ min-width: 2rem;
+ height: 2rem;
+ min-height: 2rem;
+ padding: 0.3rem;
+ }
+
+ .toolbox-shell__brand {
+ padding-inline: 0.1rem;
+ }
+
+ .toolbox-shell__brand-mark {
+ width: 1.75rem;
+ height: 1.75rem;
}
.toolbox-shell__name {
- max-width: 30vw;
+ font-size: 0.82rem;
+ }
+
+ .toolbox-shell__popover {
+ max-width: calc(100vw - 1rem);
}
}
diff --git a/packages/shell-react/test/AppShell.test.tsx b/packages/shell-react/test/AppShell.test.tsx
index b040eba..ff1d9c0 100644
--- a/packages/shell-react/test/AppShell.test.tsx
+++ b/packages/shell-react/test/AppShell.test.tsx
@@ -1,11 +1,17 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import {
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+ within,
+} 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";
+import { AppShell, ToolboxHeader } from "../src/index.js";
const currentApp: ToolboxAppManifest = {
schemaVersion: 1,
@@ -112,16 +118,40 @@ describe("AppShell", () => {
expect(
document.querySelector("[data-toolbox-context='standalone']"),
).toBeInTheDocument();
+ const controls = screen.getByLabelText("Toolbox controls");
expect(
- screen.queryByRole("navigation", { name: "Toolbox applications" }),
+ Array.from(controls.children, (element) =>
+ element.getAttribute("aria-label"),
+ ),
+ ).toEqual([
+ "Personalize",
+ "Apps",
+ "Source for PDF Workbench on Gitea",
+ "Help",
+ ]);
+ fireEvent.click(screen.getByRole("button", { name: "Apps" }));
+ expect(
+ screen.getByRole("navigation", { name: "Toolbox applications" }),
).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();
+ expect(
+ screen.queryByRole("navigation", { name: "Toolbox applications" }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Personalize" }),
+ ).toHaveTextContent("Personalize");
+ expect(screen.getByRole("button", { name: "Apps" })).toHaveTextContent(
+ "Apps",
+ );
+ expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
+ expect(screen.getByText("add·ideas Toolbox").closest("a")).toBeNull();
+ expect(document.querySelector(".toolbox-shell__footer")).toContainElement(
+ screen.getByLabelText("Version 1.2.3"),
+ );
});
it("loads same-origin context and creates full-page contextual switch links", async () => {
@@ -140,7 +170,13 @@ describe("AppShell", () => {
,
);
- const navigation = await screen.findByRole("navigation", {
+ await waitFor(() =>
+ expect(
+ document.querySelector("[data-toolbox-context='connected']"),
+ ).toBeInTheDocument(),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "Apps" }));
+ const navigation = screen.getByRole("navigation", {
name: "Toolbox applications",
});
expect(navigation).toBeInTheDocument();
@@ -152,10 +188,14 @@ describe("AppShell", () => {
expect(destination.searchParams.get("toolbox")).toBe(
"https://tools.example.test/toolbox.catalog.json",
);
- expect(screen.getByRole("link", { name: "add·ideas" })).toHaveAttribute(
- "href",
- "https://tools.example.test/",
- );
+ expect(
+ screen.getByRole("link", { name: "add·ideas Toolbox" }),
+ ).toHaveAttribute("href", "https://tools.example.test/");
+ expect(
+ screen
+ .getByRole("link", { name: "add·ideas Toolbox" })
+ .querySelector("img"),
+ ).toHaveAttribute("src", "https://tools.example.test/favicon.svg");
expect(
document.querySelector("[data-toolbox-context='connected']"),
).toBeInTheDocument();
@@ -183,8 +223,9 @@ describe("AppShell", () => {
code: "cross-origin",
});
expect(fetch).not.toHaveBeenCalled();
+ fireEvent.click(screen.getByRole("button", { name: "Apps" }));
expect(
- screen.queryByRole("navigation", { name: "Toolbox applications" }),
+ screen.getByRole("navigation", { name: "Toolbox applications" }),
).toHaveTextContent("Open this app from Toolbox");
});
@@ -236,7 +277,7 @@ describe("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: "Personalize" }));
fireEvent.click(screen.getByRole("button", { name: "Light" }));
expect(shell).toHaveAttribute("data-toolbox-theme", "light");
expect(
@@ -244,10 +285,19 @@ describe("AppShell", () => {
).toMatchObject({ theme: "light" });
});
- it("resolves a relative explicit manifest URL against the deployed page", async () => {
+ it("resolves manifest-relative header links without rendering an app icon", async () => {
render(
{
,
);
- expect(document.querySelector(".toolbox-shell__icon")).toHaveAttribute(
- "src",
- "https://tools.example.test/nested/pdf/icon.svg",
+ expect(
+ screen.getByRole("link", { name: "Source for PDF Workbench on Gitea" }),
+ ).toHaveAttribute("href", "https://tools.example.test/nested/pdf/source/");
+ expect(document.querySelector(".toolbox-shell__icon")).toBeNull();
+ });
+
+ it("uses one accessible controlled popover for Personalize and Apps", () => {
+ const onHelp = vi.fn();
+ render(
+
Full preferences}
+ helpAction={{ onClick: onHelp }}
+ sourceHref="https://git.example.test/pdf-tools"
+ apps={[
+ {
+ id: "xslt",
+ name: "XSLT Workbench",
+ href: "#xslt",
+ },
+ ]}
+ />,
);
+
+ const personalize = screen.getByRole("button", { name: "Personalize" });
+ const apps = screen.getByRole("button", { name: "Apps" });
+ expect(
+ screen.getByRole("group", { name: "Toolbox controls" }),
+ ).toBeInTheDocument();
+ expect(personalize).toHaveAttribute("aria-label", "Personalize");
+ expect(apps).toHaveAttribute("aria-label", "Apps");
+ expect(apps).not.toHaveAttribute("aria-haspopup");
+
+ fireEvent.click(personalize);
+ expect(
+ screen.getByRole("dialog", { name: "Personalize Toolbox" }),
+ ).toHaveTextContent("Full preferences");
+ expect(personalize).toHaveAttribute("aria-expanded", "true");
+ expect(
+ screen.getByRole("link", { name: "Full preferences" }),
+ ).toHaveFocus();
+
+ fireEvent.click(apps);
+ expect(
+ screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
+ ).not.toBeInTheDocument();
+ const navigation = screen.getByRole("navigation", {
+ name: "Toolbox applications",
+ });
+ expect(navigation).toBeInTheDocument();
+ expect(
+ within(navigation).getByRole("link", { name: "XSLT Workbench" }),
+ ).toHaveFocus();
+
+ fireEvent.keyDown(document, { key: "Escape" });
+ expect(
+ screen.queryByRole("navigation", { name: "Toolbox applications" }),
+ ).not.toBeInTheDocument();
+ expect(apps).toHaveFocus();
+
+ fireEvent.click(personalize);
+ fireEvent.pointerDown(document.body);
+ expect(
+ screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
+ ).not.toBeInTheDocument();
+
+ fireEvent.click(apps);
+ fireEvent.click(
+ within(
+ screen.getByRole("navigation", { name: "Toolbox applications" }),
+ ).getByRole("link", { name: "XSLT Workbench" }),
+ );
+ expect(
+ screen.queryByRole("navigation", { name: "Toolbox applications" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("keeps the legacy onPersonalize callback when no popover content exists", () => {
+ const onPersonalize = vi.fn();
+ const onThemeChange = vi.fn();
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
+ expect(onPersonalize).toHaveBeenCalledOnce();
+ expect(
+ screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("closes Personalize if its content becomes unavailable", () => {
+ const { rerender } = render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: "Personalize" }));
+ expect(
+ screen.getByRole("dialog", { name: "Personalize Toolbox" }),
+ ).toBeInTheDocument();
+
+ rerender();
+ expect(
+ screen.queryByRole("dialog", { name: "Personalize Toolbox" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Personalize" })).toBeDisabled();
});
});
diff --git a/packages/testkit/package.json b/packages/testkit/package.json
index 48a108e..41d8580 100644
--- a/packages/testkit/package.json
+++ b/packages/testkit/package.json
@@ -1,6 +1,6 @@
{
"name": "@add-ideas/toolbox-testkit",
- "version": "0.2.0",
+ "version": "0.2.1",
"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.0"
+ "@add-ideas/toolbox-contract": "0.2.1"
},
"scripts": {
"build": "tsc -p tsconfig.build.json && node -e \"const fs=require('node:fs');fs.chmodSync('dist/cli.js',0o755);fs.copyFileSync('../../LICENSE','LICENSE')\"",