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.
This commit is contained in:
+13
-2
@@ -17,6 +17,7 @@ export default function App() {
|
||||
const [theme, setTheme] = useAppearance("system");
|
||||
const [unread, setUnread] = useState<Record<string, number>>({});
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const fullscreenRef = useRef(false);
|
||||
const [backdrop, setBackdrop] = useState<string | null>(null);
|
||||
|
||||
const stageRef = useRef<HTMLDivElement>(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]);
|
||||
|
||||
+7
-2
@@ -6,8 +6,13 @@ import type { Config, Group, WorkApp } from "./types";
|
||||
export const getConfig = () => invoke<Config>("get_config");
|
||||
export const bootstrap = () => invoke<void>("bootstrap");
|
||||
|
||||
export const setStage = (x: number, y: number, width: number, height: number) =>
|
||||
invoke<void>("set_stage", { x, y, width, height });
|
||||
export const setStage = (
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
radius: number,
|
||||
) => invoke<void>("set_stage", { x, y, width, height, radius });
|
||||
|
||||
export const setActive = (appId: string) => invoke<void>("set_active", { appId });
|
||||
export const hideStage = () => invoke<void>("hide_stage");
|
||||
|
||||
+36
-9
@@ -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<Record<string, string>> | null = null;
|
||||
/** A glyph's path, and the brand's own colour. */
|
||||
export type BrandIcon = [path: string, hex: string];
|
||||
|
||||
export function loadBrandIcons(): Promise<Record<string, string>> {
|
||||
/** Fetched once, lazily — it is four megabytes and nothing needs it at boot. */
|
||||
let pending: Promise<Record<string, BrandIcon>> | null = null;
|
||||
|
||||
export function loadBrandIcons(): Promise<Record<string, BrandIcon>> {
|
||||
pending ??= fetch("/brand-icons.json")
|
||||
.then((r) => (r.ok ? r.json() : {}))
|
||||
.catch(() => ({}));
|
||||
@@ -24,6 +27,9 @@ export function loadBrandIcons(): Promise<Record<string, string>> {
|
||||
* only that it is Google and not which of a dozen products you are looking at.
|
||||
*/
|
||||
const KNOWN: Record<string, string> = {
|
||||
"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<string, string> = {
|
||||
"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<string, string> = {
|
||||
* 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, string>): string | null {
|
||||
export function slugForHost(host: string, icons: Record<string, BrandIcon>): 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, string>): 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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
+19
-9
@@ -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<Record<string, string> | null>(null);
|
||||
const [icons, setIcons] = useState<Record<string, BrandIcon> | 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 ? (
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="#fff"
|
||||
fill={ink}
|
||||
style={{ width: size * 0.58, height: size * 0.58 }}
|
||||
>
|
||||
<path d={path} />
|
||||
<path d={icon[0]} />
|
||||
</svg>
|
||||
) : (
|
||||
/* No glyph for this host — its initial, in the same round tile, so a
|
||||
private tool sits in the row looking like it belongs. */
|
||||
<span
|
||||
className="font-semibold leading-none text-white"
|
||||
style={{ fontSize: Math.max(8, size * 0.5) }}
|
||||
className="font-semibold leading-none"
|
||||
style={{ fontSize: Math.max(8, size * 0.5), color: ink }}
|
||||
>
|
||||
{letter}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user