From 27d44a6a2be61a0ca7772976b13e50a5c7e6f24f Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 09:09:39 +0000 Subject: [PATCH 01/11] =?UTF-8?q?fix(webapp):=20A2=20=E2=80=94=20one=20Dri?= =?UTF-8?q?ve=20icon=20in=20the=20strip,=20not=20three=20panel=20toggles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's Files / teenyapps / Connections buttons duplicated the right icon strip (WorkspaceRailStrip), which owns those panels. The slot now holds a single Drive icon that navigates to the Drive page — the route the account menu's Drive entry used. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/CloudApp.tsx | 16 ----- packages/webapp/src/shell/ShellNav.tsx | 11 ---- packages/webapp/src/shell/StripIcons.tsx | 18 +----- packages/webapp/src/shell/WorkspaceStrip.tsx | 63 ++++--------------- packages/webapp/src/strip-rail.css | 19 +----- packages/webapp/test/workspace-strip.test.tsx | 35 +++-------- 6 files changed, 22 insertions(+), 140 deletions(-) diff --git a/packages/webapp/src/CloudApp.tsx b/packages/webapp/src/CloudApp.tsx index 8fece8fb..4cfac0a8 100644 --- a/packages/webapp/src/CloudApp.tsx +++ b/packages/webapp/src/CloudApp.tsx @@ -773,19 +773,6 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { }); }, [activeWorkspaceId, setWorkspaceTabs]); - /** The strip's surface icons focus a panel. They open, they never close: - * the right icon strip owns the toggle. */ - const openWorkspacePanel = useCallback((panel: WorkspaceDrawerSegment) => { - if (!activeWorkspaceId) return; - updateWorkspaceTabs((tabs) => showPanelTab(tabs, panel)); - if (mobileWebApp) { - setDrawerOpen(false); - setFilesDrawerOpen(true); - return; - } - setFocusedRegion('side'); - }, [activeWorkspaceId, mobileWebApp, updateWorkspaceTabs]); - const toggleFiles = useCallback(() => { if (!activeWorkspaceId) return; if (mobileWebApp) { @@ -1375,12 +1362,9 @@ export default function CloudApp({ client, resolver }: CloudAppProps) { ? railSessions : []} activeSessionId={railActiveSessionId ?? ''} - openPanels={openPanels} - pendingRequestCount={activePendingRequests.length} drawerOpen={drawerOpen} onSelectWorkspace={selectWorkspace} onCreateWorkspace={() => setShowCreateWorkspace(true)} - onOpenPanel={openWorkspacePanel} onSwitchOrg={(orgId) => { void client.switchOrg(orgId).then(() => window.location.reload()); }} diff --git a/packages/webapp/src/shell/ShellNav.tsx b/packages/webapp/src/shell/ShellNav.tsx index 96ee6bec..66db5c62 100644 --- a/packages/webapp/src/shell/ShellNav.tsx +++ b/packages/webapp/src/shell/ShellNav.tsx @@ -1,6 +1,5 @@ import type { TenantMe } from '../api-adapter'; import type { SpawnSessionType } from '../WebAppHeader'; -import type { WorkspaceDrawerSegment } from '../storage'; import type { CloudWorkspaceModel } from '../workspace-store'; import type { DriveRailSession } from './rail-sessions'; import { WorkspaceSessionRail } from './WorkspaceSessionRail'; @@ -17,12 +16,9 @@ export type ShellNavProps = { showRail: boolean; sessions: DriveRailSession[]; activeSessionId: string; - openPanels: ReadonlySet; - pendingRequestCount: number; drawerOpen: boolean; onSelectWorkspace: (workspaceId: string) => void; onCreateWorkspace: () => void; - onOpenPanel: (panel: WorkspaceDrawerSegment) => void; onSwitchOrg: (orgId: string) => void; onCreateOrg: () => void; onOpenDrive: () => void; @@ -45,12 +41,9 @@ export function ShellNav({ showRail, sessions, activeSessionId, - openPanels, - pendingRequestCount, drawerOpen, onSelectWorkspace, onCreateWorkspace, - onOpenPanel, onSwitchOrg, onCreateOrg, onOpenDrive, @@ -68,12 +61,8 @@ export function ShellNav({ workspaces={workspaces} viewer={viewer} activeWorkspaceId={activeWorkspaceId} - openPanels={openPanels} - pendingRequestCount={pendingRequestCount} - surfacesEnabled={activeWorkspace !== undefined} onSelectWorkspace={onSelectWorkspace} onCreateWorkspace={onCreateWorkspace} - onOpenPanel={onOpenPanel} onSwitchOrg={onSwitchOrg} onCreateOrg={onCreateOrg} onOpenDrive={onOpenDrive} diff --git a/packages/webapp/src/shell/StripIcons.tsx b/packages/webapp/src/shell/StripIcons.tsx index 4d905f26..3c2a42c3 100644 --- a/packages/webapp/src/shell/StripIcons.tsx +++ b/packages/webapp/src/shell/StripIcons.tsx @@ -24,24 +24,12 @@ function glyph(path: React.ReactNode, strokeWidth = 1.4) { export const PlusGlyph = glyph(, 1.7); -export const FilesGlyph = glyph( +/* Drive wears the folder outline the Drive surface already draws + * (files/DriveIcons.tsx `DriveGlyph`), on the strip's 16-grid. */ +export const DriveGlyph = glyph( , ); -export const PortsGlyph = glyph( - <> - - - , -); - -export const ConnectionsGlyph = glyph( - <> - - - , -); - export const ShareGlyph = glyph( <> diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx index c55ece5b..17b176da 100644 --- a/packages/webapp/src/shell/WorkspaceStrip.tsx +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -1,13 +1,7 @@ import { useEffect, useState } from 'react'; import type { TenantMe } from '../api-adapter'; -import type { WorkspaceDrawerSegment } from '../storage'; import type { CloudWorkspaceModel } from '../workspace-store'; -import { - ConnectionsGlyph, - FilesGlyph, - PlusGlyph, - PortsGlyph, -} from './StripIcons'; +import { DriveGlyph, PlusGlyph } from './StripIcons'; /** 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 @@ -19,18 +13,6 @@ export function workspaceCode(title: string): string { return words.slice(0, 3).map((word) => word[0]!).join('').toUpperCase(); } -/** The surfaces the strip can focus. They are the same three panels the right - * icon strip toggles, under the same names, so one panel never has two. */ -const SURFACES: Array<{ - id: WorkspaceDrawerSegment; - label: string; - Glyph: (props: { className?: string }) => React.ReactElement; -}> = [ - { id: 'files', label: 'Files', Glyph: FilesGlyph }, - { id: 'previews', label: 'teenyapps', Glyph: PortsGlyph }, - { id: 'connections', label: 'Connections', Glyph: ConnectionsGlyph }, -]; - function stateLabel(workspace: CloudWorkspaceModel): string { if (workspace.lifecycleStatus === 'creating') return 'creating'; if (workspace.lifecycleStatus === 'error') return 'failed'; @@ -42,14 +24,8 @@ export type WorkspaceStripProps = { workspaces: CloudWorkspaceModel[]; viewer: TenantMe | null; activeWorkspaceId: string | null; - /** Panels already open in the work area; the strip rings the matching icon. */ - openPanels: ReadonlySet; - pendingRequestCount: number; - /** False on Drive and settings, where there is no box to open a panel on. */ - surfacesEnabled: boolean; onSelectWorkspace: (workspaceId: string) => void; onCreateWorkspace: () => void; - onOpenPanel: (panel: WorkspaceDrawerSegment) => void; onSwitchOrg: (orgId: string) => void; onCreateOrg: () => void; onOpenDrive: () => void; @@ -58,18 +34,14 @@ export type WorkspaceStripProps = { }; /** Column one of the shell (plans/mockups/session-rail.html `#strip`): the org - * mark, one tile per workspace, the create tile, the workspace surfaces, and - * the account menu on the bottom edge. */ + * mark, one tile per workspace, the create tile, Drive, and the account menu + * on the bottom edge. The workspace panels are the right icon strip's job. */ export function WorkspaceStrip({ workspaces, viewer, activeWorkspaceId, - openPanels, - pendingRequestCount, - surfacesEnabled, onSelectWorkspace, onCreateWorkspace, - onOpenPanel, onSwitchOrg, onCreateOrg, onOpenDrive, @@ -203,27 +175,14 @@ export function WorkspaceStrip({
-
{atCap && (

@@ -229,11 +218,24 @@ export function TemplateRepoPicker({

)} - - {value.length === 0 - ? 'No repositories selected' - : `${String(value.length)} ${value.length === 1 ? 'repository' : 'repositories'} selected`} - +
+ + {value.length === 0 + ? 'No repositories selected' + : `${String(value.length)} ${value.length === 1 ? 'repository' : 'repositories'} selected`} + + {/* Refresh repeats here, not only in the empty state. GitHub never + * returns to this page after an install, so the list cannot re-read + * itself. Without this, an account installed mid-session stays + * invisible until the whole screen is rebuilt. */} + +
); } diff --git a/packages/webapp/test/template-screen.test.tsx b/packages/webapp/test/template-screen.test.tsx index 4e33ed7a..71d8dc49 100644 --- a/packages/webapp/test/template-screen.test.tsx +++ b/packages/webapp/test/template-screen.test.tsx @@ -873,6 +873,11 @@ describe('create template screen', () => { const listed = () => [...view.container.querySelectorAll('.tplf-repo')] .map((label) => label.textContent); expect(listed()).toEqual(['acme/app']); + // Refresh reads under the list, on its right edge — not in the filter row. + expect(view.container.querySelector('.tplf-repos-controls .tplf-repos-refresh')).toBeNull(); + expect(view.container.querySelector( + '.tplf-repos-list + .tplf-repos-listfoot .tplf-repos-refresh', + )).not.toBeNull(); installedSecond = true; await act(async () => { From 98235d169f247a4d704111eed5459d0c8e8af69e Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 09:16:16 +0000 Subject: [PATCH 04/11] =?UTF-8?q?fix(webapp):=20A6=20=E2=80=94=20a=20deter?= =?UTF-8?q?ministic=20gradient=20per=20workspace=20tile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's tiles were flat, so a two-letter code was the only thing telling them apart. src/shell/workspace-tile.ts hashes the workspace id to a hue, paints a 135° gradient to hue + 40°, and picks a near-white or near-black ink from the gradient's average luminance. Ids whose average lands in the band where neither ink clears 4.5:1 are darkened out of it; measured worst case over all 360 hues is 4.83:1. The active ring and the dashed create tile are unchanged. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/shell/WorkspaceStrip.tsx | 2 + packages/webapp/src/shell/workspace-tile.ts | 114 ++++++++++++++++++ packages/webapp/test/workspace-strip.test.tsx | 6 + packages/webapp/test/workspace-tile.test.ts | 72 +++++++++++ 4 files changed, 194 insertions(+) create mode 100644 packages/webapp/src/shell/workspace-tile.ts create mode 100644 packages/webapp/test/workspace-tile.test.ts diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx index 17b176da..44375663 100644 --- a/packages/webapp/src/shell/WorkspaceStrip.tsx +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'; import type { TenantMe } from '../api-adapter'; import type { CloudWorkspaceModel } from '../workspace-store'; import { DriveGlyph, PlusGlyph } from './StripIcons'; +import { workspaceTileStyle } from './workspace-tile'; /** 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 @@ -157,6 +158,7 @@ export function WorkspaceStrip({ aria-label={workspace.title} aria-current={active ? 'page' : undefined} disabled={!workspace.canControl} + style={workspaceTileStyle(workspace.id)} title={owner === null ? `${workspace.title} — ${stateLabel(workspace)}` : `${workspace.title} — shared by ${owner}`} diff --git a/packages/webapp/src/shell/workspace-tile.ts b/packages/webapp/src/shell/workspace-tile.ts new file mode 100644 index 00000000..88142d9c --- /dev/null +++ b/packages/webapp/src/shell/workspace-tile.ts @@ -0,0 +1,114 @@ +/** 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 + * 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. */ + background: string; + /** The initials' colour, picked so the tile clears WCAG AA (4.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; + +const INK_LIGHT: Rgb = { red: 248, green: 250, blue: 252 }; +const INK_DARK: Rgb = { red: 11, green: 16, blue: 32 }; + +/** FNV-1a, 32-bit. Chosen for spreading short ids across the wheel, not for + * any security property. */ +function hashWorkspaceId(workspaceId: string): number { + let hash = 0x811c9dc5; + for (let index = 0; index < workspaceId.length; index += 1) { + hash = Math.imul(hash ^ workspaceId.charCodeAt(index), 0x01000193) >>> 0; + } + 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)]; +} + +/** 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); + return { + background: `linear-gradient(135deg, ${css(start)} 0%, ${css(end)} 100%)`, + color: css(light >= dark ? INK_LIGHT : INK_DARK), + }; +} diff --git a/packages/webapp/test/workspace-strip.test.tsx b/packages/webapp/test/workspace-strip.test.tsx index eb6487e9..c6a780b2 100644 --- a/packages/webapp/test/workspace-strip.test.tsx +++ b/packages/webapp/test/workspace-strip.test.tsx @@ -71,6 +71,12 @@ 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 + // dashed outline the stylesheet gives it. + expect(tiles[0]?.style.background).toContain("linear-gradient"); + expect(tiles[1]?.style.background).toContain("linear-gradient"); + expect(tiles[0]?.style.background).not.toBe(tiles[1]?.style.background); + expect(tiles[2]?.style.background).toBe(""); await view.unmount(); }); diff --git a/packages/webapp/test/workspace-tile.test.ts b/packages/webapp/test/workspace-tile.test.ts new file mode 100644 index 00000000..1e76bad3 --- /dev/null +++ b/packages/webapp/test/workspace-tile.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { workspaceTileStyle } from "../src/shell/workspace-tile.js"; + +/** WCAG 2.2 relative luminance, written out here rather than imported, so the + * contrast claim is checked against the published formula and not against the + * helper's own arithmetic. */ +function luminance(red: number, green: number, blue: number): number { + const channel = (value: number) => { + const unit = value / 255; + return unit <= 0.04045 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4; + }; + return 0.2126 * channel(red) + 0.7152 * channel(green) + 0.0722 * channel(blue); +} + +function contrast(one: number, other: number): number { + return (Math.max(one, other) + 0.05) / (Math.min(one, other) + 0.05); +} + +function colors(value: string): number[][] { + return [...value.matchAll(/rgb\((\d+) (\d+) (\d+)\)/gu)] + .map((match) => [Number(match[1]), Number(match[2]), Number(match[3])]); +} + +function inkOverTile(workspaceId: string): number { + const style = workspaceTileStyle(workspaceId); + const stops = colors(style.background); + expect(stops).toHaveLength(2); + const tile = stops + .map(([red, green, blue]) => luminance(red!, green!, blue!)) + .reduce((total, value) => total + value, 0) / stops.length; + const ink = colors(style.color); + expect(ink).toHaveLength(1); + const [red, green, blue] = ink[0]!; + return contrast(luminance(red!, green!, blue!), tile); +} + +const ids = Array.from({ length: 600 }, (_, index) => `workspace-${String(index)}`); + +describe("workspaceTileStyle", () => { + it("paints the same tile for the same id, every time", () => { + expect(workspaceTileStyle("workspace-one")) + .toEqual(workspaceTileStyle("workspace-one")); + expect(workspaceTileStyle("workspace-one").background) + .toBe(workspaceTileStyle("workspace-one").background); + }); + + it("is a two-stop gradient, and the stops are two different hues", () => { + const style = workspaceTileStyle("design-team"); + expect(style.background).toMatch( + /^linear-gradient\(135deg, rgb\(\d+ \d+ \d+\) 0%, rgb\(\d+ \d+ \d+\) 100%\)$/u, + ); + const [start, end] = colors(style.background); + expect(start).not.toEqual(end); + }); + + it("spreads a stripful of workspaces around the wheel", () => { + // 360 hues, so ids collide eventually; a strip holds tens, not hundreds. + const strip = ids.slice(0, 60); + const backgrounds = new Set(strip.map((id) => workspaceTileStyle(id).background)); + expect(backgrounds.size).toBeGreaterThanOrEqual(50); + }); + + it("picks an ink that clears WCAG AA over the gradient it painted", () => { + const ratios = ids.map((id) => inkOverTile(id)); + expect(Math.min(...ratios)).toBeGreaterThanOrEqual(4.5); + }); + + it("uses both inks, so the luminance choice is a real choice", () => { + const inks = new Set(ids.map((id) => workspaceTileStyle(id).color)); + expect(inks.size).toBe(2); + }); +}); From 225d8e024a8d7fa10fbe21cb242efde262b2f732 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 09:18:16 +0000 Subject: [PATCH 05/11] =?UTF-8?q?fix(webapp):=20A7=20=E2=80=94=20the=20ava?= =?UTF-8?q?tar=20opens=20settings,=20and=20the=20menu=20is=20gone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The strip's avatar drew a four-item popover. Drive is a strip icon since A2, Settings was one click away, and the name was a label. Clicking the avatar now navigates to settings. "Ask us on Discord" moves to the bottom of the settings side navigation, under the section tabs. Co-Authored-By: Claude Fable 5 --- packages/webapp/src/SettingsPage.tsx | 9 +++ packages/webapp/src/settings.css | 16 +++++ packages/webapp/src/shell/WorkspaceStrip.tsx | 71 +++---------------- packages/webapp/src/strip-rail.css | 6 +- .../webapp/test/compute-credentials.test.tsx | 6 ++ packages/webapp/test/workspace-strip.test.tsx | 29 +++----- 6 files changed, 50 insertions(+), 87 deletions(-) diff --git a/packages/webapp/src/SettingsPage.tsx b/packages/webapp/src/SettingsPage.tsx index fb9446ff..4f0fae8d 100644 --- a/packages/webapp/src/SettingsPage.tsx +++ b/packages/webapp/src/SettingsPage.tsx @@ -190,6 +190,15 @@ export function SettingsPage({ + {/* The one place the product asks for a human. It lived in the strip's + * account menu until that menu went; settings is where a member looks + * for it next. */} + Ask us on Discord
{section === 'profile' && } diff --git a/packages/webapp/src/settings.css b/packages/webapp/src/settings.css index 97b18a81..a90c9077 100644 --- a/packages/webapp/src/settings.css +++ b/packages/webapp/src/settings.css @@ -127,6 +127,22 @@ font-weight: 650; } +.settings-nav-link { + display: block; + margin-top: 10px; + padding: 8px 9px; + border-top: 1px solid var(--rule); + color: var(--faint); + font: 12px/1.5 var(--font-ui); + text-decoration: none; +} + +.settings-nav-link:hover, +.settings-nav-link:focus-visible { + color: var(--ink); + outline: 0; +} + .settings-content { min-width: 0; min-height: 0; diff --git a/packages/webapp/src/shell/WorkspaceStrip.tsx b/packages/webapp/src/shell/WorkspaceStrip.tsx index 44375663..4d52576b 100644 --- a/packages/webapp/src/shell/WorkspaceStrip.tsx +++ b/packages/webapp/src/shell/WorkspaceStrip.tsx @@ -35,8 +35,9 @@ export type WorkspaceStripProps = { }; /** Column one of the shell (plans/mockups/session-rail.html `#strip`): the org - * mark, one tile per workspace, the create tile, Drive, and the account menu - * on the bottom edge. The workspace panels are the right icon strip's job. */ + * mark, one tile per workspace, the create tile, Drive, and the avatar on the + * bottom edge, which goes straight to settings. The workspace panels are the + * right icon strip's job. */ export function WorkspaceStrip({ workspaces, viewer, @@ -50,20 +51,17 @@ export function WorkspaceStrip({ onCloseDrawer, }: WorkspaceStripProps) { const [orgMenuOpen, setOrgMenuOpen] = useState(false); - const [accountMenuOpen, setAccountMenuOpen] = useState(false); const orgLabel = viewer?.org.name || viewer?.org.slug || 'Organization'; const userLabel = viewer?.identity.name || viewer?.identity.email || 'BlitzOS'; useEffect(() => { - if (!orgMenuOpen && !accountMenuOpen) return; + if (!orgMenuOpen) return; const closeOnEscape = (event: KeyboardEvent) => { - if (event.key !== 'Escape') return; - setOrgMenuOpen(false); - setAccountMenuOpen(false); + if (event.key === 'Escape') setOrgMenuOpen(false); }; window.addEventListener('keydown', closeOnEscape); return () => window.removeEventListener('keydown', closeOnEscape); - }, [accountMenuOpen, orgMenuOpen]); + }, [orgMenuOpen]); return (
); diff --git a/packages/webapp/src/strip-rail.css b/packages/webapp/src/strip-rail.css index 1038e46e..6f0e5929 100644 --- a/packages/webapp/src/strip-rail.css +++ b/packages/webapp/src/strip-rail.css @@ -154,8 +154,8 @@ .shell-av__photo { width: 100%; height: 100%; object-fit: cover; } -/* The org and account popovers reuse the shell's menu skin; only the anchor - changes, because the strip is 48px wide and they open beside it. */ +/* The org popover reuses the shell's menu skin; only the anchor changes, + because the strip is 48px wide and it opens beside it. */ .shell-strip__menu { top: 0; right: auto; @@ -163,8 +163,6 @@ width: 220px; } -.shell-strip__menu--account { top: auto; bottom: 0; } - /* --------------------------------------------------------------- column 2 */ .shell-rail { display: flex; diff --git a/packages/webapp/test/compute-credentials.test.tsx b/packages/webapp/test/compute-credentials.test.tsx index e30d4c3e..d2746489 100644 --- a/packages/webapp/test/compute-credentials.test.tsx +++ b/packages/webapp/test/compute-credentials.test.tsx @@ -71,6 +71,12 @@ describe('compute credential settings', () => { , ); expect(memberView.container.textContent).not.toContain('Compute'); + // The Discord link left the strip's account menu; settings navigation + // carries it now. + const discord = memberView.container.querySelector( + '.settings-side a[href^="https://discord.gg/"]', + ); + expect(discord?.textContent).toBe('Ask us on Discord'); await memberView.unmount(); const adminView = await render( diff --git a/packages/webapp/test/workspace-strip.test.tsx b/packages/webapp/test/workspace-strip.test.tsx index c6a780b2..8e39f5f7 100644 --- a/packages/webapp/test/workspace-strip.test.tsx +++ b/packages/webapp/test/workspace-strip.test.tsx @@ -134,30 +134,17 @@ describe("workspace strip", () => { await view.unmount(); }); - it("reaches Drive and settings from the account menu", async () => { - const onOpenDrive = vi.fn(); + it("goes straight to settings from the avatar, with no menu in between", async () => { const onOpenSettings = vi.fn(); - const view = await render(strip({ onOpenDrive, onOpenSettings })); - const menu = () => view.container.querySelector( - '[role="menu"][aria-label="Account"]', + const view = await render(strip({ onOpenSettings })); + const avatar = view.container.querySelector( + 'button[aria-label="Settings"]', ); - expect(menu()?.hidden).toBe(true); - await act(async () => view.container.querySelector( - 'button[aria-label="Account: Person"]', - )?.click()); - expect(menu()?.hidden).toBe(false); - - const items = [...menu()!.querySelectorAll('[role="menuitem"]')]; - expect(items.map(({ textContent }) => textContent)) - .toEqual(["Drive", "Settings", "Ask us on Discord"]); - await act(async () => items[0]?.click()); - expect(onOpenDrive).toHaveBeenCalledOnce(); - - await act(async () => view.container.querySelector( - 'button[aria-label="Account: Person"]', - )?.click()); - await act(async () => menu()!.querySelectorAll('[role="menuitem"]')[1]?.click()); + expect(avatar?.title).toBe("Person"); + expect(avatar?.getAttribute("aria-haspopup")).toBeNull(); + await act(async () => avatar?.click()); expect(onOpenSettings).toHaveBeenCalledOnce(); + expect(view.container.querySelector('[role="menu"][aria-label="Account"]')).toBeNull(); await view.unmount(); }); }); From b9205f451f7c138a930af8d56603d25329292434 Mon Sep 17 00:00:00 2001 From: pythonlearner1025 Date: Sat, 29 Aug 2026 09:29:41 +0000 Subject: [PATCH 06/11] =?UTF-8?q?fix(webapp):=20B1=20=E2=80=94=20the=20det?= =?UTF-8?q?ails=20dialog=20wears=20the=20pre-#106=20chrome=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog #106 built kept the three tabs the plan asks for and lost the look the pre-#106 one had. This puts the look back without touching what any control does: - the header says `Workspace details “name”` again; - every panel opens with the micro-caps section heading the old `workspace-details-grid` sections wore; - the member rows take the old access-list geometry (a 28px avatar, a 48px row) instead of the taller #106 one; - Clone and Delete leave the bottom of the Settings tab for the restored `workspace-details-footer`, which is where the old dialog kept Delete. Members, Credentials and Settings are the same three tabs, and every write behind them is untouched. Co-Authored-By: Claude Fable 5 --- .../webapp/src/WorkspaceDetailsDialog.tsx | 24 ++++++++++++++++--- .../webapp/src/WorkspaceMembersEditor.tsx | 2 +- packages/webapp/src/WorkspaceSettingsTab.tsx | 20 +++------------- .../webapp/src/workspace-details-dialog.css | 16 ++++++++----- .../test/WorkspaceDetailsDialog.test.tsx | 13 +++++++--- 5 files changed, 45 insertions(+), 30 deletions(-) diff --git a/packages/webapp/src/WorkspaceDetailsDialog.tsx b/packages/webapp/src/WorkspaceDetailsDialog.tsx index 6e28a280..8417a948 100644 --- a/packages/webapp/src/WorkspaceDetailsDialog.tsx +++ b/packages/webapp/src/WorkspaceDetailsDialog.tsx @@ -89,6 +89,7 @@ function CredentialsTab({ aria-label="Credentials" className="workspace-details-credentials" > +

Credentials

Workspace credentials reach every member machine through{' '} blitz-cred. A value is write-only: it never comes back out @@ -172,6 +173,10 @@ function CredentialsTab({ * the workspace credential names, and the settings. The old Compute and * Storage panels are gone — a workspace has no single machine to describe, * so those facts live on the member rows instead. + * + * The chrome is the pre-#106 one: the header names the workspace, the tab row + * sits under it, and the two workspace-wide verbs live in the footer rather + * than at the bottom of one tab. */ export function WorkspaceDetailsDialog({ client, @@ -290,7 +295,7 @@ export function WorkspaceDetailsDialog({ aria-label={`Workspace details for ${workspace.title}`} >

-

Workspace “{workspace.title}”

+

Workspace details “{workspace.title}”

@@ -317,6 +322,7 @@ export function WorkspaceDetailsDialog({ aria-label="Members" className="workspace-details-members" > +

Who has access

run(client.updateWorkspace(workspaceId, input))} onAddRepo={addRepo} onRemoveRepo={removeRepo} - onClone={onClone} - onDelete={onDelete} /> )}
+ {(onClone !== null || onDelete !== null) && ( +
+ {onClone && ( + + )} + {onDelete && ( + + )} +
+ )} {pendingTypeChange !== null && ( - + {name} {pinned && Workspace owner} diff --git a/packages/webapp/src/WorkspaceSettingsTab.tsx b/packages/webapp/src/WorkspaceSettingsTab.tsx index 86bd46cf..4580fc4b 100644 --- a/packages/webapp/src/WorkspaceSettingsTab.tsx +++ b/packages/webapp/src/WorkspaceSettingsTab.tsx @@ -136,7 +136,8 @@ function ReposEditor({ /** * The Settings tab of plan §6: name, default machine type, auto-provision, - * agent rules, repos, clone and delete. + * agent rules and repos. Clone and delete are workspace-wide verbs, so they + * sit in the dialog footer rather than at the bottom of this tab. * * Every field here is workspace-admin work (§3), so a member reads the values * and an admin edits them. The default machine type applies to machines @@ -152,8 +153,6 @@ export function WorkspaceSettingsTab({ onSave, onAddRepo, onRemoveRepo, - onClone, - onDelete, }: { client: AgentRulesApi; workspace: CloudWorkspaceModel; @@ -163,8 +162,6 @@ export function WorkspaceSettingsTab({ onSave: (input: UpdateWorkspaceRequest) => void; onAddRepo: (repo: string) => void; onRemoveRepo: (repo: string) => void; - onClone: (() => void) | null; - onDelete: (() => void) | null; }) { const [draft, setDraft] = useState(() => draftFor(workspace)); const changes = settingsChanges(workspace, draft); @@ -176,6 +173,7 @@ export function WorkspaceSettingsTab({ aria-label="Settings" className="workspace-details-settings" > +

Workspace

{canManage ? (