Skip to content

Commit d2fe225

Browse files
youknowriadclaude
andcommitted
Add new portable UI app shell (apps/ui)
Introduces a new UI layer that can run as both an Electron renderer and a standalone web app. Uses an injectable connector pattern for data access (IPC for Electron, Telex REST API for web), TanStack Router (code-based) and TanStack Query with localStorage persistence. Co-Authored-By: Claude Opus 4.6 <[email protected]>
1 parent 0ec159c commit d2fe225

36 files changed

Lines changed: 2278 additions & 112 deletions
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* Electron-vite dev config for running the Electron app with the new @studio/ui renderer.
3+
* Builds main + preload in development mode; skips the renderer since it's handled
4+
* by the @studio/ui Vite dev server (pointed to via ELECTRON_RENDERER_URL).
5+
*/
6+
7+
import { resolve } from 'path';
8+
import { defineConfig } from 'electron-vite';
9+
10+
export default defineConfig( {
11+
main: {
12+
plugins: [],
13+
resolve: {
14+
alias: {
15+
src: resolve( __dirname, 'src' ),
16+
'@studio/common': resolve( __dirname, '../../tools/common' ),
17+
'@wp-playground/blueprints/blueprint-schema-validator': resolve(
18+
__dirname,
19+
'../../node_modules/@wp-playground/blueprints/blueprint-schema-validator.js'
20+
),
21+
},
22+
},
23+
define: {
24+
'process.env.NODE_ENV': JSON.stringify( process.env.NODE_ENV ),
25+
COMMIT_HASH: JSON.stringify( 'dev' ),
26+
},
27+
build: {
28+
externalizeDeps: {
29+
exclude: [ '@studio/common' ],
30+
},
31+
rollupOptions: {
32+
input: {
33+
index: resolve( __dirname, 'src/index.ts' ),
34+
},
35+
output: {
36+
entryFileNames: '[name].js',
37+
},
38+
external: [ /^@php-wasm\/.*/ ],
39+
},
40+
},
41+
},
42+
preload: {
43+
build: {
44+
externalizeDeps: { exclude: [ '@sentry/electron' ] },
45+
lib: {
46+
entry: resolve( __dirname, 'src/preload.ts' ),
47+
},
48+
},
49+
},
50+
} );

apps/studio/src/main-window.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,7 @@ export async function createMainWindow(): Promise< BrowserWindow > {
6565
}
6666

6767
const userData = await loadUserData();
68-
nativeTheme.themeSource = userData.colorScheme ?? 'light';
68+
nativeTheme.themeSource = userData.colorScheme ?? 'system';
6969

