feat: add Verso Lumière theme with card-based project dashboard
Build and Deploy Verso / deploy (push) Successful in 14m45s
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:
@@ -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 ''
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user