Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions packages/webapp/src/avatar-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** Google serves an account photo in whatever shape the URL's size directive
* asks for. Without the `-c` (crop) suffix a tall original stays tall, and a
* tall bitmap inside a round avatar reads as a mis-crop. Ask Google for the
* centered square instead; every avatar on the page is round.
* Non-Google URLs pass through untouched. */
export function squareAvatarUrl(avatarUrl: string): string {
let host = "";
try {
host = new URL(avatarUrl).hostname;
} catch {
return avatarUrl;
}
if (!host.endsWith(".googleusercontent.com")) return avatarUrl;
if (/=s\d+(?:-c)?$/u.test(avatarUrl)) return avatarUrl.replace(/=s(\d+)(?:-c)?$/u, "=s$1-c");
return `${avatarUrl}=s128-c`;
}
1 change: 1 addition & 0 deletions packages/webapp/src/files-drive.css
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,7 @@
.drive-avatar--lg { width: 34px; height: 34px; font-size: 13px; }

.drive-avatar img {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
Expand Down
3 changes: 2 additions & 1 deletion packages/webapp/src/files/DriveAvatar.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { squareAvatarUrl } from '../avatar-url';
import { personInitial } from './drive-model';

export function DriveAvatar({
Expand All @@ -17,7 +18,7 @@ export function DriveAvatar({
title={name}
aria-hidden="true"
>
{avatarUrl ? <img src={avatarUrl} alt="" /> : personInitial(name)}
{avatarUrl ? <img src={squareAvatarUrl(avatarUrl)} alt="" /> : personInitial(name)}
</span>
);
}
16 changes: 7 additions & 9 deletions packages/webapp/src/shell/WorkspaceStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { TenantMe } from '../api-adapter';
import type { CloudWorkspaceModel } from '../workspace-store';
import { DriveGlyph, PlusGlyph } from './StripIcons';
import { workspaceTileStyle } from './workspace-tile';
import { squareAvatarUrl } from '../avatar-url';

/** The tile legend: initials when the name has several words, otherwise its
* first two letters. `design-team` reads DT and `engineering` reads EN, as the
Expand Down Expand Up @@ -253,11 +254,11 @@ export function WorkspaceStrip({

{tileMenu !== null && menuWorkspace !== undefined && (
<>
<button
{/* A div, not a button: with no global button reset, a fullscreen
* button paints the UA's opaque button face over the whole app.
* Same shape as WebAppHeader's tab-menu backdrop. */}
<div
className="webapp-session-backdrop"
type="button"
aria-label="Close workspace menu"
tabIndex={-1}
onMouseDown={() => setTileMenu(null)}
/>
<div
Expand Down Expand Up @@ -316,11 +317,8 @@ export function WorkspaceStrip({

{renaming !== null && (
<>
<button
<div
className="webapp-session-backdrop"
type="button"
aria-label="Close workspace rename"
tabIndex={-1}
onMouseDown={finishRename}
/>
<div
Expand Down Expand Up @@ -356,7 +354,7 @@ export function WorkspaceStrip({
onClick={onOpenSettings}
>
{viewer?.identity.avatarUrl
? <img className="shell-av__photo" src={viewer.identity.avatarUrl} alt="" referrerPolicy="no-referrer" />
? <img className="shell-av__photo" src={squareAvatarUrl(viewer.identity.avatarUrl)} alt="" referrerPolicy="no-referrer" />
: userLabel.trim().charAt(0).toUpperCase() || 'B'}
</button>
</div>
Expand Down
108 changes: 19 additions & 89 deletions packages/webapp/src/shell/workspace-tile.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,23 @@
/** Every workspace tile in the strip wears a gradient derived from its id, so
* two tiles are told apart by colour before their two-letter code is read. The
* derivation is pure and deterministic: the same id always paints the same
/** Every workspace tile in the strip wears a solid pastel derived from its id,
* so two tiles are told apart by colour before their two-letter code is read.
* The derivation is pure and deterministic: the same id always paints the same
* tile, on every device and every reload, with nothing stored anywhere. */

type Rgb = { red: number; green: number; blue: number };

export type WorkspaceTileStyle = {
/** A CSS `background` value: the two-stop gradient. */
/** A CSS `background` value: one solid pastel. */
background: string;
/** The initials' colour, picked so the tile clears WCAG AA (4.5:1). */
/** The initials' colour. A pastel is light by construction, so the ink is
* always the near-black; measured worst case over the wheel is 7.5:1. */
color: string;
};

/** The second stop is a short walk around the wheel: far enough to read as a
* gradient, near enough that the tile stays one colour rather than two. */
const HUE_SPREAD = 40;
const SATURATION = 0.58;
const LIGHTNESS = 0.46;

/** Against a background of this luminance neither white nor black reaches
* 4.5:1 with any margin — 4.58:1 is the best a pure black or white can do, and
* these near-black and near-white inks do worse. Tiles that land in the band
* are darkened out of it, which keeps the near-white ink well past AA. */
const AMBIGUOUS_LUMINANCE_MIN = 0.16;
const AMBIGUOUS_LUMINANCE_MAX = 0.26;
const DARKEN_FACTOR = 0.66;
/** Pastel: high lightness, moderate saturation. At L 0.80 the darkest hue on
* the wheel keeps a relative luminance above 0.52, so the near-black ink
* clears WCAG AA with room. */
const SATURATION = 0.52;
const LIGHTNESS = 0.8;

const INK_LIGHT: Rgb = { red: 248, green: 250, blue: 252 };
const INK_DARK: Rgb = { red: 11, green: 16, blue: 32 };
const INK_DARK = "rgb(11 16 32)";

/** FNV-1a, 32-bit. Chosen for spreading short ids across the wheel, not for
* any security property. */
Expand All @@ -39,76 +29,16 @@ function hashWorkspaceId(workspaceId: string): number {
return hash;
}

function hueToRgb(hue: number): Rgb {
const chroma = (1 - Math.abs(2 * LIGHTNESS - 1)) * SATURATION;
const sector = hue / 60;
const second = chroma * (1 - Math.abs((sector % 2) - 1));
const base = LIGHTNESS - chroma / 2;
const channels: [number, number, number] = sector < 1 ? [chroma, second, 0]
: sector < 2 ? [second, chroma, 0]
: sector < 3 ? [0, chroma, second]
: sector < 4 ? [0, second, chroma]
: sector < 5 ? [second, 0, chroma]
: [chroma, 0, second];
return {
red: Math.round((channels[0] + base) * 255),
green: Math.round((channels[1] + base) * 255),
blue: Math.round((channels[2] + base) * 255),
};
}

function darken(color: Rgb): Rgb {
return {
red: Math.round(color.red * DARKEN_FACTOR),
green: Math.round(color.green * DARKEN_FACTOR),
blue: Math.round(color.blue * DARKEN_FACTOR),
};
}

function channelLuminance(value: number): number {
const unit = value / 255;
return unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
}

/** WCAG relative luminance, 0 (black) to 1 (white). */
function relativeLuminance(color: Rgb): number {
return 0.2126 * channelLuminance(color.red)
+ 0.7152 * channelLuminance(color.green)
+ 0.0722 * channelLuminance(color.blue);
}

/** WCAG contrast ratio between two relative luminances. */
function contrastRatio(one: number, other: number): number {
const lighter = Math.max(one, other);
const darker = Math.min(one, other);
return (lighter + 0.05) / (darker + 0.05);
}

function css(color: Rgb): string {
return `rgb(${String(color.red)} ${String(color.green)} ${String(color.blue)})`;
}

/** The two gradient stops. The ink has to read over both, so the pair's
* average luminance is what the ink choice is made against. */
function workspaceTileStops(workspaceId: string): [Rgb, Rgb] {
const hue = hashWorkspaceId(workspaceId) % 360;
const start = hueToRgb(hue);
const end = hueToRgb((hue + HUE_SPREAD) % 360);
const luminance = (relativeLuminance(start) + relativeLuminance(end)) / 2;
if (luminance < AMBIGUOUS_LUMINANCE_MIN || luminance > AMBIGUOUS_LUMINANCE_MAX) {
return [start, end];
}
return [darken(start), darken(end)];
export function workspaceTileHue(workspaceId: string): number {
return hashWorkspaceId(workspaceId) % 360;
}

/** The inline style for one workspace tile. */
export function workspaceTileStyle(workspaceId: string): WorkspaceTileStyle {
const [start, end] = workspaceTileStops(workspaceId);
const luminance = (relativeLuminance(start) + relativeLuminance(end)) / 2;
const light = contrastRatio(relativeLuminance(INK_LIGHT), luminance);
const dark = contrastRatio(relativeLuminance(INK_DARK), luminance);
const hue = workspaceTileHue(workspaceId);
const saturation = Math.round(SATURATION * 100);
const lightness = Math.round(LIGHTNESS * 100);
return {
background: `linear-gradient(135deg, ${css(start)} 0%, ${css(end)} 100%)`,
color: css(light >= dark ? INK_LIGHT : INK_DARK),
background: `hsl(${String(hue)} ${String(saturation)}% ${String(lightness)}%)`,
color: INK_DARK,
};
}
14 changes: 8 additions & 6 deletions packages/webapp/src/strip-rail.css
Original file line number Diff line number Diff line change
Expand Up @@ -76,15 +76,17 @@
.shell-wtile {
position: relative;
display: grid;
width: 32px;
height: 32px;
/* Same footprint as the org mark at the top of the strip (user ruling
2026-08-29). */
width: 22px;
height: 22px;
flex: none;
place-items: center;
border: 0;
border-radius: 9px;
border-radius: 6px;
color: var(--muted);
background: var(--hover);
font: 700 11.5px/1 var(--font-ui);
font: 700 9px/1 var(--font-ui);
cursor: pointer;
}

Expand Down Expand Up @@ -114,7 +116,7 @@

.shell-wtile:disabled { cursor: default; }

.shell-wtile__plus { width: 13px; height: 13px; }
.shell-wtile__plus { width: 11px; height: 11px; }

.shell-strip__spacer { flex: 1 1 auto; }

Expand Down Expand Up @@ -152,7 +154,7 @@
cursor: pointer;
}

.shell-av__photo { width: 100%; height: 100%; object-fit: cover; }
.shell-av__photo { display: block; width: 100%; height: 100%; border-radius: 50%; object-fit: cover; }

/* The org popover reuses the shell's menu skin; only the anchor changes,
because the strip is 48px wide and it opens beside it. */
Expand Down
4 changes: 4 additions & 0 deletions packages/webapp/src/webapp-workspace.css
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,10 @@
position: fixed;
z-index: 199;
inset: 0;
/* Explicit: a fullscreen element with the UA's default button face would
* paint over the entire app. Keep this class safe on any element. */
border: 0;
background: transparent;
}

.webapp-session-menu {
Expand Down
5 changes: 4 additions & 1 deletion packages/webapp/src/workspace-details-dialog.css
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@

.workspace-details-body { min-height: 280px; overflow-y: auto; padding: 20px 22px 8px; }
.workspace-details-list { margin: 0; }
.workspace-details-list > div { display: grid; grid-template-columns: minmax(105px, 1fr) minmax(0, 1.25fr); gap: 12px; padding: 6px 0; border-top: 1px solid color-mix(in oklab, var(--rule) 60%, transparent); }
/* One separator under the section heading; none between rows (user ruling
2026-08-29: keep the divider between the heading and the fields only). */
.workspace-details-list > div { display: grid; grid-template-columns: minmax(105px, 1fr) minmax(0, 1.25fr); gap: 12px; padding: 6px 0; }
.workspace-details-list > div:first-child { border-top: 1px solid color-mix(in oklab, var(--rule) 60%, transparent); }
.workspace-details-list dt { color: var(--faint); font-size: 11px; }
.workspace-details-list dd { min-width: 0; margin: 0; overflow: hidden; font-size: 11px; text-align: right; text-overflow: ellipsis; text-transform: capitalize; white-space: nowrap; }

Expand Down
13 changes: 10 additions & 3 deletions packages/webapp/test/workspace-strip.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,13 @@ describe("workspace strip", () => {
expect(tiles[1]?.getAttribute("aria-current")).toBeNull();
expect(tiles[1]?.className).toContain("shell-wtile--off");
expect(tiles[2]?.getAttribute("aria-label")).toBe("Create workspace");
// Each workspace tile wears its own gradient; the create tile keeps the
// Each workspace tile wears its own solid pastel; the create tile keeps the
// dashed outline the stylesheet gives it.
expect(tiles[0]?.style.background).toContain("linear-gradient");
expect(tiles[1]?.style.background).toContain("linear-gradient");
// jsdom normalizes hsl() to rgb() on read-back; assert solid + distinct.
expect(tiles[0]?.style.background).toMatch(/^rgb\(/u);
expect(tiles[1]?.style.background).toMatch(/^rgb\(/u);
expect(tiles[0]?.style.background).not.toContain("gradient");
expect(tiles[0]?.style.background).not.toBe(tiles[1]?.style.background);
expect(tiles[0]?.style.background).not.toBe(tiles[1]?.style.background);
expect(tiles[2]?.style.background).toBe("");
await view.unmount();
Expand Down Expand Up @@ -130,6 +133,10 @@ describe("workspace strip", () => {

const menu = view.container.querySelector<HTMLElement>('[role="menu"][aria-label="Workspace design-team"]');
expect(menu).not.toBeNull();
// The backdrop must never be a <button>: with no global button reset, a
// fullscreen button paints the UA's opaque button face over the whole app.
const backdrop = view.container.querySelector<HTMLElement>(".webapp-session-backdrop");
expect(backdrop?.tagName).toBe("DIV");
expect(menu?.style.left).toBe("40px");
expect(menu?.style.top).toBe("90px");
const items = [...menu!.querySelectorAll<HTMLButtonElement>('[role="menuitem"]')];
Expand Down
Loading
Loading