diff --git a/packages/webapp/src/avatar-url.ts b/packages/webapp/src/avatar-url.ts new file mode 100644 index 00000000..a0f65e56 --- /dev/null +++ b/packages/webapp/src/avatar-url.ts @@ -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`; +} diff --git a/packages/webapp/src/files-drive.css b/packages/webapp/src/files-drive.css index 94150304..1a6da922 100644 --- a/packages/webapp/src/files-drive.css +++ b/packages/webapp/src/files-drive.css @@ -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%; diff --git a/packages/webapp/src/files/DriveAvatar.tsx b/packages/webapp/src/files/DriveAvatar.tsx index 1d02077a..1521503a 100644 --- a/packages/webapp/src/files/DriveAvatar.tsx +++ b/packages/webapp/src/files/DriveAvatar.tsx @@ -1,3 +1,4 @@ +import { squareAvatarUrl } from '../avatar-url'; import { personInitial } from './drive-model'; export function DriveAvatar({ @@ -17,7 +18,7 @@ export function DriveAvatar({ title={name} aria-hidden="true" > - {avatarUrl ? : personInitial(name)} + {avatarUrl ? : personInitial(name)} ); } diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx index ac21e41a..401ea42b 100644 --- a/packages/webapp/src/shell/WorkspaceStrip.tsx +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -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 @@ -253,11 +254,11 @@ export function WorkspaceStrip({ {tileMenu !== null && menuWorkspace !== undefined && ( <> - diff --git a/packages/webapp/src/shell/workspace-tile.ts b/packages/webapp/src/shell/workspace-tile.ts index 88142d9c..9c8fa562 100644 --- a/packages/webapp/src/shell/workspace-tile.ts +++ b/packages/webapp/src/shell/workspace-tile.ts @@ -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. */ @@ -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, }; } diff --git a/packages/webapp/src/strip-rail.css b/packages/webapp/src/strip-rail.css index fd09aaf2..ee2aa95b 100644 --- a/packages/webapp/src/strip-rail.css +++ b/packages/webapp/src/strip-rail.css @@ -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; } @@ -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; } @@ -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. */ diff --git a/packages/webapp/src/webapp-workspace.css b/packages/webapp/src/webapp-workspace.css index 738fdd0a..ecf096be 100644 --- a/packages/webapp/src/webapp-workspace.css +++ b/packages/webapp/src/webapp-workspace.css @@ -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 { diff --git a/packages/webapp/src/workspace-details-dialog.css b/packages/webapp/src/workspace-details-dialog.css index e1bd065f..839863ac 100644 --- a/packages/webapp/src/workspace-details-dialog.css +++ b/packages/webapp/src/workspace-details-dialog.css @@ -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; } diff --git a/packages/webapp/test/workspace-strip.test.tsx b/packages/webapp/test/workspace-strip.test.tsx index a7c43fa4..22693630 100644 --- a/packages/webapp/test/workspace-strip.test.tsx +++ b/packages/webapp/test/workspace-strip.test.tsx @@ -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(); @@ -130,6 +133,10 @@ describe("workspace strip", () => { const menu = view.container.querySelector('[role="menu"][aria-label="Workspace design-team"]'); expect(menu).not.toBeNull(); + // The backdrop must never be a