diff --git a/services/web/app/src/Features/Project/ProjectController.mjs b/services/web/app/src/Features/Project/ProjectController.mjs index 24b79fb117..3934a98326 100644 --- a/services/web/app/src/Features/Project/ProjectController.mjs +++ b/services/web/app/src/Features/Project/ProjectController.mjs @@ -1364,6 +1364,7 @@ const _ProjectController = { function getInitialLoadingScreenTheme(overallThemeSetting) { switch (overallThemeSetting) { case 'light-': + case 'lumiere-': return 'light' case '': return 'dark' diff --git a/services/web/app/src/Features/Project/UserSettingsHelper.mjs b/services/web/app/src/Features/Project/UserSettingsHelper.mjs index e99c74731a..d23ec46d8c 100644 --- a/services/web/app/src/Features/Project/UserSettingsHelper.mjs +++ b/services/web/app/src/Features/Project/UserSettingsHelper.mjs @@ -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) { diff --git a/services/web/app/src/infrastructure/ExpressLocals.mjs b/services/web/app/src/infrastructure/ExpressLocals.mjs index 297880288e..5da9f4b583 100644 --- a/services/web/app/src/infrastructure/ExpressLocals.mjs +++ b/services/web/app/src/infrastructure/ExpressLocals.mjs @@ -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-', }, { diff --git a/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx new file mode 100644 index 0000000000..950394aedf --- /dev/null +++ b/services/web/frontend/js/features/project-list/components/project-list-lumiere.tsx @@ -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 ( + +
+ {initial} +
+
+ {project.name} +
+ + {getFormatLabel(variant)} + + {ownerName && ( + + {ownerName} + + )} +
+ {date} +
+
+ ) +}) + +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 ( +
+ + +
+ +
+
+ + {error && } +
+ +
+ + +
+
+ {visibleProjects.length === 0 ? ( +

{t('no_projects')}

+ ) : ( +
+ {visibleProjects.map(project => ( + + ))} +
+ )} + +
+
+
+
+ +
+ ) +} diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx index 352c6fd37a..12bdd1d8a4 100644 --- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx +++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx @@ -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 ( + + + + ) + } + return ( diff --git a/services/web/frontend/js/features/project-list/components/sidebar/theme-toggle.tsx b/services/web/frontend/js/features/project-list/components/sidebar/theme-toggle.tsx index 708aed59d7..619d000402 100644 --- a/services/web/frontend/js/features/project-list/components/sidebar/theme-toggle.tsx +++ b/services/web/frontend/js/features/project-list/components/sidebar/theme-toggle.tsx @@ -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': diff --git a/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx index bb95ecfe57..d10dd0bad6 100644 --- a/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx +++ b/services/web/frontend/js/shared/hooks/use-active-overall-theme.tsx @@ -13,7 +13,7 @@ function getTheme( if (isIEEEBranded()) { return 'dark' } - if (overallTheme === 'light-') { + if (overallTheme === 'light-' || overallTheme === 'lumiere-') { return 'light' } if (overallTheme === 'system') { diff --git a/services/web/frontend/js/shared/utils/styles.ts b/services/web/frontend/js/shared/utils/styles.ts index c03f3052f7..a897c2877c 100644 --- a/services/web/frontend/js/shared/utils/styles.ts +++ b/services/web/frontend/js/shared/utils/styles.ts @@ -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'], diff --git a/services/web/frontend/stylesheets/pages/all.scss b/services/web/frontend/stylesheets/pages/all.scss index d7570c4ad2..dba082608d 100644 --- a/services/web/frontend/stylesheets/pages/all.scss +++ b/services/web/frontend/stylesheets/pages/all.scss @@ -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'; diff --git a/services/web/frontend/stylesheets/pages/project-list-lumiere.scss b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss new file mode 100644 index 0000000000..7a339134cd --- /dev/null +++ b/services/web/frontend/stylesheets/pages/project-list-lumiere.scss @@ -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; + } +}