From 4548e5c6a0d1fda28b5415c280e6b65f2997dd87 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Wed, 3 Jun 2026 20:57:38 +0200 Subject: [PATCH] feat: add compact mobile tab icons --- .changeset/mobile-tab-icons.md | 5 + src/client/src/components/PiWebApp.ts | 33 +++-- .../components/appShell/AppMobileMainTabs.ts | 130 ++++++++++++++++-- 3 files changed, 150 insertions(+), 18 deletions(-) create mode 100644 .changeset/mobile-tab-icons.md diff --git a/.changeset/mobile-tab-icons.md b/.changeset/mobile-tab-icons.md new file mode 100644 index 0000000..27e4b79 --- /dev/null +++ b/.changeset/mobile-tab-icons.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Use compact icons and initials for the mobile main tab bar so tabs are easier to fit without losing horizontal scrolling. diff --git a/src/client/src/components/PiWebApp.ts b/src/client/src/components/PiWebApp.ts index 342ed08..ce1b4c6 100644 --- a/src/client/src/components/PiWebApp.ts +++ b/src/client/src/components/PiWebApp.ts @@ -46,7 +46,7 @@ import "./WorkspacePanel"; import type { WorkspacePanelEmptyState } from "./WorkspacePanel"; import "./appShell/AppContextBar"; import "./appShell/AppMobileMainTabs"; -import type { AppMobileMainTab } from "./appShell/AppMobileMainTabs"; +import type { AppMobileMainTab, AppMobileMainTabIcon } from "./appShell/AppMobileMainTabs"; import "./appShell/AppNavigationPanel"; import "./appShell/AppPanelEdgeControl"; import "./appShell/AppRefreshControl"; @@ -650,12 +650,19 @@ export class PiWebApp extends LitElement { return "Select a project and workspace to start a session."; } - private renderMobilePanelTitle(panel: QualifiedWorkspacePanelContribution) { + private mobilePanelBadge(panel: QualifiedWorkspacePanelContribution): unknown { const workspace = this.state.selectedWorkspace; - if (workspace === undefined) return panel.title; - const badge = panel.badge?.(this.createWorkspacePanelContext(workspace)); - if (badge === undefined || badge === "") return panel.title; - return html`${panel.title} ${badge}`; + if (workspace === undefined) return undefined; + return panel.badge?.(this.createWorkspacePanelContext(workspace)); + } + + private mobilePanelIcon(panel: QualifiedWorkspacePanelContribution): AppMobileMainTabIcon | undefined { + switch (panel.id) { + case "core:workspace.files": return "files"; + case "core:workspace.git": return "git"; + case "core:workspace.terminal": return "terminal"; + default: return undefined; + } } private createWorkspacePanelContext(workspace: Workspace): WorkspacePanelContext { @@ -1011,9 +1018,17 @@ export class PiWebApp extends LitElement { private mobileMainTabs(): AppMobileMainTab[] { return [ - { id: "navigation", label: "Sessions", className: "navigation-tab" }, - { id: "chat", label: "Chat" }, - ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => ({ id: panel.id, label: this.renderMobilePanelTitle(panel) })), + { id: "navigation", label: "Sessions", icon: "navigation", className: "navigation-tab" }, + { id: "chat", label: "Chat", icon: "chat" }, + ...this.visibleWorkspacePanels().map((panel): AppMobileMainTab => { + const icon = this.mobilePanelIcon(panel); + return { + id: panel.id, + label: panel.title, + ...(icon === undefined ? {} : { icon }), + badge: this.mobilePanelBadge(panel), + }; + }), ]; } diff --git a/src/client/src/components/appShell/AppMobileMainTabs.ts b/src/client/src/components/appShell/AppMobileMainTabs.ts index 4eaf5fa..44302e3 100644 --- a/src/client/src/components/appShell/AppMobileMainTabs.ts +++ b/src/client/src/components/appShell/AppMobileMainTabs.ts @@ -1,10 +1,14 @@ -import { LitElement, css, html } from "lit"; +import { LitElement, css, html, svg } from "lit"; import { customElement, property, query, state } from "lit/decorators.js"; import type { AppState } from "../../appState"; +export type AppMobileMainTabIcon = "navigation" | "chat" | "files" | "git" | "terminal"; + export interface AppMobileMainTab { id: AppState["mainView"]; - label: unknown; + label: string; + icon?: AppMobileMainTabIcon; + badge?: unknown; className?: string | undefined; } @@ -37,12 +41,20 @@ export class AppMobileMainTabs extends LitElement { } override render() { + const fallbackLabels = this.fallbackLabels(); return html`
- ${this.tabs.map((tab) => html` - - `)} + ${this.tabs.map((tab) => { + const selected = this.selectedView === tab.id; + return html` + + `; + })}
`; @@ -59,6 +71,98 @@ export class AppMobileMainTabs extends LitElement { ].join(" "); } + private tabAriaLabel(tab: AppMobileMainTab): string { + if (typeof tab.badge !== "string" && typeof tab.badge !== "number") return tab.label; + const badge = String(tab.badge).trim(); + return badge === "" ? tab.label : `${tab.label}, ${badge}`; + } + + private isEmptyBadge(badge: unknown): boolean { + return badge === undefined || badge === ""; + } + + private renderTabMark(tab: AppMobileMainTab, fallbackLabels: Map) { + return tab.icon === undefined + ? html`` + : this.renderIcon(tab.icon); + } + + private fallbackLabels(): Map { + const fallbackTabs = this.tabs.filter((tab) => tab.icon === undefined); + const counts = new Map(); + for (const tab of fallbackTabs) { + const initials = this.initialsLabel(tab.label); + counts.set(initials, (counts.get(initials) ?? 0) + 1); + } + + const labels = new Map(); + for (const tab of fallbackTabs) { + const initials = this.initialsLabel(tab.label); + labels.set(tab.id, (counts.get(initials) ?? 0) > 1 ? this.fullFallbackLabel(tab.label) : initials); + } + return labels; + } + + private initialsLabel(label: string): string { + const words = label.match(/[\p{L}\p{N}]+/gu) ?? []; + const initials = words.map((word) => Array.from(word)[0] ?? "").join("").toLocaleUpperCase(); + return initials === "" ? "?" : initials; + } + + private fullFallbackLabel(label: string): string { + const trimmed = label.trim(); + return trimmed === "" ? "?" : trimmed; + } + + private renderIcon(icon: AppMobileMainTabIcon) { + switch (icon) { + case "navigation": + return svg` + + `; + case "chat": + return svg` + + `; + case "files": + return svg` + + `; + case "git": + return svg` + + `; + case "terminal": + return svg` + + `; + } + } + private observeMobileTabs(): void { const mobileTabs = this.mobileTabsElement(); if (this.observedMobileTabs === mobileTabs) return; @@ -98,13 +202,21 @@ export class AppMobileMainTabs extends LitElement { .mobile-tabs-frame::after { right: 0; background: linear-gradient(270deg, color-mix(in srgb, var(--pi-shadow-strong) 55%, transparent) 0%, transparent 100%); } .mobile-tabs-frame.can-scroll-left::before, .mobile-tabs-frame.can-scroll-right::after { opacity: 1; } .mobile-tabs { flex: 1 1 auto; min-width: 0; display: flex; align-items: center; gap: 6px; padding: 8px; overflow-x: auto; overflow-y: hidden; overscroll-behavior-x: contain; scrollbar-width: thin; } - .mobile-tabs button { flex: 0 0 auto; white-space: nowrap; } - .navigation-tab { display: none; } + .mobile-tabs button { flex: 0 0 auto; display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; } + .mobile-tabs .navigation-tab { display: none; } .mobile-tabs button.selected { border-color: var(--pi-accent); background: var(--pi-selection-bg); } - .tab-badge { display: inline-block; min-width: 14px; margin-left: 4px; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } + .tab-icon { flex: 0 0 auto; width: 18px; height: 18px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; } + .tab-fallback { display: none; font-weight: 650; letter-spacing: .01em; pointer-events: none; } + .tab-label { min-width: 0; } + .tab-badge { flex: 0 0 auto; display: inline-block; min-width: 14px; margin-left: 0; border: 1px solid var(--pi-success-border); border-radius: 999px; background: var(--pi-success-surface); color: var(--pi-success); padding: 0 5px; font-size: 11px; line-height: 16px; text-align: center; } button { border: 1px solid var(--pi-border); border-radius: 8px; background: var(--pi-surface); color: var(--pi-text); padding: 7px 9px; cursor: pointer; } @media (max-width: 760px) { - .navigation-tab { display: block; } + .mobile-tabs { gap: 4px; padding: 6px 8px; } + .mobile-tabs button { position: relative; min-width: 40px; height: 36px; justify-content: center; gap: 0; padding: 0 10px; } + .mobile-tabs .navigation-tab { display: inline-flex; } + .tab-fallback { display: inline-block; } + .tab-label { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; border: 0; } + .tab-badge { position: absolute; top: 1px; right: 1px; min-width: 13px; padding: 0 3px; font-size: 10px; line-height: 13px; } } `; }