From ffedb72b83df51a0dfbac4bb02d4dce30c2e3060 Mon Sep 17 00:00:00 2001 From: Vincent Date: Tue, 1 Sep 2026 14:51:20 +0200 Subject: [PATCH] Brand colours on the marks; round the app's corners; move the lights in Simple Icons publishes each brand's own hex next to its glyph, and the build was throwing it away. Tiles now carry it - Gmail red, Drive blue, Chat green, Gemini violet - and the glyph is drawn black or white by the tile's perceived brightness, since brand colours are picked 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. An app's right corners are now rounded on its own layer. The container's rounded-xl could never have clipped them: an app is a native view sitting on top, not something the shell lays out, so it kept square corners and overhung the curve. Only the right pair - the left edge butts against the nav, and rounding it would notch the middle of the window. The traffic lights move to (26, 24) and the drag strip deepens to match, so they sit inside the window's margin instead of against its corner. Adds Gemini, Claude, ChatGPT, Linear, ClickUp, HubSpot, Shopify and Cloudflare to the host table. --- .../specs/2026-09-01-work-app-design.md | 25 ++++++++-- scripts/build-brand-icons.mjs | 29 ++++++----- src-tauri/src/commands.rs | 8 +++- src-tauri/src/webviews.rs | 48 ++++++++++++++++++- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 15 +++++- src/api.ts | 9 +++- src/brandIcons.ts | 45 +++++++++++++---- src/components/Nav.tsx | 9 +++- src/components/ui.tsx | 28 +++++++---- 10 files changed, 175 insertions(+), 43 deletions(-) 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}