feat: add Verso Lumière theme with card-based project dashboard
Build and Deploy Verso / deploy (push) Successful in 14m45s

New theme with gradient document cards, serif title typography and a
light airy palette. Set as the default for new users. Existing users
keep their current theme; all users can switch via the theme toggle
(new sparkle icon). Classic Dark / Classic Light are renamed accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
claude
2026-06-11 09:13:46 +00:00
parent 31db7b2b4e
commit 926b6f7cbb
10 changed files with 412 additions and 5 deletions
@@ -1364,6 +1364,7 @@ const _ProjectController = {
function getInitialLoadingScreenTheme(overallThemeSetting) {
switch (overallThemeSetting) {
case 'light-':
case 'lumiere-':
return 'light'
case '':
return 'dark'
@@ -1,4 +1,5 @@
const SYSTEM_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 2, 2, 12, 0, 0)) // 12pm GMT on March 2, 2026
const LUMIERE_THEME_USER_CUTOFF_DATE = new Date(Date.UTC(2026, 5, 11, 12, 0, 0)) // 12pm GMT on June 11, 2026
function getOverallTheme(user) {
if (user.ace.overallTheme != null) {
@@ -10,7 +11,11 @@ function getOverallTheme(user) {
return ''
}
return 'system'
if (user.signUpDate < LUMIERE_THEME_USER_CUTOFF_DATE) {
return 'system'
}
return 'lumiere-'
}
async function buildUserSettings(_req, _res, user) {
@@ -307,11 +307,15 @@ export default async function (webRouter, privateApiRouter, publicApiRouter) {
webRouter.use(function (req, res, next) {
res.locals.overallThemes = [
{
name: 'Dark',
name: 'Verso Lumière',
val: 'lumiere-',
},
{
name: 'Classic Dark',
val: '',
},
{
name: 'Light',
name: 'Classic Light',
val: 'light-',
},
{
@@ -0,0 +1,148 @@
import { memo } from 'react'
import { useTranslation } from 'react-i18next'
import { useProjectListContext } from '../context/project-list-context'
import { Project } from '../../../../../types/project/dashboard/api'
import { getOwnerName } from '../util/project'
import { fromNowDate } from '../../../utils/dates'
import { ProjectCompiler } from '../../../../../types/project-settings'
import getMeta from '@/utils/meta'
import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import SidebarDsNav from '@/features/project-list/components/sidebar/sidebar-ds-nav'
import SystemMessages from '@/shared/components/system-messages'
import CookieBanner from '@/shared/components/cookie-banner'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import NewProjectButton from './new-project-button'
import ProjectListTitle from './title/project-list-title'
import LoadMore from './load-more'
import DashApiError from './dash-api-error'
type FormatVariant = 'latex' | 'typst' | 'quarto' | 'quarto-slides'
function getFormatVariant(
compiler: ProjectCompiler | undefined,
quartoFlavor: 'revealjs' | 'pdf' | undefined
): FormatVariant {
if (compiler === 'quarto') {
return quartoFlavor === 'revealjs' ? 'quarto-slides' : 'quarto'
}
if (compiler === 'typst') return 'typst'
return 'latex'
}
function getFormatLabel(variant: FormatVariant): string {
switch (variant) {
case 'typst':
return 'Typst'
case 'quarto':
return 'Quarto'
case 'quarto-slides':
return 'Quarto Slides'
default:
return 'LaTeX'
}
}
const ProjectCard = memo(function ProjectCard({
project,
}: {
project: Project
}) {
const variant = getFormatVariant(project.compiler, project.quartoFlavor)
const ownerName = getOwnerName(project)
const date = fromNowDate(project.lastUpdated)
const initial = project.name.charAt(0).toUpperCase() || '?'
return (
<a
href={`/project/${project.id}`}
className={`lumiere-card lumiere-card--${variant}`}
translate="no"
>
<div className="lumiere-card-thumb">
<span className="lumiere-card-initial">{initial}</span>
</div>
<div className="lumiere-card-body">
<span className="lumiere-card-name">{project.name}</span>
<div className="lumiere-card-meta">
<span className={`lumiere-format-badge lumiere-format-badge--${variant}`}>
{getFormatLabel(variant)}
</span>
{ownerName && (
<span className="lumiere-card-owner" translate="yes">
{ownerName}
</span>
)}
</div>
<span className="lumiere-card-date">{date}</span>
</div>
</a>
)
})
export function ProjectListLumiere() {
const navbarProps = getMeta('ol-navbar')
const footerProps = getMeta('ol-footer')
const { t } = useTranslation()
const {
error,
visibleProjects,
searchText,
setSearchText,
filter,
tags,
selectedTagId,
} = useProjectListContext()
const selectedTag = tags.find(tag => tag._id === selectedTagId)
return (
<div className="project-list-lumiere">
<SystemMessages />
<DefaultNavbar {...navbarProps} showCloseIcon />
<div className="lumiere-layout">
<SidebarDsNav />
<div className="lumiere-main-wrapper">
<main className="lumiere-main" aria-labelledby="lumiere-title">
<UserNotifications />
{error && <DashApiError />}
<div className="lumiere-header">
<ProjectListTitle
filter={filter}
selectedTag={selectedTag}
selectedTagId={selectedTagId}
className="lumiere-title"
id="lumiere-title"
/>
<div className="lumiere-header-actions">
<SearchForm
inputValue={searchText}
setInputValue={setSearchText}
filter={filter}
selectedTag={selectedTag}
/>
<NewProjectButton
id="lumiere-new-project-button"
showAddAffiliationWidget
/>
</div>
</div>
{visibleProjects.length === 0 ? (
<p className="lumiere-empty">{t('no_projects')}</p>
) : (
<div className="lumiere-card-grid">
{visibleProjects.map(project => (
<ProjectCard key={project.id} project={project} />
))}
</div>
)}
<LoadMore />
</main>
<Footer {...footerProps} />
</div>
</div>
<CookieBanner />
</div>
)
}
@@ -17,10 +17,12 @@ import DefaultNavbar from '@/shared/components/navbar/default-navbar'
import Footer from '@/shared/components/footer/footer'
import WelcomePageContent from '@/features/project-list/components/welcome-page-content'
import { ProjectListDsNav } from '@/features/project-list/components/project-list-ds-nav'
import { ProjectListLumiere } from '@/features/project-list/components/project-list-lumiere'
import { DsNavStyleProvider } from '@/features/project-list/components/use-is-ds-nav'
import CookieBanner from '@/shared/components/cookie-banner'
import useThemedPage from '@/shared/hooks/use-themed-page'
import { UserSettingsProvider } from '@/shared/context/user-settings-context'
import { useUserSettingsContext } from '@/shared/context/user-settings-context'
import { TutorialProvider } from '@/shared/context/tutorial-context'
function ProjectListRoot() {
@@ -80,6 +82,9 @@ function ProjectListPageContent() {
useThemedPage()
const { totalProjectsCount, isLoading, loadProgress } =
useProjectListContext()
const {
userSettings: { overallTheme },
} = useUserSettingsContext()
useEffect(() => {
eventTracking.sendMB('loads_v2_dash', { page: 'projects' })
@@ -105,6 +110,15 @@ function ProjectListPageContent() {
</>
)
}
if (overallTheme === 'lumiere-') {
return (
<DsNavStyleProvider>
<ProjectListLumiere />
</DsNavStyleProvider>
)
}
return (
<DsNavStyleProvider>
<ProjectListDsNav />
@@ -8,6 +8,8 @@ import { useTranslation } from 'react-i18next'
const getIcon = (theme: OverallThemeMeta) => {
switch (theme.val) {
case 'lumiere-':
return 'auto_awesome'
case 'light-':
return 'light_mode'
case 'system':
@@ -13,7 +13,7 @@ function getTheme(
if (isIEEEBranded()) {
return 'dark'
}
if (overallTheme === 'light-') {
if (overallTheme === 'light-' || overallTheme === 'lumiere-') {
return 'light'
}
if (overallTheme === 'system') {
@@ -1,4 +1,4 @@
export type OverallTheme = '' | 'light-' | 'system'
export type OverallTheme = '' | 'light-' | 'system' | 'lumiere-'
export const fontFamilies = {
monaco: ['Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'monospace'],
@@ -4,6 +4,7 @@
@import 'project-list';
@import 'project-list-default';
@import 'project-list-ds-nav';
@import 'project-list-lumiere';
@import 'sidebar-v2-dash-pane';
@import 'editor/ide';
@import 'editor/ide-redesign';
@@ -0,0 +1,232 @@
// Verso Lumière — card-based project dashboard theme
.project-list-lumiere {
display: flex;
flex-direction: column;
min-height: 100vh;
background: #f0f4f8;
.lumiere-layout {
display: flex;
flex: 1;
min-height: 0;
// Reuse the existing sidebar but give it a white background in Lumière
.project-list-sidebar-wrapper-react {
background: #ffffff;
border-right: 1px solid #e2e8f0;
}
}
.lumiere-main-wrapper {
display: flex;
flex-direction: column;
flex: 1;
min-width: 0;
overflow-y: auto;
}
.lumiere-main {
flex: 1;
padding: 2rem 2.5rem;
max-width: 1400px;
width: 100%;
@media (max-width: 768px) {
padding: 1.25rem 1rem;
}
}
// ── Header ──────────────────────────────────────────────────────────────
.lumiere-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.lumiere-header-actions {
display: flex;
align-items: center;
gap: 0.75rem;
flex-shrink: 0;
flex-wrap: wrap;
}
// Override ProjectListTitle so it renders with Lumière typography
.lumiere-title {
font-family: Georgia, 'Times New Roman', 'DejaVu Serif', serif;
font-size: 2.25rem;
font-weight: 700;
color: #1a2e3b;
line-height: 1.15;
margin: 0;
}
// ── Empty state ─────────────────────────────────────────────────────────
.lumiere-empty {
color: #64748b;
font-size: 0.95rem;
margin-top: 2rem;
}
// ── Card grid ───────────────────────────────────────────────────────────
.lumiere-card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 1.5rem;
margin-top: 1.5rem;
}
// ── Individual card ──────────────────────────────────────────────────────
.lumiere-card {
display: flex;
flex-direction: column;
text-decoration: none;
border-radius: 10px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.07);
transition:
transform 0.18s ease,
box-shadow 0.18s ease;
overflow: hidden;
color: inherit;
&:hover,
&:focus-visible {
transform: translateY(-3px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.13);
text-decoration: none;
color: inherit;
}
}
// ── Card thumbnail ───────────────────────────────────────────────────────
.lumiere-card-thumb {
position: relative;
height: 130px;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
// Folded corner effect
&::after {
content: '';
position: absolute;
top: 0;
right: 0;
width: 0;
height: 0;
border-style: solid;
border-width: 0 28px 28px 0;
border-color: transparent rgba(255, 255, 255, 0.3) transparent transparent;
z-index: 1;
}
}
.lumiere-card-initial {
font-family: Georgia, 'Times New Roman', 'DejaVu Serif', serif;
font-size: 3rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.75);
line-height: 1;
user-select: none;
}
// Format-specific gradients
.lumiere-card--latex .lumiere-card-thumb {
background: linear-gradient(135deg, #4caf7d 0%, #2a9d8f 100%);
}
.lumiere-card--typst .lumiere-card-thumb {
background: linear-gradient(135deg, #2a9d8f 0%, #3d7ebf 100%);
}
.lumiere-card--quarto .lumiere-card-thumb {
background: linear-gradient(135deg, #7c4dff 0%, #3d7ebf 100%);
}
.lumiere-card--quarto-slides .lumiere-card-thumb {
background: linear-gradient(135deg, #e67e22 0%, #e74c3c 100%);
}
// ── Card body ────────────────────────────────────────────────────────────
.lumiere-card-body {
display: flex;
flex-direction: column;
gap: 0.35rem;
padding: 0.85rem 0.9rem 0.9rem;
flex: 1;
}
.lumiere-card-name {
font-size: 0.875rem;
font-weight: 600;
color: #1a2e3b;
line-height: 1.3;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.lumiere-card-meta {
display: flex;
align-items: center;
gap: 0.4rem;
flex-wrap: wrap;
}
.lumiere-card-owner {
font-size: 0.72rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100px;
}
.lumiere-card-date {
font-size: 0.72rem;
color: #94a3b8;
margin-top: auto;
}
// ── Format badges ────────────────────────────────────────────────────────
.lumiere-format-badge {
display: inline-block;
font-size: 0.65rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
padding: 0.15em 0.5em;
border-radius: 4px;
line-height: 1.5;
}
.lumiere-format-badge--latex {
background: #e8f5ee;
color: #2a9d8f;
}
.lumiere-format-badge--typst {
background: #e0f2fe;
color: #3d7ebf;
}
.lumiere-format-badge--quarto,
.lumiere-format-badge--quarto-slides {
background: #ede9fe;
color: #7c4dff;
}
}