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
2 changes: 1 addition & 1 deletion apps/desktop/src/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ if (clientPlatform === "darwin") {
const syncWindowControlInset = () => {
document.documentElement.style.setProperty(
"--desktop-window-controls-inset",
`${90 / webFrame.getZoomFactor()}px`,
`${82 / webFrame.getZoomFactor()}px`,
);
};
window.addEventListener("DOMContentLoaded", syncWindowControlInset, { once: true });
Expand Down
8 changes: 4 additions & 4 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -797,10 +797,10 @@ describe("DesktopWindow", () => {
yield* desktopWindow.zoomMain(direction);
const position = fakeWindow.setWindowButtonPosition.mock.lastCall?.[0];
assert.isDefined(position);
// The 14-point native buttons should share the zoomed 52px header's center.
const headerCenter = 26 * fakeWindow.window.webContents.getZoomFactor();
// The 14-point native buttons should share the zoomed 42px header's center.
const headerCenter = 21 * fakeWindow.window.webContents.getZoomFactor();
assert.isAtMost(Math.abs(position.y + 7 - headerCenter), 0.5);
assert.equal(position.x, 16);
assert.equal(position.x, 14);
}

fakeWindow.isFullScreen.mockReturnValue(true);
Expand All @@ -810,7 +810,7 @@ describe("DesktopWindow", () => {

fakeWindow.isFullScreen.mockReturnValue(false);
fakeWindow.windowListeners.get("leave-full-screen")?.();
assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 16, y: 19 }]);
assert.deepEqual(fakeWindow.setWindowButtonPosition.mock.lastCall, [{ x: 14, y: 14 }]);
}).pipe(Effect.provide(layer));
}),
);
Expand Down
12 changes: 7 additions & 5 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,15 +34,17 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts"
import { makeQuitShortcutHandler } from "./QuitHold.ts";

const TITLEBAR_HEIGHT = 40;
// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS
// buttons are 14 points tall and do not scale with the renderer's zoom.
const MACOS_WORKSPACE_TOPBAR_HEIGHT = 52;
// Matches the Mac app's --workspace-topbar-height in apps/web/src/appFrame.css.
// Native macOS buttons are 14 points tall and do not scale with the renderer's zoom.
const MACOS_WORKSPACE_TOPBAR_HEIGHT = 42;
const MACOS_WINDOW_BUTTON_RADIUS = 7;
// The buttons' left inset; the renderer leaves them 82px (preload.ts).
const MACOS_WINDOW_BUTTON_X = 14;

