diff --git a/docs/superpowers/specs/2026-09-01-work-app-design.md b/docs/superpowers/specs/2026-09-01-work-app-design.md index f870751..1fd864e 100644 --- a/docs/superpowers/specs/2026-09-01-work-app-design.md +++ b/docs/superpowers/specs/2026-09-01-work-app-design.md @@ -261,10 +261,18 @@ returned a sign-in page's icon for anything behind a login, nothing at all for a host, and cached both answers past any way of asking again. A "refresh icons" button could not fix that, because the staleness was not local. -Colours are Tailwind 500s, chosen by hashing the host rather than at random — you find -things by their colour, and a colour that moved every launch would be worse than none. +Each tile carries the **brand's own colour**, which Simple Icons publishes alongside the +glyph — Gmail red, Drive blue, Chat green, Gemini violet. The glyph is drawn black or +white depending on the tile's perceived brightness, because brand colours are chosen to +look right rather than to carry a white mark: GitHub and Notion are near-black, Snapchat +is pure yellow, and a fixed white glyph loses one end of that range. -The build turns Simple Icons' 15MB of SVG files into one 4.5MB map of slug to path data, +A host with no mark falls back to a Tailwind 500 chosen by hashing the host, so it is +still stable — you find things by their colour, and one that moved every launch would be +worse than none. + +The build turns Simple Icons' 15MB of SVG files into one 4.5MB map of slug to path and +hex, written to `public/` so it is fetched once at runtime rather than parsed into the JS bundle at every start. The bundle stays at 233KB. The map is generated by `npm run icons`, which `npm run build` runs, so it is never committed and never stale. @@ -277,7 +285,16 @@ says only that it is Google, not which of a dozen products. ## The window There is no title bar and no toolbar, so in a normal window the shell keeps a 6px margin -around itself. That margin is the only part of the window that is not a web page, and +around itself, and the traffic lights are pushed in to sit within it rather than crowding +the corner — which also sets the depth of the nav's drag strip. + +An app's corners are rounded on its own layer, not by the container around it: an app is a +native view sitting on top rather than something the shell lays out, so a `rounded-xl` on +its parent does nothing and it overhangs the curve. Only the right pair is rounded; the +left edge butts against the nav, and rounding it would cut a notch out of the middle of +the window. + +The margin That margin is the only part of the window that is not a web page, and therefore the only place left to grab it by. Full screen has no use for it and gets the room back. diff --git a/scripts/build-brand-icons.mjs b/scripts/build-brand-icons.mjs index b75c32d..276668a 100644 --- a/scripts/build-brand-icons.mjs +++ b/scripts/build-brand-icons.mjs @@ -1,29 +1,34 @@ /** - * Turns Simple Icons' 3,400 SVG files into one map of slug → path data. + * Turns Simple Icons into one map of slug → [path, brand colour]. * * The package ships a file per icon and 15MB of SVG wrapper around what is - * really a single `d` attribute each. This keeps the attribute and throws the - * rest away, so the app carries about a megabyte instead of fifteen. + * really a single `d` attribute each, plus a separate metadata file carrying + * the brand's own hex. This joins the two and throws the rest away. * * The output goes to public/ rather than src/: it is fetched once at runtime * instead of being parsed into the JS bundle at every startup. * - * Run `npm run icons` after upgrading simple-icons. The output is committed, - * so a build never depends on this having been run. + * `npm run build` runs this, so the result is never committed and never stale. */ -import { readdirSync, readFileSync, writeFileSync } from "node:fs"; +import { readFileSync, readdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -const SRC = "node_modules/simple-icons/icons"; +const ICONS = "node_modules/simple-icons/icons"; +const META = "node_modules/simple-icons/data/simple-icons.json"; const OUT = "public/brand-icons.json"; +const hexes = new Map(); +for (const icon of JSON.parse(readFileSync(META, "utf8"))) { + hexes.set(icon.slug, icon.hex); +} + const icons = {}; -for (const file of readdirSync(SRC)) { +for (const file of readdirSync(ICONS)) { if (!file.endsWith(".svg")) continue; - const svg = readFileSync(join(SRC, file), "utf8"); - const match = /\sd="([^"]+)"/.exec(svg); - if (!match) continue; - icons[file.slice(0, -4)] = match[1]; + const slug = file.slice(0, -4); + const path = /\sd="([^"]+)"/.exec(readFileSync(join(ICONS, file), "utf8"))?.[1]; + if (!path) continue; + icons[slug] = [path, hexes.get(slug) ?? "64748B"]; } writeFileSync(OUT, JSON.stringify(icons)); diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 13217c0..fcdcad3 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -15,6 +15,8 @@ pub type Stage = (f64, f64, f64, f64); pub struct AppState { pub dir: PathBuf, + /// Corner radius the window's inner frame is currently drawn with. + pub radius: Mutex, pub config: Mutex, pub active: Mutex>, pub stage: Mutex, @@ -65,6 +67,7 @@ pub fn build_state(handle: &AppHandle) -> Result { let config = config::load(&dir)?; Ok(AppState { dir, + radius: Mutex::new(12.0), config: Mutex::new(config), active: Mutex::new(None), stage: Mutex::new((240.0, 38.0, 800.0, 600.0)), @@ -88,13 +91,15 @@ pub fn set_stage( y: f64, width: f64, height: f64, + radius: f64, app: AppHandle, state: State<'_, AppState>, ) { let stage = (x, y, width.max(0.0), height.max(0.0)); *state.stage.lock().unwrap() = stage; + *state.radius.lock().unwrap() = radius; let active = state.active.lock().unwrap().clone(); - webviews::set_stage(&app, active.as_deref(), stage); + webviews::set_stage(&app, active.as_deref(), stage, radius); } /// Creates every app's webview: the active one first, the rest staggered. @@ -122,6 +127,7 @@ pub fn bootstrap(app: AppHandle, state: State<'_, AppState>) -> Result<(), Strin if let Some(id) = &first { if let Some(a) = cfg.app(id) { webviews::create(&app, a, &cfg, stage)?; + webviews::set_corner_radius(&app, &a.id, *state.radius.lock().unwrap()); *state.active.lock().unwrap() = Some(id.clone()); webviews::show_only(&app, Some(id), &cfg, stage); } diff --git a/src-tauri/src/webviews.rs b/src-tauri/src/webviews.rs index 7d94d8e..4e4262f 100644 --- a/src-tauri/src/webviews.rs +++ b/src-tauri/src/webviews.rs @@ -419,6 +419,41 @@ pub fn snapshot(_: &AppHandle, _: &str) -> Option { None } +/// Rounds an app's right-hand corners to match the window's inner frame. +/// +/// A `rounded-xl` on the container around it does nothing: the app is a native +/// view sitting on top, not something the shell lays out, so it keeps its own +/// square corners and overhangs the curve. The rounding has to go on its layer. +/// +/// Only the right pair — the left edge butts against the nav, and rounding it +/// would cut a notch out of the middle of the window. +#[cfg(target_os = "macos")] +pub fn set_corner_radius(handle: &AppHandle, app_id: &str, radius: f64) { + use objc2::runtime::AnyObject; + + let Some(wv) = handle.get_webview(&label_for(app_id)) else { return }; + let _ = wv.with_webview(move |platform| unsafe { + let view = platform.inner() as *mut AnyObject; + if view.is_null() { + return; + } + let _: () = objc2::msg_send![view, setWantsLayer: true]; + let layer: *mut AnyObject = objc2::msg_send![view, layer]; + if layer.is_null() { + return; + } + // kCALayerMaxXMinYCorner | kCALayerMaxXMaxYCorner — both right corners, + // whichever way round the layer's Y axis happens to run. + let right_corners: usize = (1 << 1) | (1 << 3); + let _: () = objc2::msg_send![layer, setCornerRadius: radius]; + let _: () = objc2::msg_send![layer, setMaskedCorners: right_corners]; + let _: () = objc2::msg_send![layer, setMasksToBounds: radius > 0.0]; + }); +} + +#[cfg(not(target_os = "macos"))] +pub fn set_corner_radius(_: &AppHandle, _: &str, _: f64) {} + /// Turns on WKWebView's two-finger back and forward swipes. /// /// wry supports it but Tauri does not expose it, so it is set on the native @@ -534,6 +569,10 @@ pub fn create( Ok(()) } +fn radius(handle: &AppHandle) -> f64 { + *handle.state::().radius.lock().unwrap() +} + /// Tells a page whether it is the one being looked at. /// /// Separate from the view's real visibility on purpose — see the note in @@ -558,6 +597,7 @@ pub fn show_only( let _ = wv.set_size(LogicalSize::new(stage.2, stage.3)); let _ = wv.set_zoom(app.zoom); let _ = wv.show(); + set_corner_radius(handle, &app.id, radius(handle)); set_page_visibility(handle, &app.id, Some(app.id.as_str()) == app_id); } if let Some(id) = app_id { @@ -570,12 +610,18 @@ pub fn show_only( /// All of them, not just the visible one: they are all really on screen now, /// stacked, so one left at a stale size would show around the edges of the /// active app the moment the window grew. -pub fn set_stage(handle: &AppHandle, active: Option<&str>, stage: (f64, f64, f64, f64)) { +pub fn set_stage( + handle: &AppHandle, + active: Option<&str>, + stage: (f64, f64, f64, f64), + radius: f64, +) { let cfg = handle.state::().cfg(); for app in &cfg.apps { let Some(wv) = handle.get_webview(&label_for(&app.id)) else { continue }; let _ = wv.set_position(LogicalPosition::new(stage.0, stage.1)); let _ = wv.set_size(LogicalSize::new(stage.2, stage.3)); + set_corner_radius(handle, &app.id, radius); } if let Some(id) = active { raise_to_front(handle, id); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e40313..da7b2a4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -21,7 +21,7 @@ "center": true, "titleBarStyle": "Overlay", "hiddenTitle": true, - "trafficLightPosition": { "x": 19, "y": 18 } + "trafficLightPosition": { "x": 26, "y": 24 } } ], "security": { "csp": null } diff --git a/src/App.tsx b/src/App.tsx index 9f9b4a5..3cee35c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ export default function App() { const [theme, setTheme] = useAppearance("system"); const [unread, setUnread] = useState>({}); const [fullscreen, setFullscreen] = useState(false); + const fullscreenRef = useRef(false); const [backdrop, setBackdrop] = useState(null); const stageRef = useRef(null); @@ -34,7 +35,11 @@ export default function App() { it and gives the room back. */ useEffect(() => { const w = getCurrentWindow(); - const check = () => void w.isFullscreen().then(setFullscreen); + const check = () => + void w.isFullscreen().then((f) => { + fullscreenRef.current = f; + setFullscreen(f); + }); check(); const un = w.onResized(check); return () => { @@ -62,7 +67,9 @@ export default function App() { const el = stageRef.current; if (!el) return; const r = el.getBoundingClientRect(); - void api.setStage(r.x, r.y, r.width, r.height); + // The radius goes with it: an app is a native view, so the container's + // rounded corners cannot clip it and its own layer has to be told. + void api.setStage(r.x, r.y, r.width, r.height, fullscreenRef.current ? 0 : 12); }, []); useLayoutEffect(() => { @@ -87,6 +94,10 @@ export default function App() { void api.bootstrap(); }, [config, report]); + useEffect(() => { + report(); + }, [fullscreen, report]); + useEffect(() => { if (activeId) void api.setActive(activeId); }, [activeId]); diff --git a/src/api.ts b/src/api.ts index 63a8f7e..3e097b1 100644 --- a/src/api.ts +++ b/src/api.ts @@ -6,8 +6,13 @@ import type { Config, Group, WorkApp } from "./types"; export const getConfig = () => invoke("get_config"); export const bootstrap = () => invoke("bootstrap"); -export const setStage = (x: number, y: number, width: number, height: number) => - invoke("set_stage", { x, y, width, height }); +export const setStage = ( + x: number, + y: number, + width: number, + height: number, + radius: number, +) => invoke("set_stage", { x, y, width, height, radius }); export const setActive = (appId: string) => invoke("set_active", { appId }); export const hideStage = () => invoke("hide_stage"); diff --git a/src/brandIcons.ts b/src/brandIcons.ts index fa7a8d9..b5c6ef4 100644 --- a/src/brandIcons.ts +++ b/src/brandIcons.ts @@ -7,10 +7,13 @@ * local, so they are the same every time. */ -/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */ -let pending: Promise> | null = null; +/** A glyph's path, and the brand's own colour. */ +export type BrandIcon = [path: string, hex: string]; -export function loadBrandIcons(): Promise> { +/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */ +let pending: Promise> | null = null; + +export function loadBrandIcons(): Promise> { pending ??= fetch("/brand-icons.json") .then((r) => (r.ok ? r.json() : {})) .catch(() => ({})); @@ -24,6 +27,9 @@ export function loadBrandIcons(): Promise> { * only that it is Google and not which of a dozen products you are looking at. */ const KNOWN: Record = { + "gemini.google.com": "googlegemini", + "aistudio.google.com": "googlegemini", + "notebooklm.google.com": "googlegemini", "mail.google.com": "gmail", "drive.google.com": "googledrive", "chat.google.com": "googlechat", @@ -47,6 +53,14 @@ const KNOWN: Record = { "x.com": "x", "app.asana.com": "asana", "app.slack.com": "slack", + "claude.ai": "claude", + "chatgpt.com": "openai", + "chat.openai.com": "openai", + "linear.app": "linear", + "app.clickup.com": "clickup", + "app.hubspot.com": "hubspot", + "admin.shopify.com": "shopify", + "dash.cloudflare.com": "cloudflare", "mail.proton.me": "protonmail", }; @@ -56,7 +70,7 @@ const KNOWN: Record = { * Tries the registrable name first — `example.odoo.com` is Odoo, not Aputure — * because a self-hosted tool is nearly always on a subdomain of the vendor. */ -export function slugForHost(host: string, icons: Record): string | null { +export function slugForHost(host: string, icons: Record): string | null { const h = host.toLowerCase().replace(/^www\./, ""); if (KNOWN[h]) return KNOWN[h] in icons ? KNOWN[h] : null; @@ -72,12 +86,11 @@ export function slugForHost(host: string, icons: Record): string } /** - * The palette the marks are drawn on. + * The fallback palette, for a host with no brand mark of its own. * - * Tailwind's 500s, minus the ones that turn to mud behind a white glyph. The - * choice is a hash rather than a random number so an app keeps its colour — - * you learn where things are by their colour, and a colour that moved every - * launch would be worse than none. + * Tailwind's 500s. The choice is a hash rather than a random number so an app + * keeps its colour — you learn where things are by their colour, and one that + * moved every launch would be worse than none. */ const PALETTE = [ "#0ea5e9", // sky @@ -113,3 +126,17 @@ export function hostOf(url: string): string { return ""; } } + +/** + * Black or white for a glyph, whichever the tile can actually be read against. + * + * Brand colours are chosen to look right, not to carry a white glyph: GitHub + * and Notion are near-black, Snapchat is pure yellow. Perceived brightness + * decides, so both ends of that range stay legible. + */ +export function glyphOn(hex: string): string { + const n = Number.parseInt(hex.replace("#", ""), 16); + const [r, g, b] = [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + // Rec. 601 luma: green reads far brighter than blue at the same value. + return (r * 299 + g * 587 + b * 114) / 1000 > 150 ? "#0f172a" : "#ffffff"; +} diff --git a/src/components/Nav.tsx b/src/components/Nav.tsx index 778b9ca..54465c3 100644 --- a/src/components/Nav.tsx +++ b/src/components/Nav.tsx @@ -23,8 +23,13 @@ interface Props { */ const RAIL = 72; const PANEL = 240; -/** The strip the traffic lights sit in. Draggable, since there is no title bar. */ -const TITLEBAR = 36; +/** + * The strip the traffic lights sit in. Draggable, since there is no title bar. + * + * Deep enough to give them room: with the window's own frame around it too, + * anything shallower leaves them crowding the corner. + */ +const TITLEBAR = 48; export const navWidth = (collapsed: boolean) => (collapsed ? RAIL : PANEL); diff --git a/src/components/ui.tsx b/src/components/ui.tsx index e166eba..cf886bd 100644 --- a/src/components/ui.tsx +++ b/src/components/ui.tsx @@ -7,7 +7,14 @@ */ import { useEffect, useState, type ReactNode } from "react"; -import { colourFor, hostOf, loadBrandIcons, slugForHost } from "../brandIcons"; +import { + colourFor, + glyphOn, + hostOf, + loadBrandIcons, + slugForHost, + type BrandIcon, +} from "../brandIcons"; /** Every control in the app is this tall, so a row of mixed ones lines up. */ export const CONTROL_H = "h-[30px]"; @@ -218,7 +225,7 @@ export function Favicon({ name: string; size?: number; }) { - const [icons, setIcons] = useState | null>(null); + const [icons, setIcons] = useState | null>(null); useEffect(() => { let live = true; @@ -230,8 +237,11 @@ export function Favicon({ const host = hostOf(url); const slug = icons ? slugForHost(host, icons) : null; - const path = slug ? icons?.[slug] : undefined; - const colour = colourFor(host || name); + const icon = slug ? icons?.[slug] : undefined; + // The brand's own colour where there is one; a stable stand-in where there + // is not, so an unbranded tool still reads as a distinct thing in the list. + const colour = icon ? `#${icon[1]}` : colourFor(host || name); + const ink = glyphOn(colour); const letter = name.trim().charAt(0).toUpperCase() || "?"; return ( @@ -240,20 +250,20 @@ export function Favicon({ style={{ width: size, height: size, background: colour }} aria-hidden > - {path ? ( + {icon ? ( - + ) : ( /* No glyph for this host — its initial, in the same round tile, so a private tool sits in the row looking like it belongs. */ {letter}