feat: add PWA install icon and uppercase branding

This commit is contained in:
Federico Jaramillo Martinez
2026-05-23 20:50:45 +02:00
parent 428f7bb8c2
commit 1546143038
38 changed files with 248 additions and 211 deletions
+10 -10
View File
@@ -203,14 +203,14 @@ function describeServiceShell(): string {
if (shell.fallback) {
return shell.detected === undefined
? "could not detect a supported login shell; using bash"
: `detected ${shell.detected}; using bash because Pi Web currently supports bash, zsh, and fish`;
: `detected ${shell.detected}; using bash because PI WEB currently supports bash, zsh, and fish`;
}
return shell.detected === undefined ? shell.name : `${shell.name} (${shell.detected})`;
}
function sessiondUnit(executables: ServiceExecutables): string {
return `[Unit]
Description=Pi Web session daemon
Description=PI WEB session daemon
[Service]
Type=simple
@@ -226,7 +226,7 @@ WantedBy=default.target
function webUnit(options: InstallOptions, executables: ServiceExecutables): string {
const configEnvironment = options.config === undefined ? "" : `Environment="PI_WEB_CONFIG=${systemdEscape(resolve(options.config))}"\n`;
return `[Unit]
Description=Pi Web server
Description=PI WEB server
After=${sessiondServiceName}
Wants=${sessiondServiceName}
@@ -254,7 +254,7 @@ async function install(args: string[]): Promise<void> {
const options = parseInstallOptions(args);
const executables = resolveServiceExecutables();
console.log("Running Pi Web install preflight checks...");
console.log("Running PI WEB install preflight checks...");
console.log(`Service shell: ${describeServiceShell()}`);
if (!runChecks(installPreflightChecks(executables))) {
printPathSetupAdvice();
@@ -271,7 +271,7 @@ async function install(args: string[]): Promise<void> {
run("systemctl", ["--user", "enable", "--now", sessiondServiceName], { check: true });
run("systemctl", ["--user", "enable", "--now", webServiceName], { check: true });
console.log(`\nPi Web is installed and starting.`);
console.log(`\nPI WEB is installed and starting.`);
console.log(`Config: ${configPath}`);
console.log(`Open: http://${options.host === "0.0.0.0" ? "127.0.0.1" : options.host}:${options.port}`);
@@ -296,7 +296,7 @@ async function uninstall(): Promise<void> {
await rm(join(serviceDir, webServiceName), { force: true });
await rm(join(serviceDir, sessiondServiceName), { force: true });
run("systemctl", ["--user", "daemon-reload"]);
console.log("Pi Web systemd user services removed.");
console.log("PI WEB systemd user services removed.");
}
function serviceAction(action: "start" | "stop" | "restart" | "status"): void {
@@ -384,10 +384,10 @@ function printPathSetupAdvice(): void {
console.log(" Do not rely only on ~/.bashrc or prompt hooks for tools needed by services or agents.");
} else if (shell.name === "zsh") {
console.log(" Detected zsh. Put PATH setup for node/version managers/tools in ~/.zprofile, not only ~/.zshrc.");
console.log(" Avoid relying on prompt hooks; Pi Web services run non-interactive login shells.");
console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
} else {
console.log(" Detected fish. Prefer universal PATH setup such as `fish_add_path -U ...` for tools needed by services or agents.");
console.log(" Avoid relying on prompt hooks; Pi Web services run non-interactive login shells.");
console.log(" Avoid relying on prompt hooks; PI WEB services run non-interactive login shells.");
}
}
@@ -408,14 +408,14 @@ function doctor(): void {
if (!ok) {
console.log("\nIf a command works in your terminal but fails here, make sure your service shell login files set PATH the same way.");
console.log("If a bundled entrypoint is not accessible, reinstall or update the Pi Web package.");
console.log("If a bundled entrypoint is not accessible, reinstall or update the PI WEB package.");
printPathSetupAdvice();
process.exitCode = 1;
}
}
function help(): void {
console.log(`Pi Web
console.log(`PI WEB
Usage:
pi-web install [--host 127.0.0.1] [--port 8504] [--config ~/.config/pi-web/config.json]
+3
View File
@@ -4,7 +4,10 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>PI WEB</title>
<meta name="theme-color" content="#0d1117" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<style>
:root {
color-scheme: dark;
Binary file not shown.

After

Width:  |  Height:  |  Size: 452 B

+1 -1
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title desc">
<title id="title">piweb mark</title>
<title id="title">PI WEB mark</title>
<desc id="desc">A horizontal brand bar.</desc>
<style>
.bar { fill: #00f0d8; }

Before

Width:  |  Height:  |  Size: 391 B

After

Width:  |  Height:  |  Size: 392 B

+24
View File
@@ -0,0 +1,24 @@
{
"name": "PI WEB",
"short_name": "PI WEB",
"description": "Remote web UI and browser control plane for persistent Pi Coding Agent sessions.",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0d1117",
"theme_color": "#0d1117",
"icons": [
{
"src": "/pwa-icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any maskable"
},
{
"src": "/pwa-icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 487 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+4 -4
View File
@@ -359,9 +359,9 @@ function optionalPiWebInstallationInfo(value: unknown): PiWebInstallationInfo |
if (value === undefined) return undefined;
const record = requireRecord(value);
const kind = requireString(record, "kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid Pi Web installation kind");
if (kind !== "pi-package" && kind !== "npm-global" && kind !== "local" && kind !== "unknown") throw new Error("Invalid PI WEB installation kind");
const scope = record["scope"];
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid Pi Web installation scope");
if (scope !== undefined && scope !== "user" && scope !== "project") throw new Error("Invalid PI WEB installation scope");
return {
kind,
...optionalField("path", optionalString(record, "path")),
@@ -400,12 +400,12 @@ function parsePiWebStatusMessage(value: unknown): PiWebStatusMessage {
}
function parsePiWebServiceComponent(value: unknown): PiWebServiceComponent {
if (value !== "web" && value !== "sessiond") throw new Error("Invalid Pi Web service component");
if (value !== "web" && value !== "sessiond") throw new Error("Invalid PI WEB service component");
return value;
}
function parsePiWebStatusSeverity(value: unknown): PiWebStatusSeverity {
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid Pi Web status severity");
if (value !== "info" && value !== "warning" && value !== "error") throw new Error("Invalid PI WEB status severity");
return value;
}
+5 -5
View File
@@ -218,7 +218,7 @@ export class PiWebApp extends LitElement {
try {
this.setState({ piWebStatus: await piWebApi.piWebStatus() });
} catch (error) {
console.warn("Failed to refresh Pi Web status", error);
console.warn("Failed to refresh PI WEB status", error);
}
}
@@ -431,7 +431,7 @@ export class PiWebApp extends LitElement {
});
return html`
<header>
<strong>Pi Web</strong>
<strong>PI WEB</strong>
<button title="Show Actions" aria-label="Show Actions" @click=${() => { this.setState({ actionPaletteOpen: true }); }}>Actions</button>
</header>
<project-list
@@ -517,7 +517,7 @@ export class PiWebApp extends LitElement {
if (this.state.isLoadingProjects) {
return {
title: "Loading projects…",
body: "Looking for projects you have added to Pi Web.",
body: "Looking for projects you have added to PI WEB.",
};
}
if (project === undefined) {
@@ -603,13 +603,13 @@ export class PiWebApp extends LitElement {
try {
this.plugins.register(registration);
} catch (error) {
console.warn(`Failed to register Pi Web plugin ${registration.id}`, error);
console.warn(`Failed to register PI WEB plugin ${registration.id}`, error);
}
}
this.applyPreferredTheme(false);
this.requestUpdate();
} catch (error) {
console.warn("Failed to load external Pi Web plugins", error);
console.warn("Failed to load external PI WEB plugins", error);
}
}
+1 -1
View File
@@ -92,7 +92,7 @@ export class ProjectList extends LitElement {
private close(project: Project) {
this.openMenuProjectId = undefined;
if (confirm(`Close ${project.name}?\n\nThis only removes it from Pi Web; it will not change the project folder.`)) this.onClose?.(project);
if (confirm(`Close ${project.name}?\n\nThis only removes it from PI WEB; it will not change the project folder.`)) this.onClose?.(project);
}
static override styles = listStyles;
+1 -1
View File
@@ -43,7 +43,7 @@ export function createCoreActions(): PluginAction[] {
{
id: "theme.select",
title: "Select Theme",
description: "Choose the Pi Web color theme",
description: "Choose the PI WEB color theme",
group: "Preferences",
run: (context) => { context.openThemePicker(); },
},
+1 -1
View File
@@ -4,7 +4,7 @@ import { createCoreWorkspacePanels } from "./panels";
export const corePlugin: PiWebPlugin = {
apiVersion: 1,
name: "Pi Web Core",
name: "PI WEB Core",
activate: () => ({
contributions: {
actions: createCoreActions(),
+1 -1
View File
@@ -21,7 +21,7 @@ export async function loadExternalPlugins(manifestUrl = "/pi-web-plugins/manifes
const plugin = parsePluginModule(module, moduleUrl);
registrations.push({ id: entry.id, plugin });
} catch (error) {
console.warn(`Failed to load Pi Web plugin ${entry.module}`, error);
console.warn(`Failed to load PI WEB plugin ${entry.module}`, error);
}
}
return registrations;
+1 -1
View File
@@ -120,7 +120,7 @@ describe("PluginRegistry", () => {
expect(new Set(shortcuts.map(([, shortcut]) => shortcut)).size).toBe(shortcuts.length);
});
it("collects built-in Pi Web themes from an in-app plugin", () => {
it("collects built-in PI WEB themes from an in-app plugin", () => {
const registry = new PluginRegistry();
registry.register({ id: "themes", plugin: themePackPlugin });
+9 -9
View File
@@ -116,30 +116,30 @@ const piWebLightTokens = {
export const themePackPlugin: PiWebPlugin = {
apiVersion: 1,
name: "Pi Web Themes",
name: "PI WEB Themes",
activate: () => ({
contributions: {
themes: [
{
id: "pi-web-dark",
name: "Pi Web Dark",
description: "Dark Pi Web palette.",
name: "PI WEB Dark",
description: "Dark PI WEB palette.",
order: 10,
colorScheme: "dark",
tokens: piWebDarkTokens,
},
{
id: "pi-web-light",
name: "Pi Web Light",
description: "Light Pi Web palette.",
name: "PI WEB Light",
description: "Light PI WEB palette.",
order: 20,
colorScheme: "light",
tokens: piWebLightTokens,
},
{
id: "classic",
name: "Pi Web Classic",
description: "The original Pi Web dark palette.",
name: "PI WEB Classic",
description: "The original PI WEB dark palette.",
order: 30,
colorScheme: "dark",
tokens: classicTokens,
@@ -148,8 +148,8 @@ export const themePackPlugin: PiWebPlugin = {
themePairs: [
{
id: "pi-web",
name: "Pi Web",
description: "Follow the system light/dark preference with Pi Web themes.",
name: "PI WEB",
description: "Follow the system light/dark preference with PI WEB themes.",
order: 10,
light: "pi-web-light",
dark: "pi-web-dark",
+4 -4
View File
@@ -41,9 +41,9 @@ const tokens = {
} satisfies ThemeTokens;
const themes = [
theme("pi-web-dark", "Pi Web Dark", "dark"),
theme("pi-web-light", "Pi Web Light", "light"),
theme("classic", "Pi Web Classic", "dark"),
theme("pi-web-dark", "PI WEB Dark", "dark"),
theme("pi-web-light", "PI WEB Light", "light"),
theme("classic", "PI WEB Classic", "dark"),
];
const themePairs: QualifiedThemePairContribution[] = [
@@ -51,7 +51,7 @@ const themePairs: QualifiedThemePairContribution[] = [
id: "themes:pi-web",
pluginId: "themes",
localId: "pi-web",
name: "Pi Web",
name: "PI WEB",
light: "themes:pi-web-light",
dark: "themes:pi-web-dark",
},
+4 -4
View File
@@ -46,7 +46,7 @@ export function loadPiWebConfig(options: LoadOptions = {}): LoadedPiWebConfig {
if (!existsSync(path)) return { path, exists: false, config: {} };
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
if (!isRecord(parsed)) throw new Error(`Pi Web config must be a JSON object: ${path}`);
if (!isRecord(parsed)) throw new Error(`PI WEB config must be a JSON object: ${path}`);
return { path, exists: true, config: parsePiWebConfig(parsed, path) };
}
@@ -78,20 +78,20 @@ function parsePiWebConfig(value: Record<string, unknown>, path: string): PiWebCo
}
function parseString(value: unknown, key: string, path: string): string {
if (typeof value !== "string" || value === "") throw new Error(`Pi Web config ${key} must be a non-empty string: ${path}`);
if (typeof value !== "string" || value === "") throw new Error(`PI WEB config ${key} must be a non-empty string: ${path}`);
return value;
}
function parsePort(value: unknown, key: string, path = "environment"): number {
const port = typeof value === "number" ? value : typeof value === "string" && value !== "" ? Number(value) : NaN;
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`Pi Web config ${key} must be an integer from 1 to 65535: ${path}`);
if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error(`PI WEB config ${key} must be an integer from 1 to 65535: ${path}`);
return port;
}
function parseAllowedHosts(value: unknown, path: string): string[] | true {
if (value === true) return true;
if (!isNonEmptyStringArray(value)) {
throw new Error(`Pi Web config allowedHosts must be true or an array of non-empty strings: ${path}`);
throw new Error(`PI WEB config allowedHosts must be true or an array of non-empty strings: ${path}`);
}
return value;
}
+1 -1
View File
@@ -59,7 +59,7 @@ describe("buildApp", () => {
expect(emptyListResponse.json<Project[]>()).toEqual([]);
});
it("serves the Pi Web plugin manifest and plugin assets", async () => {
it("serves the PI WEB plugin manifest and plugin assets", async () => {
const manifestResponse = await app.inject({ method: "GET", url: "/pi-web-plugins/manifest.json" });
expect(manifestResponse.statusCode).toBe(200);
expect(manifestResponse.json()).toEqual({ plugins: [{ id: "fake", module: "/pi-web-plugins/fake/plugin.js?v=1", source: "test", scope: "local" }] });
+9 -9
View File
@@ -203,10 +203,10 @@ async function discoverPackageRoot(root: string, configuredPackage: ConfiguredPi
async function discoverPluginEntries(root: string, config: PiWebPackageConfig): Promise<ArraylessPluginRecord[]> {
const plugins: ArraylessPluginRecord[] = [];
for (const entry of config.plugins) {
if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe Pi Web plugin module path for ${entry.id}: ${entry.module}`);
if (!isSafeRelativePath(entry.module)) throw new Error(`Unsafe PI WEB plugin module path for ${entry.id}: ${entry.module}`);
const entryPath = join(root, entry.module);
const entryStat = await stat(entryPath).catch(() => undefined);
if (entryStat?.isFile() !== true) throw new Error(`Pi Web plugin module not found for ${entry.id}: ${entry.module}`);
if (entryStat?.isFile() !== true) throw new Error(`PI WEB plugin module not found for ${entry.id}: ${entry.module}`);
plugins.push({ id: entry.id, root, entryFile: entry.module, version: String(Math.floor(entryStat.mtimeMs)) });
}
return plugins;
@@ -227,24 +227,24 @@ async function readPiWebPackageConfig(root: string): Promise<PiWebPackageConfig
}
function parsePluginEntries(piWeb: Record<string, unknown>, packagePath: string): PiWebPluginEntry[] {
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported Pi Web plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
if (piWeb["plugin"] !== undefined) throw new Error(`Unsupported PI WEB plugin metadata in ${packagePath}: use piWeb.plugins with { id, module } entries`);
const plugins = piWeb["plugins"];
if (plugins === undefined) return [];
if (!Array.isArray(plugins)) throw new Error(`Pi Web plugins must be an array in ${packagePath}`);
if (!Array.isArray(plugins)) throw new Error(`PI WEB plugins must be an array in ${packagePath}`);
return plugins.map((entry, index): PiWebPluginEntry => {
if (!isRecord(entry)) throw new Error(`Pi Web plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
if (!isRecord(entry)) throw new Error(`PI WEB plugin entry ${String(index + 1)} must be an object in ${packagePath}`);
const id = entry["id"];
const module = entry["module"];
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid Pi Web plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid Pi Web plugin module for ${id} in ${packagePath}`);
if (typeof id !== "string" || !pluginIdPattern.test(id)) throw new Error(`Invalid PI WEB plugin id in ${packagePath}: ${String(id)}`);
if (typeof module !== "string" || module === "") throw new Error(`Invalid PI WEB plugin module for ${id} in ${packagePath}`);
return { id, module };
});
}
function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): void {
if (records.has(plugin.id)) {
warnInvalidPlugin(plugin.source, `Duplicate Pi Web plugin id: ${plugin.id}`);
warnInvalidPlugin(plugin.source, `Duplicate PI WEB plugin id: ${plugin.id}`);
return;
}
records.set(plugin.id, plugin);
@@ -252,7 +252,7 @@ function addUnique(records: Map<string, PluginRecord>, plugin: PluginRecord): vo
function warnInvalidPlugin(source: string, error: unknown): void {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Skipping Pi Web plugin from ${source}: ${message}`);
console.warn(`Skipping PI WEB plugin from ${source}: ${message}`);
}
function isSafeRelativePath(path: string): boolean {
+1 -1
View File
@@ -10,7 +10,7 @@ afterEach(() => {
vi.restoreAllMocks();
});
describe("Pi Web status", () => {
describe("PI WEB status", () => {
it("compares semver-shaped CalVer versions", () => {
expect(comparePackageVersions("1.202605.9", "1.202605.8")).toBeGreaterThan(0);
expect(comparePackageVersions("1.202605.8", "1.202605.8")).toBe(0);
+3 -3
View File
@@ -309,8 +309,8 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
messages.push({
id: "update-available",
severity: "info",
title: "Pi Web update available",
body: `Pi Web ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update Pi Web, then restart Pi Web services.`,
title: "PI WEB update available",
body: `PI WEB ${release.latestVersion} is available${installedVersion === undefined ? "" : `; installed version is ${installedVersion}`}. Update PI WEB, then restart PI WEB services.`,
command: commands.update,
});
}
@@ -330,7 +330,7 @@ function buildMessages(components: PiWebStatusResponse["components"], release: P
id: "sessiond-unavailable",
severity: "warning",
title: "Session daemon version unavailable",
body: `Pi Web could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
body: `PI WEB could not check the session daemon version${components.sessiond.error === undefined ? "." : `: ${components.sessiond.error}`}`,
command: "systemctl --user status pi-web-sessiond.service",
});
} else if (components.sessiond.stale) {