function syncMacosWindowButtons(window: Electron.BrowserWindow): void {
if (window.isDestroyed() || window.isFullScreen()) return;
window.setWindowButtonPosition({
x: 16,
x: MACOS_WINDOW_BUTTON_X,
y: Math.round(
(MACOS_WORKSPACE_TOPBAR_HEIGHT * window.webContents.getZoomFactor()) / 2 -
MACOS_WINDOW_BUTTON_RADIUS,
Expand Down Expand Up @@ -262,7 +264,7 @@ function getWindowTitleBarOptions(
return {
titleBarStyle: "hiddenInset",
trafficLightPosition: {
x: 16,
x: MACOS_WINDOW_BUTTON_X,
y: MACOS_WORKSPACE_TOPBAR_HEIGHT / 2 - MACOS_WINDOW_BUTTON_RADIUS,
},
};
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/generated-uniwind-theme-names.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
[
"chatgpt-light",
"chatgpt-dark",
"t3-chat-light",
"t3-chat-dark",
"grove-light",
Expand Down
308 changes: 308 additions & 0 deletions apps/mobile/generated-uniwind-themes.css

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/mobile/scripts/generate-uniwind-themes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ describe("generate mobile Uniwind themes", () => {

it("registers every custom palette for both appearances", () => {
expect(customThemeNames).toEqual([
"chatgpt-light",
"chatgpt-dark",
"t3-chat-light",
"t3-chat-dark",
"grove-light",
Expand Down
9 changes: 8 additions & 1 deletion apps/mobile/src/lib/mobileTheme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,13 @@ export function createMobileThemeVariables(
const c = nativeColors(colors);
const groupedCard = themeColorToNativeColor(groupedCardColor);
const textSurfaces = [c.canvas, c.surface, c.surfaceRaised, c.chrome, groupedCard];
// Skills in a sent message wear the send color; a monochrome theme's send
// color is its message text (ChatGPT's), so there they take its accent.
const actionOnBubble = readableTextColor(c.messageAction, c.messageSurface);
const skillOnBubble =
actionOnBubble === c.messageForeground
? readableTextColor(c.accent, c.messageSurface)
: actionOnBubble;
return {
"--color-screen": c.canvas,
"--color-sheet": withAlpha(c.chrome, 0.98),
Expand Down Expand Up @@ -330,7 +337,7 @@ export function createMobileThemeVariables(
"--color-user-bubble": c.messageSurface,
"--color-user-bubble-foreground": c.messageForeground,
"--color-user-bubble-foreground-muted": withAlpha(c.messageForeground, 0.78),
"--color-user-bubble-skill-foreground": readableTextColor(c.messageAction, c.messageSurface),
"--color-user-bubble-skill-foreground": skillOnBubble,
"--color-backdrop": withAlpha("#000000", appearance === "dark" ? 0.48 : 0.22),
"--color-drawer": c.sidebar,
"--color-drawer-foreground": c.sidebarForeground,
Expand Down
4 changes: 2 additions & 2 deletions apps/mobile/src/lib/mobileThemeRuntime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ describe("mobileThemeRuntime", () => {
(operation) => operation.kind === "update-text-variables",
);

expect(variableOperations).toHaveLength(12);
expect(variableOperations).toHaveLength(14);
expect(variableOperations.at(-1)?.themeName).toBe("iris-dark");
expect(operations.at(-1)).toEqual({
kind: "set-appearance-mode",
Expand Down Expand Up @@ -68,7 +68,7 @@ describe("mobileThemeRuntime", () => {
baseFontSize: 18,
});

expect(operations).toHaveLength(12);
expect(operations).toHaveLength(14);
expect(operations.every((operation) => operation.kind === "update-text-variables")).toBe(true);
expect(operations.at(-1)).toMatchObject({
kind: "update-text-variables",
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/uniwind-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

declare module 'uniwind' {
export interface UniwindConfig {
themes: readonly ['light', 'dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark']
themes: readonly ['light', 'dark', 'chatgpt-light', 'chatgpt-dark', 't3-chat-light', 't3-chat-dark', 'grove-light', 'grove-dark', 'ocean-light', 'ocean-dark', 'ember-light', 'ember-dark', 'iris-light', 'iris-dark']
}
}

Expand Down
206 changes: 206 additions & 0 deletions apps/web/src/appFrame.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
/*
* Otter Mail's window frame (AppSidebarLayout sets data-app-frame on the
* sidebar wrapper, from md up). The window wears the sidebar's surface: the
* rail of spaces runs down its left edge, and the sidebar and panes after it
* share one rounded content panel. In the desktop app ("window") the panel
* starts under the title band, so every pane's header sits on the frame, with
* a thin margin of frame on the right and bottom. In a browser tab ("tab")
* the panel runs to the page's edges, rounded only against the rail.
*
* The panes keep painting their own surfaces; the frame only recolors their
* title bands, and AppFramePanelEdge draws the rim and rounds the corners
* over them, so no pane has to know where the panel's corners fall.
*/

:root {
--workspace-rail-width: 52px;
/* The window's frame: a step off the canvas, as Otter Mail's Mac window
reads over its vibrancy, so the panel inside it has an edge. Themes use
their sidebar color. Resolved on the root so it is one color everywhere. */
--app-frame: #f4f4f5;
/* The panel's edge against the frame: the frame a little of the way to the
text, so it shows in every theme whatever its border role is. */
--panel-edge: color-mix(in srgb, var(--foreground) 5%, var(--app-frame));
/* The sidebar's body in the panel: halfway from the frame to the canvas, so
the rail stands apart from it. */
--sidebar-panel-surface: color-mix(in srgb, var(--app-frame) 50%, var(--background));
--app-frame-radius: calc(var(--radius) + 4px);

@variant dark {
--app-frame: #171717;
}
}

html[data-theme-id]:not([data-theme-id=""]) {
--app-frame: var(--app-theme-sidebar);
}

/* The Mac app's thin chrome (Linear's): the traffic lights sit centered in it
(apps/desktop DesktopWindow). Windows and Linux follow their overlay. */
html.electron:not(.wco) {
--workspace-topbar-height: 42px;
}

[data-app-frame] {
background-color: var(--app-frame);
}

/* Where the rail's first space sits: in line with the sidebar's brand row,
the panel's first row (under the title band in the desktop app). */
[data-space-rail] {
--space-rail-top: calc((var(--workspace-topbar-height) - 2.25rem) / 2);
}

/* The panes after the rail. The fixed sidebar is positioned inside them. */
[data-app-frame="window"] [data-app-panel] {
padding: 0 0.25rem 0.25rem 0;
}

[data-app-frame] [data-slot="sidebar-container"] {
left: var(--workspace-rail-width);
}

/* Collapsed, the sidebar slides away under the rail. */
[data-app-frame] [data-collapsible="offcanvas"] [data-slot="sidebar-container"] {
left: calc(var(--workspace-rail-width) - var(--sidebar-width));
}

[data-app-frame="window"] [data-slot="sidebar-container"] {
height: auto;
bottom: 0.25rem;
}

[data-app-frame] [data-slot="sidebar-inner"] {
background: var(--sidebar-panel-surface);
}

[data-app-frame="window"] [data-slot="sidebar-inner"] {
background: linear-gradient(
to bottom,
transparent var(--workspace-topbar-height),
var(--sidebar-panel-surface) var(--workspace-topbar-height)
);
}

/* In the desktop app each pane's title band is part of the frame. */
[data-app-frame="window"] [data-slot="sidebar-inset"] {
height: auto;
background: linear-gradient(
to bottom,
var(--app-frame) var(--workspace-topbar-height),
var(--background) var(--workspace-topbar-height)
);
}

/* The pages' shared header (WorkspacePageHeader): pages such as Settings
paint the canvas behind it, so it carries the frame itself, only as far
down as the band (a header that wraps taller reaches into the panel). */
[data-app-frame="window"] [data-workspace-page-header] {
background: linear-gradient(
to bottom,
var(--app-frame) var(--workspace-topbar-height),
transparent var(--workspace-topbar-height)
);
}

/* Themes paint the header with their toolbar color; on the frame it is the frame's. */
[data-app-frame="window"] [data-chat-header] {
--toolbar-background: var(--app-frame);
background-color: var(--app-frame);
}

[data-app-frame="window"] [data-preview-panel-mode="inline"] {
background: linear-gradient(
to bottom,
var(--app-frame) var(--workspace-topbar-height),
var(--background) var(--workspace-topbar-height)
);
}

/* The rim and the rounded corners, over the panes (AppFramePanelEdge). */
[data-app-panel-edge] {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 30;
}

[data-app-frame="window"] [data-app-panel-edge] {
inset: var(--workspace-topbar-height) 0.25rem 0.25rem 0;
}

[data-app-panel-edge] > [data-rim] {
position: absolute;
inset: 0;
border: 1px solid var(--panel-edge);
border-radius: var(--app-frame-radius);
}

[data-app-frame="tab"] [data-app-panel-edge] > [data-rim] {
border-block-width: 0;
border-right-width: 0;
border-radius: var(--app-frame-radius) 0 0 var(--app-frame-radius);
}

/* Each corner is frame-colored beyond the panel's curve. */
[data-app-panel-edge] > [data-corner] {
position: absolute;
width: var(--app-frame-radius);
height: var(--app-frame-radius);
--corner-curve:
transparent calc(var(--app-frame-radius) - 0.5px),
var(--app-frame) calc(var(--app-frame-radius) + 0.5px);
}

[data-app-panel-edge] > [data-corner="top-left"] {
top: 0;
left: 0;
background: radial-gradient(circle at 100% 100%, var(--corner-curve));
}

[data-app-panel-edge] > [data-corner="top-right"] {
top: 0;
right: 0;
background: radial-gradient(circle at 0 100%, var(--corner-curve));
}

[data-app-panel-edge] > [data-corner="bottom-left"] {
bottom: 0;
left: 0;
background: radial-gradient(circle at 100% 0, var(--corner-curve));
}

[data-app-panel-edge] > [data-corner="bottom-right"] {
bottom: 0;
right: 0;
background: radial-gradient(circle at 0 0, var(--corner-curve));
}

[data-app-frame="tab"]
[data-app-panel-edge]
> :is([data-corner="top-right"], [data-corner="bottom-right"]) {
display: none;
}

/* The dev and nightly stage art sits at the top of the panel, behind the
brand, and fades into the sidebar's body. */
:root [data-app-frame] [data-app-sidebar] {
--sidebar-stage-fade: var(--sidebar-panel-surface);
}

/* Where the sidebar's heading starts in its first row: past the pinned
sidebar toggle in a tab; in line with the rows below it in the desktop
app's panel, whose title band holds the toggle. */
:root {
--sidebar-heading-start: 0px;
}

[data-app-frame="tab"] [data-app-sidebar] {
--sidebar-heading-start: calc(
var(--workspace-titlebar-content-left) - var(--sidebar-row-content-inset)
);
}

[data-app-frame="window"] [data-app-sidebar] {
--sidebar-heading-start: var(--sidebar-content-inset);
}
Loading
Loading