-
{viewer?.identity.avatarUrl
- ?

+ ?
})
: userLabel.trim().charAt(0).toUpperCase() || 'B'}
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