feat(ui): add Apps & extensions page to the Control UI (#110563)

* feat(ui): add Apps & extensions page

* feat(ui): add per-theme app-art card illustrations

* docs: describe the Apps and extensions page in control-ui

* fix(ui): keep icons.ts under max-lines, drop unused export, regen docs map
This commit is contained in:
Peter Steinberger
2026-07-18 09:52:34 +01:00
committed by GitHub
parent 0d91b07f5b
commit 654560f289
36 changed files with 884 additions and 1 deletions
+1
View File
@@ -10519,6 +10519,7 @@ Do not edit it by hand; run `pnpm docs:map:gen`.
- H2: Language support
- H2: Appearance themes
- H2: Manage plugins
- H2: Apps and extensions
- H2: Sidebar navigation
- H2: New session page
- H2: What it can do (today)
+12
View File
@@ -173,6 +173,18 @@ The page intentionally focuses on inventory, discovery, install, enablement,
and removal. Use [`openclaw plugins`](/cli/plugins) for arbitrary npm, git, or
local-path sources, updates, and advanced plugin configuration.
## Apps and extensions
Open **Apps** from the sidebar **More** menu, the command palette, or the
sidebar agent menu (**Get the apps**), or use `/apps` relative to the
configured Control UI base path. The page collects install links for every
OpenClaw companion surface: the [iOS](/platforms/ios) and
[Android](/platforms/android) apps, the Apple Watch and Wear OS companions
bundled with them, the [macOS](/platforms/macos), [Windows](/platforms/windows),
and [Linux](/platforms/linux) desktop apps, the
[Chrome extension](/tools/chrome-extension), the in-app Plugins hub with
[ClawHub](https://clawhub.ai), and the Discord community and docs.
## Sidebar navigation
The sidebar pins navigation above a scrollable session list. In multi-agent setups every agent appears as a collapsible top-level section; expanding an agent browses its sessions without navigating away from the open chat, and collapsed agents show an unread indicator. Within an agent the list splits into **Pinned**, one built-in section per connected channel (Telegram, Slack, WhatsApp, ...), a built-in **Work** section for sessions bound to a managed worktree or exec node (rows show a `repo ⎇ branch` line plus the node host), custom groups (the session `category`), and **Chats** for the rest. Channel and Work sections classify rows automatically; assigning a session to a custom group always wins. Opening a session moves the selection highlight without reordering the rows. Parent sessions with recent child runs show a disclosure and child count; expand it to inspect nested child sessions, live or terminal status, and runtime without leaving the sidebar. Selecting a child opens its chat and automatically reveals its ancestor path. Child rows stay outside root grouping, pinning, dragging, multi-select, and pagination. Sessions with new activity since they were last read show an unread dot, and opening one marks it read. Cloud-worker lifecycle states use a globe badge; local and reclaimed sessions omit a placement badge because local execution is the default. Each root session row has a context menu (kebab button or right-click) with Pin/Unpin, Mark as unread/read, Rename, Fork, Move to group (including New group and Remove from group), Archive, and Delete; touch layouts keep the direct pin and menu controls visible. Cmd/Ctrl-click toggles root rows into a multi-select and Shift-click extends it across the visible order; opening the menu on a selected row then offers batch actions (Mark N as unread/read, Move N to group, Archive N, Delete N) that apply to every selected session, with a single confirmation for batch delete. Drag a root session onto **Pinned** to pin it, or onto a custom group or **Chats** to move it. Custom group headers can be collapsed, expanded, or dragged to reorder them; group names and their order live in the gateway (`sessions.groups.*`), so they follow you across browsers, while the collapsed state stays in the browser profile. Group headers also have a menu (kebab button or right-click) with Rename group, New group, and Delete group; renaming or deleting a group updates every member session server-side, including archived ones, and deleting a group keeps its sessions and moves them back to Chats. The single **+** in the session-list header opens the New session page (see below). The sort control also has a Group by toggle: Grouped (default) or None for one flat list (Pinned stays separate); the choice is stored in the current browser profile. **Usage**, **Automations**, and **Plugins** are pinned by default; the **More** row opens a menu with every other destination, including plugin-provided tabs. Select **Edit pinned items** in that menu, or right-click the navigation area, to pin or unpin destinations and restore the defaults. The pinned set is stored in the current browser profile and survives reloads.
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

+8
View File
@@ -68,6 +68,14 @@ describe("sidebar pinned routes", () => {
expect(normalizeSidebarPinnedRoutes(["nodes", "usage"])).toEqual(["usage"]);
});
it("keeps the apps promo page unpinned by default but customizable", () => {
expect(SIDEBAR_NAV_ROUTES).toContain("apps");
expect(DEFAULT_SIDEBAR_PINNED_ROUTES).not.toContain("apps");
expect(sidebarMoreRoutes(DEFAULT_SIDEBAR_PINNED_ROUTES)).toContain("apps");
expect(settingsRoutes).not.toContain("apps");
expect(isSettingsNavigationRoute("apps")).toBe(false);
});
it("keeps the plugin manager in customizable workspace routes", () => {
expect(normalizeSidebarPinnedRoutes(["plugins", "usage", "plugins"])).toEqual([
"plugins",
+5
View File
@@ -84,6 +84,7 @@ describe("navigationIconForRoute", () => {
chat: "messageSquare",
custodian: "lobster",
activity: "activity",
apps: "smartphone",
approvals: "shieldCheck",
workboard: "kanban",
worktrees: "folder",
@@ -141,6 +142,7 @@ describe("titleForRoute", () => {
chat: "Chat",
custodian: "OpenClaw",
activity: "Activity",
apps: "Apps",
approvals: "Approvals",
workboard: "Workboard",
worktrees: "Worktrees",
@@ -184,6 +186,7 @@ describe("subtitleForRoute", () => {
chat: "Gateway chat for quick interventions.",
custodian: "System setup and care.",
activity: "Browser-local tool activity summaries.",
apps: "Companion apps for phone, watch, desktop, and browser.",
approvals: "Recent exec, plugin, and system-agent approvals.",
workboard: "Agent work queue and session handoff.",
worktrees: "Isolated agent task checkouts and recovery snapshots.",
@@ -222,6 +225,7 @@ describe("subtitleForRoute", () => {
describe("pathForRoute", () => {
it("returns correct path without base", () => {
expect(pathForRoute("chat")).toBe("/chat");
expect(pathForRoute("apps")).toBe("/apps");
expect(pathForRoute("custodian")).toBe("/custodian");
expect(pathForRoute("connection")).toBe("/settings/connection");
expect(pathForRoute("debug")).toBe("/debug");
@@ -256,6 +260,7 @@ describe("routeIdFromPath", () => {
expect(routeIdFromPath("/settings/connection")).toBe("connection");
expect(routeIdFromPath("/connection")).toBeNull();
expect(routeIdFromPath("/activity")).toBe("activity");
expect(routeIdFromPath("/apps")).toBe("apps");
expect(routeIdFromPath("/sessions")).toBe("sessions");
expect(routeIdFromPath("/debug")).toBe("debug");
expect(routeIdFromPath("/logs")).toBe("logs");
+3
View File
@@ -24,6 +24,7 @@ export const SIDEBAR_NAV_ROUTES = [
"sessions",
"activity",
"plugins",
"apps",
] as const satisfies readonly NavigationRouteId[];
// Routes presented as tabs of the Plugins hub. The sidebar highlights the
@@ -174,6 +175,7 @@ const SETTINGS_TAKEOVER_ROUTES = SETTINGS_NAVIGATION_ROUTES.filter(
const NAVIGATION_ICONS: NavigationItem = {
agents: "bot",
activity: "activity",
apps: "smartphone",
approvals: "shieldCheck",
workboard: "kanban",
worktrees: "folder",
@@ -269,6 +271,7 @@ export function cancelRoutePreload(
const NAVIGATION_COPY: Record<NavigationRouteId, { titleKey: string; subtitleKey: string }> = {
agents: { titleKey: "tabs.agents", subtitleKey: "subtitles.agents" },
activity: { titleKey: "tabs.activity", subtitleKey: "subtitles.activity" },
apps: { titleKey: "tabs.apps", subtitleKey: "subtitles.apps" },
approvals: { titleKey: "tabs.approvals", subtitleKey: "subtitles.approvals" },
workboard: { titleKey: "tabs.workboard", subtitleKey: "subtitles.workboard" },
worktrees: { titleKey: "tabs.worktrees", subtitleKey: "subtitles.worktrees" },
+1
View File
@@ -6,6 +6,7 @@ const APP_ROUTE_DEFINITIONS = {
custodian: { path: "/custodian" },
"new-session": { path: "/new" },
activity: { path: "/activity" },
apps: { path: "/apps" },
agents: { path: "/settings/agents", aliases: ["/agents"] },
channels: { path: "/settings/channels", aliases: ["/channels"] },
connection: { path: "/settings/connection" },
+2
View File
@@ -6,6 +6,7 @@ import { page as aboutPage } from "./pages/about/route.ts";
import { page as activityPage } from "./pages/activity/route.ts";
import { page as agentsPage } from "./pages/agents/route.ts";
import { page as approvalsPage } from "./pages/approvals/route.ts";
import { page as appsPage } from "./pages/apps/route.ts";
import { page as channelsPage } from "./pages/channels/route.ts";
import { page as chatPage } from "./pages/chat/route.ts";
import { pages as configPages } from "./pages/config/route.ts";
@@ -47,6 +48,7 @@ const APP_ROUTE_TREE = [
custodianPage,
newSessionPage,
activityPage,
appsPage,
agentsPage,
approvalsPage,
channelsPage,
+2 -1
View File
@@ -10,7 +10,8 @@ type ControlUiPublicAsset =
| "manifest.webmanifest"
| "sw.js"
| `provider-icons/ProviderIcon-${string}.svg`
| `plugin-art/${string}.webp`;
| `plugin-art/${string}.webp`
| `app-art/${string}.webp`;
export function controlUiPublicAssetPath(
asset: ControlUiPublicAsset,
@@ -230,6 +230,9 @@ export function renderSidebarAgentMenu(params: SidebarAgentMenuParams) {
case `${COMMAND_VALUE_PREFIX}pair-mobile`:
params.onPairMobile();
break;
case `${COMMAND_VALUE_PREFIX}apps`:
params.onNavigate("apps");
break;
}
}}
@wa-after-show=${(event: Event) => {
@@ -331,6 +334,10 @@ export function renderSidebarAgentMenu(params: SidebarAgentMenuParams) {
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.smartphone}</span>
<span class="sidebar-customize-menu__text">${t("nodes.pairing.button")}</span>
</wa-dropdown-item>
<wa-dropdown-item class="sidebar-customize-menu__item" value="command:apps">
<span slot="icon" class="nav-item__icon" aria-hidden="true">${icons.smartphone}</span>
<span class="sidebar-customize-menu__text">${t("agentChip.getApps")}</span>
</wa-dropdown-item>
<wa-dropdown-item
class="sidebar-customize-menu__item sidebar-agent-menu__help"
value="command:help"
+7
View File
@@ -68,6 +68,13 @@ function getPaletteBaseItems(): PaletteItem[] {
category: "navigation",
action: "nav:plugins",
},
{
id: "nav-apps",
label: t("palette.items.apps"),
icon: "smartphone",
category: "navigation",
action: "nav:apps",
},
{
id: "nav-config",
label: t("palette.items.settings"),
+1
View File
@@ -10,6 +10,7 @@ export const agentChip = {
whatCanAgentDo: "What can {name} do?",
help: "Help",
getHelp: "Get help",
getApps: "Get the apps",
discord: "Discord community",
viewChangelog: "View changelog",
agentSettings: "Agent settings",
+62
View File
@@ -1614,6 +1614,7 @@ export const en: TranslationMap = {
tabs: {
agents: "Agents",
activity: "Activity",
apps: "Apps",
approvals: "Approvals",
workboard: "Workboard",
worktrees: "Worktrees",
@@ -1651,6 +1652,7 @@ export const en: TranslationMap = {
subtitles: {
agents: "Workspaces, tools, identities.",
activity: "Browser-local tool activity summaries.",
apps: "Companion apps for phone, watch, desktop, and browser.",
approvals: "Recent exec, plugin, and system-agent approvals.",
workboard: "Agent work queue and session handoff.",
worktrees: "Isolated agent task checkouts and recovery snapshots.",
@@ -2035,6 +2037,65 @@ export const en: TranslationMap = {
gatewayVersionHint:
"Reported by the active Gateway connection; separate from this Control UI build.",
},
appsPage: {
heroTitle: "Take OpenClaw everywhere",
heroTagline:
"Companion apps for your phone, watch, desktop, and browser — plus plugins to extend what your agent can do.",
sectionMobile: "On your phone",
sectionWatch: "On your wrist",
sectionDesktop: "On your desktop",
sectionBrowser: "In your browser",
sectionCommunity: "Community",
badgeBundledIos: "Included with the iOS app",
badgeBundledAndroid: "Included with the Android app",
ctaAppStore: "App Store",
ctaPlayStore: "Google Play",
ctaDownload: "Download",
ctaDocs: "Docs",
ctaSetupGuide: "Setup guide",
ctaOpenPlugins: "Open Plugins",
ctaBrowseClawHub: "Browse ClawHub",
linkDiscord: "Discord community",
linkDocs: "Docs",
cards: {
ios: {
title: "iPhone",
desc: "Chat, talk, approve actions, and share into OpenClaw from iOS.",
},
android: {
title: "Android",
desc: "Your Android phone as a full OpenClaw node — chat, camera, and Canvas.",
},
appleWatch: {
title: "Apple Watch",
desc: "Glanceable chats and quick replies from your wrist.",
},
wearOs: {
title: "Wear OS",
desc: "The Android companion extends OpenClaw to your watch.",
},
macos: {
title: "macOS",
desc: "Menu bar companion for your Gateway — notifications, approvals, quick chat.",
},
windows: {
title: "Windows",
desc: "The Windows companion connects your PC as an OpenClaw node.",
},
linux: {
title: "Linux",
desc: "Native desktop app — .deb and AppImage builds.",
},
chrome: {
title: "Chrome extension",
desc: "Let OpenClaw drive your existing Chrome — tabs, pages, and forms.",
},
plugins: {
title: "Plugins & ClawHub",
desc: "Extend OpenClaw with channels, tools, and skills from the community.",
},
},
},
profilePage: {
offline: "Connect to the gateway to meet your agent.",
loading: "Diving for stats…",
@@ -2822,6 +2883,7 @@ export const en: TranslationMap = {
skills: "Skills",
},
items: {
apps: "Apps",
sessions: "Sessions",
scheduled: "Scheduled",
skills: "Skills",
+31
View File
@@ -0,0 +1,31 @@
import { consume } from "@lit/context";
import { html } from "lit";
import { titleForRoute } from "../../app-navigation.ts";
import type { RouteId } from "../../app-route-paths.ts";
import { applicationContext, type ApplicationContext } from "../../app/context.ts";
import { renderSettingsWorkspace } from "../../components/settings-workspace.ts";
import { OpenClawLightDomElement } from "../../lit/openclaw-element.ts";
import { renderApps } from "./view.ts";
class AppsPage extends OpenClawLightDomElement {
@consume({ context: applicationContext, subscribe: true })
private context!: ApplicationContext;
override render() {
const body = renderApps({
onNavigate: (routeId: RouteId) => this.context.navigate(routeId),
});
return html`
<section class="content-header">
<div>
<div class="page-title">${titleForRoute("apps")}</div>
</div>
</section>
${renderSettingsWorkspace(body)}
`;
}
}
if (!customElements.get("openclaw-apps-page")) {
customElements.define("openclaw-apps-page", AppsPage);
}
+52
View File
@@ -0,0 +1,52 @@
import { html } from "lit";
// Platform marks for the Apps page cards. Kept local (not in the shared
// lucide `icons` set) so the page owns its one-off glyphs and the shared
// icon file stays under its size cap; apps.css targets .icon--filled to
// render solid fills while unclassed glyphs stay stroked.
export const appsBrandIcons = {
watch: html`
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="6" />
<polyline points="12 10 12 12 13 13" />
<path d="m16.13 7.66-.81-4.05a2 2 0 0 0-2-1.61h-2.68a2 2 0 0 0-2 1.61l-.78 4.05" />
<path d="m7.88 16.36.8 4a2 2 0 0 0 2 1.61h2.72a2 2 0 0 0 2-1.61l.81-4.05" />
</svg>
`,
apple: html`
<svg viewBox="0 0 24 24" class="icon--filled">
<path
d="M17.05 12.54c-.03-2.62 2.14-3.88 2.24-3.94-1.22-1.79-3.12-2.03-3.8-2.06-1.61-.16-3.15.95-3.97.95-.82 0-2.08-.93-3.42-.9-1.76.03-3.38 1.02-4.29 2.6-1.83 3.17-.47 7.87 1.31 10.44.87 1.26 1.91 2.67 3.27 2.62 1.31-.05 1.81-.85 3.4-.85 1.58 0 2.03.85 3.42.82 1.41-.02 2.31-1.28 3.17-2.55.99-1.46 1.4-2.87 1.42-2.94-.03-.02-2.73-1.05-2.75-4.19Zm-2.6-7.68c.72-.88 1.21-2.09 1.08-3.3-1.04.04-2.3.69-3.05 1.56-.67.78-1.26 2.02-1.1 3.2 1.16.09 2.34-.59 3.07-1.46Z"
/>
</svg>
`,
android: html`
<svg viewBox="0 0 24 24" class="icon--filled">
<path
d="M17.52 15.05a.98.98 0 1 1 .01-1.96.98.98 0 0 1-.01 1.96m-11.04 0a.98.98 0 1 1 .01-1.96.98.98 0 0 1-.01 1.96m11.39-5.89 1.96-3.39a.4.4 0 0 0-.15-.55.4.4 0 0 0-.55.15l-1.98 3.43A12.03 12.03 0 0 0 12 7.75c-1.85 0-3.6.42-5.15 1.05L4.87 5.37a.4.4 0 0 0-.55-.15.4.4 0 0 0-.15.55l1.96 3.39C2.75 11 .48 14.42.14 18.4h23.72c-.34-3.98-2.61-7.4-5.99-9.24"
/>
</svg>
`,
windows: html`
<svg viewBox="0 0 24 24" class="icon--filled">
<path
d="M3 5.55 10.29 4.5v7.02H3V5.55Zm0 12.9 7.29 1.05v-6.93H3v5.88Zm8.09 1.16L21 21v-8.43h-9.91v7.04Zm0-15.22v7.13H21V3l-9.91 1.39Z"
/>
</svg>
`,
linux: html`
<svg viewBox="0 0 24 24" class="icon--filled">
<path
fill-rule="evenodd"
d="M12 2c-2.76 0-5 2.24-5 5v3.1c0 1.35-.62 2.5-1.32 3.6C4.6 15.4 4 16.9 4 18.3 4 20.9 7.58 22 12 22s8-1.1 8-3.7c0-1.4-.6-2.9-1.68-4.6-.7-1.1-1.32-2.25-1.32-3.6V7c0-2.76-2.24-5-5-5Zm-2 4.5a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm4 0a1 1 0 1 1 0 2 1 1 0 0 1 0-2Zm-2 2.7 1.8 1.3c.3.24.3.7-.02.92L12 12.6l-1.78-1.18a.58.58 0 0 1-.02-.93L12 9.2Z"
/>
</svg>
`,
chrome: html`
<svg viewBox="0 0 24 24" class="icon--filled">
<path
d="M12 2a10 10 0 0 1 8.66 5H12a5 5 0 0 0-4.79 3.57L4.4 5.72A9.98 9.98 0 0 1 12 2Zm9.54 7A10 10 0 0 1 12 22l-.34-.01 4.36-7.55A4.98 4.98 0 0 0 17 12c0-1.13-.37-2.16-1-3h5.54ZM3.35 7.57l4.36 7.56a5 5 0 0 0 6.16 2.24l-2.81 4.87A10 10 0 0 1 3.35 7.57ZM12 8.5a3.5 3.5 0 1 1 0 7 3.5 3.5 0 0 1 0-7Z"
/>
</svg>
`,
} as const;
+12
View File
@@ -0,0 +1,12 @@
import { definePage } from "@openclaw/uirouter";
import { html } from "lit";
export const page = definePage({
id: "apps",
path: "/apps",
component: () =>
import("./apps-page.ts").then(() => ({
header: true,
render: () => html`<openclaw-apps-page></openclaw-apps-page>`,
})),
});
+110
View File
@@ -0,0 +1,110 @@
/* @vitest-environment jsdom */
import { render } from "lit";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "../../i18n/index.ts";
import { renderApps } from "./view.ts";
const EXPECTED_EXTERNAL_HREFS = [
"https://apps.apple.com/app/openclaw-ai-that-does-things/id6780396132",
"https://docs.openclaw.ai/platforms/ios",
"https://play.google.com/store/apps/details?id=ai.openclaw.app",
"https://docs.openclaw.ai/platforms/android",
"https://docs.openclaw.ai/platforms/ios",
"https://docs.openclaw.ai/platforms/android",
"https://github.com/openclaw/openclaw/releases",
"https://docs.openclaw.ai/platforms/macos",
"https://github.com/openclaw/openclaw-windows-node/releases/latest",
"https://docs.openclaw.ai/platforms/windows",
"https://github.com/openclaw/openclaw/releases",
"https://docs.openclaw.ai/platforms/linux",
"https://docs.openclaw.ai/tools/chrome-extension",
"https://clawhub.ai",
"https://discord.gg/clawd",
"https://docs.openclaw.ai",
];
describe("renderApps", () => {
beforeEach(async () => {
document.body.innerHTML = "";
await i18n.setLocale("en");
});
function renderIntoContainer(onNavigate = vi.fn()) {
const container = document.createElement("div");
render(renderApps({ onNavigate }), container);
return container;
}
it("renders the hero and one heading per section", () => {
const container = renderIntoContainer();
expect(container.querySelector(".apps-hero__title")?.textContent).toBe(
"Take OpenClaw everywhere",
);
expect(container.querySelector(".apps-hero__tagline")?.textContent).toContain(
"Companion apps for your phone",
);
const headings = Array.from(container.querySelectorAll(".apps-section__heading")).map(
(heading) => heading.textContent,
);
expect(headings).toEqual([
"On your phone",
"On your wrist",
"On your desktop",
"In your browser",
"Community",
]);
});
it("renders every external link in order with safe target and rel", () => {
const container = renderIntoContainer();
const anchors = Array.from(container.querySelectorAll<HTMLAnchorElement>("a[href]"));
expect(anchors.map((anchor) => anchor.getAttribute("href"))).toEqual(EXPECTED_EXTERNAL_HREFS);
for (const anchor of anchors) {
expect(anchor.getAttribute("target")).toBe("_blank");
expect(anchor.getAttribute("rel")).toContain("noopener");
expect(anchor.getAttribute("rel")).toContain("noreferrer");
}
});
it("navigates to the in-app Plugins hub from the plugins card", () => {
const onNavigate = vi.fn();
const container = renderIntoContainer(onNavigate);
const buttons = Array.from(
container.querySelectorAll<HTMLButtonElement>("button.apps-card__cta"),
);
expect(buttons).toHaveLength(1);
expect(buttons[0]?.textContent).toContain("Open Plugins");
buttons[0]?.click();
expect(onNavigate).toHaveBeenCalledExactlyOnceWith("plugins");
});
it("badges the watch apps as bundled with their phone apps", () => {
const container = renderIntoContainer();
const badges = Array.from(container.querySelectorAll(".apps-card__badge")).map(
(badge) => badge.textContent?.trim(),
);
expect(badges).toEqual(["Included with the iOS app", "Included with the Android app"]);
});
it("renders decorative lazy card art with a per-theme variant for every card", () => {
const container = renderIntoContainer();
const images = Array.from(container.querySelectorAll<HTMLImageElement>(".apps-card__art img"));
expect(images).toHaveLength(18);
for (const image of images) {
expect(image.getAttribute("alt")).toBe("");
expect(image.getAttribute("loading")).toBe("lazy");
}
const lightSrcs = images
.filter((image) => image.classList.contains("apps-card__art-img--light"))
.map((image) => image.getAttribute("src"));
const darkSrcs = images
.filter((image) => image.classList.contains("apps-card__art-img--dark"))
.map((image) => image.getAttribute("src"));
expect(lightSrcs).toHaveLength(9);
for (const src of lightSrcs) {
expect(src).toMatch(/^\/app-art\/[a-z-]+\.webp$/);
}
expect(darkSrcs).toEqual(lightSrcs.map((src) => src?.replace(/\.webp$/, "-dark.webp")));
});
});
+302
View File
@@ -0,0 +1,302 @@
// Control UI view renders the Apps & extensions promo page.
import { html, nothing, type TemplateResult } from "lit";
import type { RouteId } from "../../app-route-paths.ts";
import { inferControlUiPublicAssetPath } from "../../app/public-assets.ts";
import { icons } from "../../components/icons.ts";
import { t } from "../../i18n/index.ts";
import { buildExternalLinkRel, EXTERNAL_LINK_TARGET } from "../../lib/external-link.ts";
import "../../styles/apps.css";
import { brandIcons } from "../about/brand-icons.ts";
import { appsBrandIcons } from "./brand-icons.ts";
type AppsProps = {
onNavigate: (routeId: RouteId) => void;
};
type AppCardCta =
| { kind: "external"; href: string; label: () => string }
| { kind: "internal"; routeId: RouteId; label: () => string };
type AppCard = {
id: string;
/** Two-stop gradient behind the card art; also covers image load latency. */
gradient: readonly [string, string];
icon: TemplateResult;
title: () => string;
desc: () => string;
badge?: () => string;
ctas: readonly AppCardCta[];
};
type AppSection = {
id: string;
label: () => string;
cards: readonly AppCard[];
};
const docsCta = (path: string): AppCardCta => ({
kind: "external",
href: `https://docs.openclaw.ai${path}`,
label: () => t("appsPage.ctaDocs"),
});
const APP_SECTIONS: readonly AppSection[] = [
{
id: "mobile",
label: () => t("appsPage.sectionMobile"),
cards: [
{
id: "ios",
gradient: ["#38bdf8", "#1d4ed8"],
icon: appsBrandIcons.apple,
title: () => t("appsPage.cards.ios.title"),
desc: () => t("appsPage.cards.ios.desc"),
ctas: [
{
kind: "external",
href: "https://apps.apple.com/app/openclaw-ai-that-does-things/id6780396132",
label: () => t("appsPage.ctaAppStore"),
},
docsCta("/platforms/ios"),
],
},
{
id: "android",
gradient: ["#34d399", "#047857"],
icon: appsBrandIcons.android,
title: () => t("appsPage.cards.android.title"),
desc: () => t("appsPage.cards.android.desc"),
ctas: [
{
kind: "external",
href: "https://play.google.com/store/apps/details?id=ai.openclaw.app",
label: () => t("appsPage.ctaPlayStore"),
},
docsCta("/platforms/android"),
],
},
],
},
{
id: "watch",
label: () => t("appsPage.sectionWatch"),
cards: [
{
id: "apple-watch",
gradient: ["#f472b6", "#be185d"],
icon: appsBrandIcons.watch,
title: () => t("appsPage.cards.appleWatch.title"),
desc: () => t("appsPage.cards.appleWatch.desc"),
badge: () => t("appsPage.badgeBundledIos"),
ctas: [docsCta("/platforms/ios")],
},
{
id: "wear-os",
gradient: ["#22d3ee", "#0e7490"],
icon: appsBrandIcons.watch,
title: () => t("appsPage.cards.wearOs.title"),
desc: () => t("appsPage.cards.wearOs.desc"),
badge: () => t("appsPage.badgeBundledAndroid"),
ctas: [docsCta("/platforms/android")],
},
],
},
{
id: "desktop",
label: () => t("appsPage.sectionDesktop"),
cards: [
{
id: "macos",
gradient: ["#a855f7", "#6b21a8"],
icon: appsBrandIcons.apple,
title: () => t("appsPage.cards.macos.title"),
desc: () => t("appsPage.cards.macos.desc"),
ctas: [
{
kind: "external",
href: "https://github.com/openclaw/openclaw/releases",
label: () => t("appsPage.ctaDownload"),
},
docsCta("/platforms/macos"),
],
},
{
id: "windows",
gradient: ["#818cf8", "#4338ca"],
icon: appsBrandIcons.windows,
title: () => t("appsPage.cards.windows.title"),
desc: () => t("appsPage.cards.windows.desc"),
ctas: [
{
kind: "external",
href: "https://github.com/openclaw/openclaw-windows-node/releases/latest",
label: () => t("appsPage.ctaDownload"),
},
docsCta("/platforms/windows"),
],
},
{
id: "linux",
gradient: ["#fbbf24", "#b45309"],
icon: appsBrandIcons.linux,
title: () => t("appsPage.cards.linux.title"),
desc: () => t("appsPage.cards.linux.desc"),
ctas: [
{
kind: "external",
href: "https://github.com/openclaw/openclaw/releases",
label: () => t("appsPage.ctaDownload"),
},
docsCta("/platforms/linux"),
],
},
],
},
{
id: "browser",
label: () => t("appsPage.sectionBrowser"),
cards: [
{
id: "chrome-extension",
gradient: ["#f59e0b", "#ea580c"],
icon: appsBrandIcons.chrome,
title: () => t("appsPage.cards.chrome.title"),
desc: () => t("appsPage.cards.chrome.desc"),
// Installs unpacked via `openclaw browser extension path`; there is no
// Chrome Web Store listing, so the only CTA is the setup guide.
ctas: [
{
kind: "external",
href: "https://docs.openclaw.ai/tools/chrome-extension",
label: () => t("appsPage.ctaSetupGuide"),
},
],
},
{
id: "plugins",
gradient: ["#fb7185", "#9f1239"],
icon: icons.puzzle,
title: () => t("appsPage.cards.plugins.title"),
desc: () => t("appsPage.cards.plugins.desc"),
ctas: [
{ kind: "internal", routeId: "plugins", label: () => t("appsPage.ctaOpenPlugins") },
{
kind: "external",
href: "https://clawhub.ai",
label: () => t("appsPage.ctaBrowseClawHub"),
},
],
},
],
},
];
const COMMUNITY_LINKS: ReadonlyArray<{ href: string; icon: TemplateResult; label: () => string }> =
[
{
href: "https://discord.gg/clawd",
icon: brandIcons.discord,
label: () => t("appsPage.linkDiscord"),
},
{ href: "https://docs.openclaw.ai", icon: icons.book, label: () => t("appsPage.linkDocs") },
];
function renderCta(cta: AppCardCta, index: number, props: AppsProps) {
const className = index === 0 ? "apps-card__cta apps-card__cta--primary" : "apps-card__cta";
if (cta.kind === "internal") {
return html`
<button type="button" class=${className} @click=${() => props.onNavigate(cta.routeId)}>
${cta.label()}
</button>
`;
}
return html`
<a
class=${className}
href=${cta.href}
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
${cta.label()}
</a>
`;
}
function renderAppCard(card: AppCard, props: AppsProps) {
const [from, to] = card.gradient;
return html`
<article class="apps-card">
<div class="apps-card__art" style=${`--apps-art-a:${from};--apps-art-b:${to}`}>
<img
class="apps-card__art-img apps-card__art-img--light"
src=${inferControlUiPublicAssetPath(`app-art/${card.id}.webp`)}
alt=""
loading="lazy"
decoding="async"
/>
<img
class="apps-card__art-img apps-card__art-img--dark"
src=${inferControlUiPublicAssetPath(`app-art/${card.id}-dark.webp`)}
alt=""
loading="lazy"
decoding="async"
/>
</div>
<div class="apps-card__body">
<div class="apps-card__title-row">
<span class="apps-card__icon" aria-hidden="true">${card.icon}</span>
<h3 class="apps-card__title">${card.title()}</h3>
${card.badge ? html`<span class="apps-card__badge">${card.badge()}</span>` : nothing}
</div>
<p class="apps-card__desc">${card.desc()}</p>
<div class="apps-card__ctas">
${card.ctas.map((cta, index) => renderCta(cta, index, props))}
</div>
</div>
</article>
`;
}
function renderSection(section: AppSection, props: AppsProps) {
return html`
<section class="apps-section" aria-label=${section.label()}>
<h2 class="apps-section__heading">${section.label()}</h2>
<div class="apps-grid">${section.cards.map((card) => renderAppCard(card, props))}</div>
</section>
`;
}
function renderCommunity() {
return html`
<section class="apps-section" aria-label=${t("appsPage.sectionCommunity")}>
<h2 class="apps-section__heading">${t("appsPage.sectionCommunity")}</h2>
<nav class="apps-community" aria-label=${t("appsPage.sectionCommunity")}>
${COMMUNITY_LINKS.map(
(link) => html`
<a
class="apps-pill"
href=${link.href}
target=${EXTERNAL_LINK_TARGET}
rel=${buildExternalLinkRel()}
>
<span class="apps-pill__icon" aria-hidden="true">${link.icon}</span>
<span>${link.label()}</span>
</a>
`,
)}
</nav>
</section>
`;
}
export function renderApps(props: AppsProps) {
return html`
<div class="apps-page">
<section class="apps-hero">
<h1 class="apps-hero__title">${t("appsPage.heroTitle")}</h1>
<p class="apps-hero__tagline">${t("appsPage.heroTagline")}</p>
</section>
${APP_SECTIONS.map((section) => renderSection(section, props))} ${renderCommunity()}
</div>
`;
}
+266
View File
@@ -0,0 +1,266 @@
/* Apps & extensions promo page — hero, card grid, and community pills. */
.apps-hero {
display: flex;
flex-direction: column;
align-items: center;
padding: 18px 16px 10px;
text-align: center;
}
.apps-hero__title {
margin: 0;
color: var(--text-strong);
font-size: 30px;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1.15;
}
.apps-hero__tagline {
max-width: 460px;
margin: 6px 0 0;
color: var(--muted);
font-size: var(--control-ui-text-sm);
line-height: 1.45;
}
.apps-section {
margin-top: 26px;
}
/* Mirrors .settings-section__heading typography; cards intentionally do not
sit inside a .settings-group surface (one level of elevation only). */
.apps-section__heading {
margin: 0 0 10px 2px;
color: var(--muted);
font-size: var(--control-ui-text-xs);
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: var(--space-4, 16px);
}
.apps-card {
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--card);
transition:
transform 140ms ease,
border-color 140ms ease;
}
.apps-card:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--border) 55%, var(--text) 45%);
}
.apps-card__art {
aspect-ratio: 16 / 9;
background: linear-gradient(
135deg,
color-mix(in srgb, var(--apps-art-a) 70%, var(--card)),
color-mix(in srgb, var(--apps-art-b) 70%, var(--card))
);
}
.apps-card__art img {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
/* Per-theme art variants; the app root always carries data-theme-mode
(bootstrap.ts), dark stays the fallback to match the dark-default tokens. */
.apps-card__art .apps-card__art-img--light {
display: none;
}
:root[data-theme-mode="light"] .apps-card__art .apps-card__art-img--light {
display: block;
}
:root[data-theme-mode="light"] .apps-card__art .apps-card__art-img--dark {
display: none;
}
.apps-card__body {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
padding: 14px;
}
.apps-card__title-row {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.apps-card__icon {
display: inline-flex;
color: var(--text);
}
.apps-card__title {
margin: 0;
color: var(--text-strong);
font-size: var(--control-ui-text-md, 15px);
font-weight: 600;
}
.apps-card__badge {
margin-inline-start: auto;
padding: 2px 9px;
border: 1px solid var(--border);
border-radius: 999px;
background: color-mix(in srgb, var(--bg-elevated) 55%, var(--card) 45%);
color: var(--muted);
font-size: var(--control-ui-text-xs);
white-space: nowrap;
}
.apps-card__desc {
margin: 0;
color: var(--muted);
font-size: var(--control-ui-text-sm);
line-height: 1.45;
}
.apps-card__ctas {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding-top: 6px;
}
.apps-card__cta {
display: inline-flex;
align-items: center;
padding: 6px 13px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
color: var(--text);
font: inherit;
font-size: var(--control-ui-text-sm);
font-weight: 550;
text-decoration: none;
cursor: pointer;
transition:
transform 140ms ease,
background 140ms ease,
border-color 140ms ease;
}
.apps-card__cta--primary {
border-color: color-mix(in srgb, var(--border) 30%, var(--text) 70%);
background: var(--text-strong);
color: var(--bg);
}
.apps-card__cta:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--border) 40%, var(--text) 60%);
background: var(--bg-hover);
text-decoration: none;
}
.apps-card__cta--primary:hover {
background: var(--text);
color: var(--bg);
}
.apps-card__cta:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.apps-community {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.apps-pill {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 15px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--card);
color: var(--text);
font-size: var(--control-ui-text-sm);
font-weight: 550;
text-decoration: none;
transition:
transform 140ms ease,
background 140ms ease,
border-color 140ms ease;
}
.apps-pill:hover {
transform: translateY(-1px);
border-color: color-mix(in srgb, var(--border) 40%, var(--text) 60%);
background: var(--bg-hover);
text-decoration: none;
}
.apps-pill:focus-visible {
outline: none;
box-shadow: var(--focus-ring);
}
.apps-pill__icon {
display: inline-flex;
}
.apps-card__icon svg,
.apps-pill svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-linecap: round;
stroke-linejoin: round;
stroke-width: 1.8;
}
.apps-card__icon svg.icon--filled,
.apps-pill svg.icon--filled {
fill: currentColor;
stroke: none;
}
@media (prefers-reduced-motion: reduce) {
.apps-card,
.apps-card__cta,
.apps-pill {
transition: none;
}
.apps-card:hover,
.apps-card__cta:hover,
.apps-pill:hover {
transform: none;
}
}
@media (max-width: 560px) {
.apps-hero__title {
font-size: 25px;
}
}