7070
const savedBounds = await loadWindowBounds();
7171
let windowOptions: BrowserWindowConstructorOptions = {

apps/studio/src/modules/user-settings/lib/ipc-handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ export async function saveColorScheme(
8585

8686
export async function getColorScheme(): Promise< 'system' | 'light' | 'dark' > {
8787
const userData = await loadUserData();
88-
const colorScheme = userData.colorScheme ?? 'light';
88+
const colorScheme = userData.colorScheme ?? 'system';
8989
nativeTheme.themeSource = colorScheme;
9090
return colorScheme;
9191
}

apps/ui/README.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# @studio/ui
2+
3+
A portable UI layer for Studio that can run as both an Electron renderer and a standalone web app.
4+
5+
## Architecture
6+
7+
### Dual-target design
8+
9+
The same React application runs in two environments with different data backends:
10+
11+
- **Electron**: Uses an IPC connector that delegates to `window.ipcApi` (exposed by the Electron preload script). Auth is optional (handled by the desktop app).
12+
- **Web**: Uses a REST connector backed by the Telex API (`telex.automattic.ai`) with WordPress.com OAuth for authentication.
13+
14+
A single entry point (`main.tsx`) selects the connector at runtime based on the `__IS_ELECTRON__` Vite define (set via the `--mode` flag). The UI code never imports environment-specific modules directly.
15+
16+
### Connector pattern
17+
18+
The `Connector` interface (`data/core/types.ts`) defines the data operations the UI needs:
19+
20+
```
21+
data/core/
22+
types.ts # Connector interface + domain types (SiteDetails, AuthUser)
23+
connector-context.tsx # React Context provider + useConnector() hook
24+
query-client.ts # TanStack Query client with localStorage persistence
25+
connectors/
26+
ipc/index.ts # Electron IPC implementation (requiresAuth: false)
27+
rest/index.ts # Telex REST API + WP.com OAuth (requiresAuth: true)
28+
```
29+
30+
Components access data through the `useConnector()` hook, which pulls the active connector from React Context. This keeps all UI code environment-agnostic.
31+
32+
The `Connector` interface includes an auth surface (`requiresAuth`, `isAuthenticated`, `authenticate`, `logout`, `getAuthUser`) alongside data methods (`getSites`, `createSite`, `deleteSite`, `startSite`, `stopSite`). The interface is intentionally minimal -- new methods are added as features are built.
33+
34+
### Authentication
35+
36+
The REST connector uses WordPress.com OAuth (implicit grant flow) for authentication:
37+
38+
1. `connector.authenticate()` redirects to `public-api.wordpress.com/oauth2/authorize`
39+
2. After authorization, WordPress.com redirects back to `/auth/callback#access_token=...`
40+
3. `main.tsx` intercepts the callback **before React mounts** to avoid TanStack Router parsing issues with special characters in the hash fragment
41+
4. The token and user profile are stored in `localStorage`
42+
43+
Route protection is handled in the root route's `beforeLoad` hook. When `connector.requiresAuth` is true, all routes except `/login` require authentication -- unauthenticated users are redirected to `/login`.
44+
45+
The IPC connector sets `requiresAuth: false`, so auth checks are skipped entirely in Electron.
46+
47+
### Data fetching with TanStack Query
48+
49+
Query hooks in `data/queries/` wrap connector methods with TanStack Query for caching, deduplication, and cache invalidation. The query client uses localStorage persistence (24h max age) mirroring the wp-calypso setup.
50+
51+
```typescript
52+
function useSites() {
53+
const connector = useConnector();
54+
return useQuery({
55+
queryKey: ['sites'],
56+
queryFn: () => connector.getSites(),
57+
});
58+
}
59+
```
60+
61+
Mutations invalidate related queries on success, keeping the UI in sync without manual refetching.
62+
63+
### Routing with TanStack Router
64+
65+
Routes are **code-based** (not file-based), following the wp-calypso hosting dashboard pattern. Routes are defined with `createRoute()` calls under `router/` and assembled into a route tree in `router/router.tsx`.
66+
67+
The router context carries both the `QueryClient` and `Connector`, enabling route-level data prefetching and auth checks in `beforeLoad` hooks.
68+
69+
### Component structure
70+
71+
Components use a folder-per-component pattern with CSS Modules:
72+
73+
```
74+
components/
75+
sidebar-layout/
76+
index.tsx
77+
style.module.css
78+
site-list/
79+
index.tsx
80+
style.module.css
81+
onboarding-layout/
82+
index.tsx
83+
style.module.css
84+
```
85+
86+
UI is built with `@wordpress/ui` and `@wordpress/theme` from the WordPress Design System, plus `@wordpress/icons` for iconography.
87+
88+
## Development
89+
90+
You can run the UI in two modes during development:
91+
92+
93+
```bash
94+
# Web mode (REST connector, Telex API)
95+
npm -w @studio/ui run dev:web
96+
97+
# Electron mode (Electron app with IPC connector)
98+
npm run start:new-ui
99+
```

apps/ui/index.html

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6+
<title>Studio</title>
7+
</head>
8+
<body>
9+
<div id="root"></div>
10+
<script type="module" src="/src/main.tsx"></script>
11+
</body>
12+
</html>

apps/ui/package.json

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"name": "@studio/ui",
3+
"private": true,
4+
"version": "0.1.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev:web": "vite --mode web",
8+
"dev:electron": "vite --mode electron",
9+
"build:web": "vite build --mode web",
10+
"build:electron": "vite build --mode electron",
11+
"typecheck": "tsc -p tsconfig.json --noEmit",
12+
"preview": "vite preview"
13+
},
14+
"dependencies": {
15+
"@tanstack/query-sync-storage-persister": "^5.96.2",
16+
"@tanstack/react-query": "^5.75.5",
17+
"@tanstack/react-query-persist-client": "^5.96.2",
18+
"@tanstack/react-router": "^1.120.14",
19+
"@wordpress/icons": "^11.8.0",
20+
"@wordpress/private-apis": "^1.10.0",
21+
"@wordpress/theme": "0.10.0",
22+
"@wordpress/ui": "0.10.0",
23+
"react": "^18.2.0",
24+
"react-dom": "^18.2.0"
25+
},
26+
"devDependencies": {
27+
"@types/react": "^18.3.27",
28+
"@types/react-dom": "^18.3.7",
29+
"@vitejs/plugin-react": "^5.1.4",
30+
"typescript": "~5.9.3",
31+
"vite": "^7.3.1"
32+
}
33+
}

apps/ui/src/app.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { QueryClientProvider } from '@tanstack/react-query';
2+
import { RouterProvider } from '@tanstack/react-router';
3+
import { privateApis } from '@wordpress/theme';
4+
import { ConnectorProvider, queryClient } from '@/data/core';
5+
import { unlock } from '@/lock-unlock';
6+
import { createAppRouter } from '@/router/router';
7+
import '@wordpress/theme/design-tokens.css';
8+
import '@/index.css';
9+
import type { Connector } from '@/data/core';
10+
11+
const { ThemeProvider } = unlock( privateApis );
12+
13+
export type AppTarget = 'electron' | 'web';
14+
15+
interface AppProps {
16+
connector: Connector;
17+
target: AppTarget;
18+
}
19+
20+
export function App( { connector, target }: AppProps ) {
21+
const router = createAppRouter( { queryClient, connector } );
22+
23+
return (
24+
<div className={ `studio-${ target }` }>
25+
<ConnectorProvider connector={ connector }>
26+
<QueryClientProvider client={ queryClient }>
27+
<ThemeProvider isRoot>
28+
<RouterProvider router={ router } />
29+
</ThemeProvider>
30+
</QueryClientProvider>
31+
</ConnectorProvider>
32+
</div>
33+
);
34+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import styles from './style.module.css';
2+
import type { ReactNode } from 'react';
3+
4+
export function OnboardingLayout( { children }: { children: ReactNode } ) {
5+
return (
6+
<div className={ styles.root }>
7+
<div className={ styles.content }>{ children }</div>
8+
</div>
9+
);
10+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
.root {
2+
display: flex;
3+
height: 100vh;
4+
align-items: center;
5+
justify-content: center;
6+
}
7+
8+
.content {
9+
max-width: 640px;
10+
width: 100%;
11+
padding: 32px;
12+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { SiteList } from '@/components/site-list';
2+
import styles from './style.module.css';
3+
import type { ReactNode } from 'react';
4+
5+
export function SidebarLayout( { children }: { children: ReactNode } ) {
6+
return (
7+
<div className={ styles.root }>
8+
<aside className={ styles.sidebar }>
9+
<div className={ styles.spacer } />
10+
<div className={ styles.separator } />
11+
<SiteList />
12+
</aside>
13+
<main className={ styles.main }>{ children }</main>
14+
</div>
15+
);
16+
}

0 commit comments

Comments
 (0)