Skip to content
Merged
3 changes: 2 additions & 1 deletion apps/desktop/src/main/userHackFiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ dripnex.menu.add({
// ctx.layout.addComponent('modal', { id: 'hello', component: Hello })
// Markdown: dripnex.markdownRenderer.remarkPlugins.push(yourPlugin)
// Editor view (CM6): dripnex.editor.getView() / dripnex.getActiveEditor().cm
// Palettes: dripnex.themes.list() / getActive() / setActive('dripnex-parchment')
// Palettes: dripnex.themes.list() / getActive() / setActive('theme-parchment')
// (install dripnex/theme-parchment first — named palettes are packs, not core)

// Vim (install dripnex/plugin-vim, then enable it). Same surface as Inkdrop:
// const Vim = dripnex.vim
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/main/windows/__tests__/menuLayout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { appMenuIncludesSettings, fileMenuSlots } from '../menuLayout';

describe('fileMenuSlots', () => {
it('puts Settings in File on Linux so the workspace is reachable without a session', () => {
expect(fileMenuSlots('linux')).toEqual(['settings', 'separator', 'quit']);
});

it('puts Settings in File on Windows', () => {
expect(fileMenuSlots('win32')).toEqual(['settings', 'separator', 'quit']);
});

it('leaves macOS File as Close; Settings lives in the app menu', () => {
expect(fileMenuSlots('darwin')).toEqual(['close']);
expect(appMenuIncludesSettings('darwin')).toBe(true);
expect(appMenuIncludesSettings('linux')).toBe(false);
});
});
45 changes: 42 additions & 3 deletions apps/desktop/src/main/windows/applicationMenu.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@
* Plugins contribute items under Plugins via IPC.
*/

import { BrowserWindow, Menu, ipcMain } from 'electron';
import { app, BrowserWindow, Menu, ipcMain } from 'electron';
import type { MenuItemConstructorOptions } from 'electron';
import { openUserHackFile } from '../userHackFiles.js';
import { createSettingsWindow } from './settingsWindow.js';
import { fileMenuSlots } from './menuLayout.js';

export interface PluginMenuContribution {
pluginId: string;
Expand All @@ -31,6 +33,26 @@ function targetWindow(browserWindow: unknown): BrowserWindow | null {
return BrowserWindow.getFocusedWindow();
}

function settingsMenuItem(): MenuItemConstructorOptions {
return {
label: 'Settings…',
accelerator: 'CommandOrControl+,',
click: () => {
createSettingsWindow();
},
};
}

function fileMenu(): MenuItemConstructorOptions {
const items: MenuItemConstructorOptions[] = fileMenuSlots(process.platform).map(slot => {
if (slot === 'settings') return settingsMenuItem();
if (slot === 'separator') return { type: 'separator' };
if (slot === 'close') return { role: 'close' };
return { role: 'quit' };
});
return { label: 'File', submenu: items };
}

function buildTemplate(): MenuItemConstructorOptions[] {
const contributed: MenuItemConstructorOptions[] = pluginItems.map(item => ({
label: item.label,
Expand Down Expand Up @@ -77,9 +99,26 @@ function buildTemplate(): MenuItemConstructorOptions[] {
...(contributed.length > 0 ? contributed : [{ label: 'No plugin commands', enabled: false }]),
];

const darwinAppMenu: MenuItemConstructorOptions = {
label: app.name,
submenu: [
{ role: 'about' },
{ type: 'separator' },
settingsMenuItem(),
{ type: 'separator' },
{ role: 'services' },
{ type: 'separator' },
{ role: 'hide' },
{ role: 'hideOthers' },
{ role: 'unhide' },
{ type: 'separator' },
{ role: 'quit' },
],
};

const template: MenuItemConstructorOptions[] = [
...(process.platform === 'darwin' ? [{ role: 'appMenu' as const }] : []),
{ role: 'fileMenu' },
...(process.platform === 'darwin' ? [darwinAppMenu] : []),
fileMenu(),
{
label: 'Note',
submenu: [
Expand Down
11 changes: 11 additions & 0 deletions apps/desktop/src/main/windows/menuLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export type FileMenuSlot = 'settings' | 'separator' | 'quit' | 'close';

/** Linux/Windows File menu includes Settings. macOS keeps Settings in the app menu. */
export function fileMenuSlots(platform: NodeJS.Platform): FileMenuSlot[] {
if (platform === 'darwin') return ['close'];
return ['settings', 'separator', 'quit'];
}

export function appMenuIncludesSettings(platform: NodeJS.Platform): boolean {
return platform === 'darwin';
}
27 changes: 3 additions & 24 deletions apps/desktop/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ import { LicenseProvider } from './contexts/LicenseContext';
import { ToastProvider } from './components/Toast';
import { Toaster } from './ui/primitives';
import { Welcome } from './components/Welcome';
import { AuthGate } from './components/auth/AuthGate';
import { useAuthStore, selectIsAuthenticated, selectSessionHydrated } from './stores/authStore';
import { useAuthStore } from './stores/authStore';
import { resolveAppShell } from './utils/appShell';
import { ErrorBoundary } from './components/ErrorBoundary';
import {
useNavigation,
Expand Down Expand Up @@ -78,9 +78,6 @@ function NotesApp() {
const [showWelcome, setShowWelcome] = useState(
() => !localStorage.getItem('dripnex-onboarding-done')
);
const sessionHydrated = useAuthStore(selectSessionHydrated);
const isAuthenticated = useAuthStore(selectIsAuthenticated);
const skipAuthGate = window.dripnex?.app?.isE2E?.() === true;

// Resizable layout
const {
Expand Down Expand Up @@ -403,25 +400,7 @@ function NotesApp() {
[handleNewNote]
);

if (!skipAuthGate && !sessionHydrated) {
return (
<ToastProvider>
<AuthGate hydrating />
<Toaster />
</ToastProvider>
);
}

if (!skipAuthGate && !isAuthenticated) {
return (
<ToastProvider>
<AuthGate />
<Toaster />
</ToastProvider>
);
}

if (showWelcome && skipAuthGate) {
if (resolveAppShell({ onboardingComplete: !showWelcome }) === 'welcome') {
return (
<ToastProvider>
<Welcome onComplete={handleWelcomeComplete} />
Expand Down
6 changes: 3 additions & 3 deletions apps/desktop/src/renderer/components/auth/AuthGate.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useCallback, useEffect, useState, type FormEvent } from 'react';
import { useAuthStore, selectError } from '../../stores/authStore';
import logo from '../../assets/logo.png';
import { LoginBackdrop } from './LoginBackdrop';
import styles from './AuthGate.module.css';
import logo from '../../assets/logo.png';

/**
* Full-window sign-in. Account is required even on the free plan
* so every install maps to a user.
* Magic-link card for optional sync. Must not be used as a hard launch gate —
* local notes, Settings, and plugins work without an account.
*/
export function AuthGate({ hydrating = false }: { hydrating?: boolean }) {
const requestMagicLink = useAuthStore(state => state.requestMagicLink);
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/src/renderer/data/nowBoard.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Updated: 2026-08-17. Version in tree: 0.15.2.

## Now (true today)

- Desktop is the product. Account required (AuthGate). Sync is optional E2E.
- Desktop is the product. No account to open a file. Sync is optional E2E.
- Magic link + React Email. API on Cloudflare (`api.dripnex.app`).
- AI on notes (local providers). Ask-notes and `search_notes` share one hybrid retriever.
- Dripnex AI is hosted Claude (product key). Dev builds show “Not in this build”.
Expand Down
12 changes: 9 additions & 3 deletions apps/desktop/src/renderer/hooks/useCommandKeybindings.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import type { KeyModifier, KeyBinding } from '@dripnex/command-registry';
import { resolveCommandContext } from '../utils/commandContext';
import { allowsAppCommandInForm, resolveCommandContext } from '../utils/commandContext';
import { registry, getEditorView } from './useCommandRegistry';

/**
Expand Down Expand Up @@ -68,8 +68,14 @@ export function useCommandKeybindings(options?: UseCommandKeybindingsOptions): v
if (!view) command = undefined;
}

// For app commands, skip if user is in a form input (but not CodeMirror)
if (command?.context === 'app' && context !== 'editor' && isFormInput(e.target)) {
// For app commands, skip if user is in a form input (but not CodeMirror).
// Settings stays available so Ctrl/Cmd+, works from an email field.
if (
command?.context === 'app' &&
context !== 'editor' &&
isFormInput(e.target) &&
!allowsAppCommandInForm(command.id)
) {
return;
}

Expand Down
31 changes: 20 additions & 11 deletions apps/desktop/src/renderer/hooks/useOfficialThemes.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { useEffect, useSyncExternalStore } from 'react';
import { setHostThemeActive, themeRegistryStore } from '@dripnex/plugin-api';
import { useSettingsStore, selectAppearance } from '../stores/settings';
import { registerOfficialThemes } from '../themes/officialThemes';
import {
persistClearedThemeIfNeeded,
registerOfficialThemes,
restoreSavedTheme,
syncInstalledPluginThemes,
} from '../themes/officialThemes';

/** Persist a palette the same way Settings → Appearance does. */
export function applyHostTheme(id: string | null): boolean {
Expand All @@ -22,7 +27,7 @@ export function applyHostTheme(id: string | null): boolean {
return true;
}

/** Register first-party palettes and restore the last chosen one. */
/** Restore the last chosen palette and register installed plugin themes. */
export function useOfficialThemes(): void {
const appearance = useSettingsStore(selectAppearance);
const registeredThemeCount = useSyncExternalStore(
Expand All @@ -33,22 +38,26 @@ export function useOfficialThemes(): void {
useEffect(() => {
registerOfficialThemes();
setHostThemeActive(applyHostTheme);
return () => setHostThemeActive(null);
void syncInstalledPluginThemes();
const offReload = window.dripnex?.ipc?.on('plugins:reload', () => {
void syncInstalledPluginThemes();
});
return () => {
setHostThemeActive(null);
offReload?.();
};
}, []);

useEffect(() => {
if (registeredThemeCount === 0) return;
const savedThemeId = appearance?.activeThemeId ?? null;
// Each window has its own registry. Settings can setActive(null) locally
// and broadcast appearance.activeThemeId; the notes window must clear too
// or named-palette tokens stay inline and Light never shows.
if (savedThemeId === null) {
themeRegistryStore.getState().setActive(null);
return;
}
const exists = themeRegistryStore.getState().themes.some(t => t.id === savedThemeId);
if (!exists) return;
themeRegistryStore.getState().setActive(savedThemeId);
const result = restoreSavedTheme(savedThemeId);
persistClearedThemeIfNeeded(savedThemeId, result, id => {
useSettingsStore.getState().updateAppearance({ activeThemeId: id });
});
if (result !== 'activated') return;
const palette = themeRegistryStore.getState().getActiveTheme();
const paletteAccent = palette?.tokens['--accent'];
if (paletteAccent && appearance?.accentColor === '#5eead4') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
background: var(--bg-elevated);
}

:global(html[data-theme='dripnex-glass']) .card {
:global(html[data-frosted]) .card {
backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturate));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export function ThemesSection() {
<div className={styles.section}>
<h2 className={styles.title}>Themes</h2>
<p className={styles.lede}>
Official combinations. A community theme is its own repo — same tokens, no core fork.
Default uses the built-in appearance. Named palettes come from installed theme packs — same
tokens, no core fork.
</p>
<div className={themeStyles.grid}>
<PaletteCard
Expand Down
Loading
Loading