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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 6.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>`,
|
||||
})),
|
||||
});
|
||||
@@ -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")));
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||