feat(ui): add five new light themes and redesign settings modal
Add five new light theme options (lavender, rose, sand, sky, slate) with dedicated color palettes. Redesign Settings modal with sidebar navigation replacing tabs, add visual theme color selector showing preview swatches, and introduce new icons (KeyIcon, PaletteIcon, UserIcon, DocumentIcon). Also update PrivacyModal to render conditionally based on authEnabled and add privacy policy link. Update tests to reflect new UI structure.
This commit is contained in:
parent
fe05de9fec
commit
1deb8c21f7
9 changed files with 711 additions and 509 deletions
|
|
@ -109,6 +109,81 @@ html.mint {
|
|||
);
|
||||
}
|
||||
|
||||
html.lavender {
|
||||
--background: #faf8ff;
|
||||
--foreground: #3b2e5a;
|
||||
--base: #f3effb;
|
||||
--offbase: #e4daf0;
|
||||
--accent: #7c3aed;
|
||||
--secondary-accent: #a78bfa;
|
||||
--muted: #8e7bab;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#c4b5fd,
|
||||
#a78bfa,
|
||||
#7c3aed
|
||||
);
|
||||
}
|
||||
|
||||
html.rose {
|
||||
--background: #fff8f8;
|
||||
--foreground: #4a2c2c;
|
||||
--base: #fef1f1;
|
||||
--offbase: #f5dada;
|
||||
--accent: #e11d48;
|
||||
--secondary-accent: #f472b6;
|
||||
--muted: #b08a8a;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#fda4af,
|
||||
#fb7185,
|
||||
#e11d48
|
||||
);
|
||||
}
|
||||
|
||||
html.sand {
|
||||
--background: #fdfbf7;
|
||||
--foreground: #44392a;
|
||||
--base: #f7f2e8;
|
||||
--offbase: #e8dfc9;
|
||||
--accent: #b45309;
|
||||
--secondary-accent: #d97706;
|
||||
--muted: #9a8b74;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#fcd34d,
|
||||
#f59e0b,
|
||||
#b45309
|
||||
);
|
||||
}
|
||||
|
||||
html.sky {
|
||||
--background: #f6faff;
|
||||
--foreground: #1e3a5f;
|
||||
--base: #edf4fc;
|
||||
--offbase: #d5e3f5;
|
||||
--accent: #2563eb;
|
||||
--secondary-accent: #3b82f6;
|
||||
--muted: #6b8db5;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#93c5fd,
|
||||
#60a5fa,
|
||||
#2563eb
|
||||
);
|
||||
}
|
||||
|
||||
html.slate {
|
||||
--background: #e8ecf0;
|
||||
--foreground: #2c3440;
|
||||
--base: #dde2e8;
|
||||
--offbase: #c8ced6;
|
||||
--accent: #5b7a9d;
|
||||
--secondary-accent: #7393b0;
|
||||
--muted: #7a8694;
|
||||
--prism-gradient: linear-gradient(90deg,
|
||||
#a3bdd4,
|
||||
#7393b0,
|
||||
#5b7a9d
|
||||
);
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ const figtree = Figtree({
|
|||
|
||||
const themeInitScript = `
|
||||
(() => {
|
||||
const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint'];
|
||||
const themes = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate'];
|
||||
const lightThemes = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
|
||||
const root = document.documentElement;
|
||||
const stored = localStorage.getItem('theme');
|
||||
const selected = stored && themes.includes(stored) ? stored : 'system';
|
||||
|
|
@ -23,7 +24,7 @@ const themeInitScript = `
|
|||
: selected;
|
||||
root.classList.remove(...themes);
|
||||
root.classList.add(effective);
|
||||
root.style.colorScheme = effective === 'dark' ? 'dark' : 'light';
|
||||
root.style.colorScheme = lightThemes.has(effective) ? 'light' : 'dark';
|
||||
})();
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
|||
<AuthLoader>
|
||||
<>
|
||||
{children}
|
||||
<PrivacyModal authEnabled={authEnabled} />
|
||||
{authEnabled && <PrivacyModal />}
|
||||
</>
|
||||
</AuthLoader>
|
||||
</ThemeProvider>
|
||||
|
|
@ -64,7 +64,7 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
|
|||
<HTMLProvider>
|
||||
<>
|
||||
{children}
|
||||
<PrivacyModal authEnabled={authEnabled} />
|
||||
{authEnabled && <PrivacyModal />}
|
||||
<DexieMigrationModal />
|
||||
</>
|
||||
</HTMLProvider>
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import { updateAppConfig, getAppConfig } from '@/lib/client/dexie';
|
|||
|
||||
interface PrivacyModalProps {
|
||||
onAccept?: () => void;
|
||||
authEnabled?: boolean;
|
||||
}
|
||||
|
||||
function PrivacyModalBody({ origin }: { origin: string }) {
|
||||
|
|
@ -32,7 +31,8 @@ function PrivacyModalBody({ origin }: { origin: string }) {
|
|||
</p>
|
||||
|
||||
<p className="leading-relaxed">
|
||||
For full details on data collection, processing, and your rights, please review our complete Privacy Policy.
|
||||
For full details on data collection, processing, and your rights, please review our complete{' '}
|
||||
<a href="/privacy" target="_blank" className="font-semibold text-accent hover:underline">Privacy Policy</a>.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -510,6 +510,78 @@ export function GridIcon(props: React.SVGProps<SVGSVGElement>) {
|
|||
</svg>
|
||||
);
|
||||
}
|
||||
export function KeyIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 5.25a3 3 0 013 3m3 0a6 6 0 01-7.029 5.912c-.563-.097-1.159.026-1.563.43L10.5 17.25H8.25v2.25H6v2.25H2.25v-2.818c0-.597.237-1.17.659-1.591l6.499-6.499c.404-.404.527-1 .43-1.563A6 6 0 1121.75 8.25z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PaletteIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M4.098 19.902a3.75 3.75 0 005.304 0l6.401-6.402M6.75 21A3.75 3.75 0 013 17.25V4.125C3 3.504 3.504 3 4.125 3h5.25c.621 0 1.125.504 1.125 1.125v4.072M6.75 21a3.75 3.75 0 003.75-3.75V8.197M6.75 21h13.125c.621 0 1.125-.504 1.125-1.125v-5.25c0-.621-.504-1.125-1.125-1.125h-4.072M10.5 8.197l2.88-2.88c.438-.439 1.15-.439 1.59 0l3.712 3.713c.44.44.44 1.152 0 1.59l-2.88 2.88M6.75 17.25h.008v.008H6.75v-.008z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 11-7.5 0 3.75 3.75 0 017.5 0zM4.501 20.118a7.5 7.5 0 0114.998 0A17.933 17.933 0 0112 21.75c-2.676 0-5.216-.584-7.499-1.632z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function DocumentIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
className={props.className}
|
||||
width={props.width || "1.5em"}
|
||||
height={props.height || "1.5em"}
|
||||
{...props}
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m2.25 0H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileSettingsIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { createContext, useContext, useEffect, useState, ReactNode, useLayoutEffect } from 'react';
|
||||
|
||||
const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint'] as const;
|
||||
const THEMES = ['system', 'light', 'dark', 'ocean', 'forest', 'sunset', 'sea', 'mint', 'lavender', 'rose', 'sand', 'sky', 'slate'] as const;
|
||||
type Theme = (typeof THEMES)[number];
|
||||
|
||||
interface ThemeContextType {
|
||||
|
|
@ -17,6 +17,8 @@ const getSystemTheme = () => {
|
|||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
};
|
||||
|
||||
const LIGHT_THEMES: ReadonlySet<string> = new Set(['light', 'lavender', 'rose', 'sand', 'sky', 'slate']);
|
||||
|
||||
const getEffectiveTheme = (theme: Theme): Theme => {
|
||||
if (theme === 'system') {
|
||||
return getSystemTheme();
|
||||
|
|
@ -24,6 +26,10 @@ const getEffectiveTheme = (theme: Theme): Theme => {
|
|||
return theme;
|
||||
};
|
||||
|
||||
const getColorScheme = (theme: Theme): string => {
|
||||
return LIGHT_THEMES.has(theme) ? 'light' : 'dark';
|
||||
};
|
||||
|
||||
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||
const [theme, setTheme] = useState<Theme>('system');
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
|
@ -36,7 +42,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
const effectiveTheme = getEffectiveTheme(initialTheme);
|
||||
document.documentElement.classList.remove(...THEMES);
|
||||
document.documentElement.classList.add(effectiveTheme);
|
||||
document.documentElement.style.colorScheme = effectiveTheme;
|
||||
document.documentElement.style.colorScheme = getColorScheme(effectiveTheme);
|
||||
if (!stored) {
|
||||
localStorage.setItem('theme', initialTheme);
|
||||
}
|
||||
|
|
@ -49,8 +55,8 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
|
||||
root.classList.remove(...THEMES);
|
||||
root.classList.add(effectiveTheme);
|
||||
root.style.colorScheme = effectiveTheme;
|
||||
|
||||
root.style.colorScheme = getColorScheme(effectiveTheme);
|
||||
|
||||
localStorage.setItem('theme', newTheme);
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
|
@ -64,7 +70,7 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
|
|||
const root = window.document.documentElement;
|
||||
root.classList.remove(...THEMES);
|
||||
root.classList.add(effectiveTheme);
|
||||
root.style.colorScheme = effectiveTheme;
|
||||
root.style.colorScheme = getColorScheme(effectiveTheme);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,13 @@ test.describe('Document deletion flow', () => {
|
|||
});
|
||||
|
||||
test('deletes all local documents from Settings modal', async ({ page }) => {
|
||||
// This test only applies when auth is NOT enabled, since with auth
|
||||
// the bulk-delete UI lives in the delete-account flow instead.
|
||||
test.skip(
|
||||
Boolean(process.env.AUTH_SECRET && process.env.BASE_URL),
|
||||
'Bulk document deletion is part of the delete-account flow when auth is enabled',
|
||||
);
|
||||
|
||||
// Upload multiple docs (PDF + EPUB)
|
||||
await uploadFile(page, 'sample.pdf');
|
||||
await uploadFile(page, 'sample.epub');
|
||||
|
|
|
|||
|
|
@ -346,18 +346,18 @@ export async function deleteDocumentByName(page: Page, fileName: string) {
|
|||
await confirmBtn.click();
|
||||
}
|
||||
|
||||
// Open Settings modal and navigate to Documents tab
|
||||
// Open Settings modal and navigate to Documents section
|
||||
export async function openSettingsDocumentsTab(page: Page) {
|
||||
await page.getByRole('button', { name: 'Settings' }).click();
|
||||
await page.getByRole('tab', { name: '📄 Docs' }).click();
|
||||
await page.getByRole('button', { name: 'Documents' }).click();
|
||||
}
|
||||
|
||||
// Delete all local documents through Settings and close dialogs
|
||||
export async function deleteAllLocalDocuments(page: Page) {
|
||||
await openSettingsDocumentsTab(page);
|
||||
await page.getByRole('button', { name: /Delete (anonymous docs|all user docs|server docs)/i }).click();
|
||||
await page.getByRole('button', { name: /Delete all data/i }).click();
|
||||
|
||||
const heading = page.getByRole('heading', { name: /Delete (Anonymous Docs|All User Docs|Server Docs)/i });
|
||||
const heading = page.getByRole('heading', { name: /Delete All Data/i });
|
||||
await expect(heading).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const confirmBtn = heading.locator('xpath=ancestor::*[@role="dialog"][1]//button[normalize-space()="Delete"]');
|
||||
|
|
|
|||
Loading…
Reference in a new issue