Просмотр исходного кода

Merge pull request #32968 from overleaf/worktree-labs-feature-preview

Add labs preview modal to editor

GitOrigin-RevId: 0df33135febc8e94129bcdfdfb5c4981326dfab0
Malik Glossop 4 месяцев назад
Родитель
Сommit
eb9d586bdb

+ 4 - 1
services/web/app/src/Features/Project/ProjectController.mjs

@@ -902,6 +902,10 @@ const _ProjectController = {
         userSettings?.overallTheme
         userSettings?.overallTheme
       )
       )
 
 
+      if (user.labsProgram) {
+        await Modules.promises.hooks.fire('assignLabsSplitTests', req, res)
+      }
+
       res.render(template, {
       res.render(template, {
         title: project.name,
         title: project.name,
         priority_title: true,
         priority_title: true,
@@ -943,7 +947,6 @@ const _ProjectController = {
         },
         },
         initialLoadingScreenTheme,
         initialLoadingScreenTheme,
         userSettings,
         userSettings,
-        labsExperiments: user.labsExperiments ?? [],
         privilegeLevel,
         privilegeLevel,
         anonymous,
         anonymous,
         isTokenMember,
         isTokenMember,

+ 16 - 0
services/web/app/src/Features/SplitTests/SplitTestHandler.mjs

@@ -828,6 +828,22 @@ async function _loadSplitTestInfoInLocals(locals, splitTestName, session) {
       phase,
       phase,
       badgeInfo: splitTest.badgeInfo?.[phase],
       badgeInfo: splitTest.badgeInfo?.[phase],
     }
     }
+
+    if (phase === 'labs') {
+      const variant = currentVersion.variants?.[0]
+      info.labsDetails = {
+        title: splitTest.labsTitle || '',
+        description: splitTest.labsDescription || '',
+        icon: splitTest.labsIcon || '',
+        surveyLink: splitTest.badgeInfo?.labs?.url || '',
+        isFull: SplitTestUtils.isExperimentFull(variant),
+        versionCreatedAt:
+          currentVersion.createdAt instanceof Date
+            ? currentVersion.createdAt.toISOString()
+            : currentVersion.createdAt,
+      }
+    }
+
     if (Settings.devToolbar.enabled) {
     if (Settings.devToolbar.enabled) {
       info.active = currentVersion.active
       info.active = currentVersion.active
       info.variants = currentVersion.variants.map(variant => ({
       info.variants = currentVersion.variants.map(variant => ({

+ 11 - 0
services/web/app/src/Features/SplitTests/SplitTestManager.mjs

@@ -329,6 +329,17 @@ async function switchToNextPhase(
     )
     )
   }
   }
 
 
+  // Labs phase requires labs config to be set
+  if (lastVersionCopy.phase === LABS_PHASE) {
+    if (!splitTest.labsTitle || !splitTest.labsDescription) {
+      // Cannot be a required fields as we do not always promote to labs
+      throw new Errors.InvalidError(
+        'Labs phase requires Labs Title and Labs Description to be set',
+        { name }
+      )
+    }
+  }
+
   // Labs phase requires exactly one variant
   // Labs phase requires exactly one variant
   if (lastVersionCopy.phase === LABS_PHASE) {
   if (lastVersionCopy.phase === LABS_PHASE) {
     if (lastVersionCopy.variants.length !== 1) {
     if (lastVersionCopy.variants.length !== 1) {

+ 10 - 0
services/web/app/src/Features/SplitTests/SplitTestUtils.mjs

@@ -14,7 +14,17 @@ function getVersion(splitTest, versionNumber) {
   })
   })
 }
 }
 
 
+function isExperimentFull(variant) {
+  const { userLimit, userCount } = variant
+  if (typeof userLimit === 'number') {
+    const currentCount = userCount ?? 0
+    return currentCount >= userLimit
+  }
+  return false
+}
+
 export default {
 export default {
   getCurrentVersion,
   getCurrentVersion,
   getVersion,
   getVersion,
+  isExperimentFull,
 }
 }

+ 0 - 1
services/web/app/views/project/editor/_meta.pug

@@ -3,7 +3,6 @@ meta(name="ol-projectName" content=projectName)
 meta(name="ol-canUseClsiCache" data-type="boolean" content=canUseClsiCache)
 meta(name="ol-canUseClsiCache" data-type="boolean" content=canUseClsiCache)
 meta(name="ol-userSettings" data-type="json" content=userSettings)
 meta(name="ol-userSettings" data-type="json" content=userSettings)
 meta(name="ol-user" data-type="json" content=user)
 meta(name="ol-user" data-type="json" content=user)
-meta(name="ol-labsExperiments" data-type="json" content=labsExperiments)
 meta(name="ol-learnedWords" data-type="json" content=learnedWords)
 meta(name="ol-learnedWords" data-type="json" content=learnedWords)
 meta(name="ol-anonymous" data-type="boolean" content=anonymous)
 meta(name="ol-anonymous" data-type="boolean" content=anonymous)
 meta(name="ol-brandVariation" data-type="json" content=brandVariation)
 meta(name="ol-brandVariation" data-type="json" content=brandVariation)

+ 2 - 0
services/web/config/settings.defaults.js

@@ -1083,6 +1083,8 @@ module.exports = {
     referenceIndices: [],
     referenceIndices: [],
     railEntries: [],
     railEntries: [],
     railPopovers: [],
     railPopovers: [],
+    railActions: [],
+    railModals: [],
   },
   },
 
 
   moduleImportSequence: [
   moduleImportSequence: [

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -641,6 +641,7 @@
   "example_project": "",
   "example_project": "",
   "existing_plan_active_until_term_end": "",
   "existing_plan_active_until_term_end": "",
   "expand": "",
   "expand": "",
+  "experiment_enabled_refresh_page": "",
   "experiment_full_check_back_soon": "",
   "experiment_full_check_back_soon": "",
   "expired": "",
   "expired": "",
   "expired_confirmation_code": "",
   "expired_confirmation_code": "",
@@ -1036,6 +1037,8 @@
   "knowledge_base": "",
   "knowledge_base": "",
   "labels_help_you_to_easily_reference_your_figures": "",
   "labels_help_you_to_easily_reference_your_figures": "",
   "labels_help_you_to_reference_your_tables": "",
   "labels_help_you_to_reference_your_tables": "",
+  "labs_ongoing_experiments": "",
+  "labs_settings": "",
   "language": "",
   "language": "",
   "last_active": "",
   "last_active": "",
   "last_active_description": "",
   "last_active_description": "",
@@ -1230,6 +1233,7 @@
   "need_to_add_new_primary_before_remove": "",
   "need_to_add_new_primary_before_remove": "",
   "need_to_leave": "",
   "need_to_leave": "",
   "neither_agree_nor_disagree": "",
   "neither_agree_nor_disagree": "",
+  "new": "",
   "new_compile_domain_notice": "",
   "new_compile_domain_notice": "",
   "new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
   "new_compiles_in_this_project_will_automatically_use_the_newest_version": "",
   "new_file": "",
   "new_file": "",
@@ -1988,6 +1992,7 @@
   "tex_live_version": "",
   "tex_live_version": "",
   "thank_you": "",
   "thank_you": "",
   "thank_you_exclamation": "",
   "thank_you_exclamation": "",
+  "thank_you_for_participating_feedback": "",
   "thank_you_for_your_feedback": "",
   "thank_you_for_your_feedback": "",
   "thanks_for_confirming_your_email_address": "",
   "thanks_for_confirming_your_email_address": "",
   "thanks_for_getting_in_touch": "",
   "thanks_for_getting_in_touch": "",

+ 20 - 8
services/web/frontend/js/features/ide-react/components/rail/rail-action-element.tsx

@@ -14,6 +14,7 @@ type RailActionButton = {
   icon: AvailableUnfilledIcon
   icon: AvailableUnfilledIcon
   title: string
   title: string
   action: () => void
   action: () => void
+  indicator?: ReactElement
   hide?: boolean
   hide?: boolean
   ref?: React.Ref<HTMLButtonElement>
   ref?: React.Ref<HTMLButtonElement>
 }
 }
@@ -23,6 +24,7 @@ type RailDropdown = {
   icon: AvailableUnfilledIcon
   icon: AvailableUnfilledIcon
   title: string
   title: string
   dropdown: ReactElement
   dropdown: ReactElement
+  indicator?: ReactElement
   hide?: boolean
   hide?: boolean
   ref?: React.Ref<HTMLButtonElement>
   ref?: React.Ref<HTMLButtonElement>
 }
 }
@@ -57,10 +59,9 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
                 as="button"
                 as="button"
                 aria-label={action.title}
                 aria-label={action.title}
               >
               >
-                <MaterialIcon
-                  className="ide-rail-tab-link-icon"
+                <RailActionIcon
                   type={action.icon}
                   type={action.icon}
-                  unfilled
+                  indicator={action.indicator}
                 />
                 />
               </DropdownToggle>
               </DropdownToggle>
             </span>
             </span>
@@ -81,11 +82,7 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
             className="ide-rail-tab-link ide-rail-tab-button"
             className="ide-rail-tab-link ide-rail-tab-button"
             aria-label={action.title}
             aria-label={action.title}
           >
           >
-            <MaterialIcon
-              className="ide-rail-tab-link-icon"
-              type={action.icon}
-              unfilled
-            />
+            <RailActionIcon type={action.icon} indicator={action.indicator} />
           </button>
           </button>
         </OLTooltip>
         </OLTooltip>
       )
       )
@@ -93,6 +90,21 @@ const RailActionElement = forwardRef<HTMLButtonElement, { action: RailAction }>(
   }
   }
 )
 )
 
 
+function RailActionIcon({
+  type,
+  indicator,
+}: {
+  type: AvailableUnfilledIcon
+  indicator?: ReactElement
+}) {
+  return (
+    <>
+      <MaterialIcon className="ide-rail-tab-link-icon" type={type} unfilled />
+      {indicator}
+    </>
+  )
+}
+
 RailActionElement.displayName = 'RailActionElement'
 RailActionElement.displayName = 'RailActionElement'
 
 
 export default RailActionElement
 export default RailActionElement

+ 1 - 1
services/web/frontend/js/features/ide-react/components/rail/rail-indicator.tsx

@@ -1,7 +1,7 @@
 import OLBadge from '@/shared/components/ol/ol-badge'
 import OLBadge from '@/shared/components/ol/ol-badge'
 
 
 type RailIndicatorProps = {
 type RailIndicatorProps = {
-  type: 'danger' | 'warning' | 'info'
+  type: 'danger' | 'warning' | 'info' | 'success'
   count: number
   count: number
 }
 }
 
 

+ 13 - 2
services/web/frontend/js/features/ide-react/components/rail/rail-modals.tsx

@@ -6,11 +6,21 @@ import {
 import { RailHelpContactUsModal } from './contact-us'
 import { RailHelpContactUsModal } from './contact-us'
 import { RailHelpShowHotkeysModal } from './keyboard-shortcuts'
 import { RailHelpShowHotkeysModal } from './keyboard-shortcuts'
 import DictionarySettingsModal from '@/features/settings/components/editor-settings/dictionary-settings-modal'
 import DictionarySettingsModal from '@/features/settings/components/editor-settings/dictionary-settings-modal'
+import importOverleafModules from '../../../../../macros/import-overleaf-module.macro'
 
 
-const RAIL_MODALS: {
+type RailModalEntry = {
   key: RailModalKey
   key: RailModalKey
   modalComponentFunction: FC<{ show: boolean }>
   modalComponentFunction: FC<{ show: boolean }>
-}[] = [
+}
+
+const moduleRailModals = (
+  importOverleafModules('railModals') as {
+    import: { default: RailModalEntry }
+    path: string
+  }[]
+).map(({ import: { default: entry } }) => entry)
+
+const RAIL_MODALS: RailModalEntry[] = [
   {
   {
     key: 'keyboard-shortcuts',
     key: 'keyboard-shortcuts',
     modalComponentFunction: RailHelpShowHotkeysModal,
     modalComponentFunction: RailHelpShowHotkeysModal,
@@ -23,6 +33,7 @@ const RAIL_MODALS: {
     key: 'dictionary',
     key: 'dictionary',
     modalComponentFunction: DictionarySettingsModal,
     modalComponentFunction: DictionarySettingsModal,
   },
   },
+  ...moduleRailModals,
 ]
 ]
 
 
 export default function RailModals() {
 export default function RailModals() {

+ 8 - 0
services/web/frontend/js/features/ide-react/components/rail/rail.tsx

@@ -56,6 +56,13 @@ const moduleRailPopovers = (
   }[]
   }[]
 ).map(({ import: { default: element } }) => element)
 ).map(({ import: { default: element } }) => element)
 
 
+const moduleRailActions = (
+  importOverleafModules('railActions') as {
+    import: { default: RailAction }
+    path: string
+  }[]
+).map(({ import: { default: action } }) => action)
+
 export const RailLayout = () => {
 export const RailLayout = () => {
   const { sendEvent } = useEditorAnalytics()
   const { sendEvent } = useEditorAnalytics()
   const { t } = useTranslation()
   const { t } = useTranslation()
@@ -152,6 +159,7 @@ export const RailLayout = () => {
         title: t('help'),
         title: t('help'),
         dropdown: <RailHelpDropdown />,
         dropdown: <RailHelpDropdown />,
       },
       },
+      ...moduleRailActions,
       {
       {
         key: 'settings',
         key: 'settings',
         icon: 'settings',
         icon: 'settings',

+ 19 - 1
services/web/frontend/js/features/ide-react/context/rail-context.tsx

@@ -25,7 +25,15 @@ export type RailTabKey =
   | 'full-project-search'
   | 'full-project-search'
   | 'workbench'
   | 'workbench'
 
 
-export type RailModalKey = 'keyboard-shortcuts' | 'contact-us' | 'dictionary'
+export type RailModalKey =
+  | 'keyboard-shortcuts'
+  | 'contact-us'
+  | 'dictionary'
+  | 'labs'
+
+export function dispatchOpenRailModal(key: RailModalKey) {
+  window.dispatchEvent(new CustomEvent('ui:open-rail-modal', { detail: key }))
+}
 
 
 const RailContext = createContext<
 const RailContext = createContext<
   | {
   | {
@@ -119,6 +127,16 @@ export const RailProvider: FC<React.PropsWithChildren> = ({ children }) => {
     }, [handlePaneCollapse, selectedTab, isOpen, openTab])
     }, [handlePaneCollapse, selectedTab, isOpen, openTab])
   )
   )
 
 
+  useEventListener(
+    'ui:open-rail-modal',
+    useCallback(
+      (event: Event) => {
+        setActiveModal((event as CustomEvent<RailModalKey>).detail)
+      },
+      [setActiveModal]
+    )
+  )
+
   useEventListener(
   useEventListener(
     'keydown',
     'keydown',
     useCallback(
     useCallback(

+ 45 - 0
services/web/frontend/js/shared/components/labs/labs-enable-button.tsx

@@ -0,0 +1,45 @@
+import { useTranslation } from 'react-i18next'
+import OLButton from '@/shared/components/ol/ol-button'
+
+type LabsEnableButtonProps = {
+  optedIn: boolean
+  disabled: boolean
+  loading?: boolean
+  ghost?: boolean
+  handleEnable: () => void
+  handleDisable: () => void
+}
+
+export function LabsEnableButton({
+  optedIn,
+  disabled,
+  loading,
+  ghost,
+  handleEnable,
+  handleDisable,
+}: LabsEnableButtonProps) {
+  const { t } = useTranslation()
+  const isDisabled = loading || disabled
+
+  if (optedIn) {
+    return (
+      <OLButton
+        variant={ghost ? 'danger-ghost' : 'secondary'}
+        onClick={handleDisable}
+        disabled={isDisabled}
+      >
+        {t('disable')}
+      </OLButton>
+    )
+  }
+
+  return (
+    <OLButton
+      variant={ghost ? 'secondary' : 'primary'}
+      onClick={handleEnable}
+      disabled={isDisabled}
+    >
+      {t('enable')}
+    </OLButton>
+  )
+}

+ 4 - 39
services/web/frontend/js/shared/components/labs/labs-experiments-widget.tsx

@@ -5,6 +5,7 @@ import { postJSON } from '@/infrastructure/fetch-json'
 import OLButton from '@/shared/components/ol/ol-button'
 import OLButton from '@/shared/components/ol/ol-button'
 import getMeta from '@/utils/meta'
 import getMeta from '@/utils/meta'
 import Notification from '../notification'
 import Notification from '../notification'
+import { LabsEnableButton } from '@/shared/components/labs/labs-enable-button'
 
 
 export type LabsExperimentWidgetProps = {
 export type LabsExperimentWidgetProps = {
   logo: ReactNode
   logo: ReactNode
@@ -96,11 +97,11 @@ export function LabsExperimentWidget({
       </div>
       </div>
       <div>
       <div>
         {labsEnabled && (
         {labsEnabled && (
-          <ActionButton
+          <LabsEnableButton
             optedIn={optedIn}
             optedIn={optedIn}
-            handleDisable={handleDisable}
-            handleEnable={handleEnable}
             disabled={disabled}
             disabled={disabled}
+            handleEnable={handleEnable}
+            handleDisable={handleDisable}
           />
           />
         )}
         )}
       </div>
       </div>
@@ -119,40 +120,4 @@ export function LabsExperimentWidget({
   )
   )
 }
 }
 
 
-type ActionButtonProps = {
-  optedIn?: boolean
-  disabled?: boolean
-  handleEnable: () => void
-  handleDisable: () => void
-}
-
-function ActionButton({
-  optedIn,
-  disabled,
-  handleEnable,
-  handleDisable,
-}: ActionButtonProps) {
-  const { t } = useTranslation()
-
-  if (optedIn) {
-    return (
-      <OLButton variant="secondary" onClick={handleDisable}>
-        {t('disable')}
-      </OLButton>
-    )
-  } else if (disabled) {
-    return (
-      <OLButton variant="primary" disabled>
-        {t('enable')}
-      </OLButton>
-    )
-  } else {
-    return (
-      <OLButton variant="primary" onClick={handleEnable}>
-        {t('enable')}
-      </OLButton>
-    )
-  }
-}
-
 export default LabsExperimentWidget
 export default LabsExperimentWidget

+ 1 - 0
services/web/frontend/js/utils/meta.ts

@@ -206,6 +206,7 @@ export interface Meta {
     surveyLink: string
     surveyLink: string
     isFull: boolean
     isFull: boolean
     optedIn: boolean
     optedIn: boolean
+    versionCreatedAt: string | null
   }>
   }>
   'ol-languages': SpellCheckLanguage[]
   'ol-languages': SpellCheckLanguage[]
   'ol-learnedWords': string[]
   'ol-learnedWords': string[]

+ 226 - 16
services/web/frontend/stylesheets/modules/labs.scss

@@ -9,10 +9,6 @@
   }
   }
 
 
   #experiments-container {
   #experiments-container {
-    .title-row {
-      display: flex;
-    }
-
     .labs-experiment-widget-container {
     .labs-experiment-widget-container {
       border: 1px solid var(--border-divider);
       border: 1px solid var(--border-divider);
     }
     }
@@ -60,13 +56,12 @@
     }
     }
 
 
     .title-row {
     .title-row {
-      margin: 0;
-      margin-bottom: var(--spacing-04);
+      display: flex;
       align-items: center;
       align-items: center;
+      margin: 0 0 var(--spacing-04) 0;
 
 
       > h3 {
       > h3 {
-        margin: 0;
-        margin-right: var(--spacing-04);
+        margin: 0 var(--spacing-04) 0 0;
       }
       }
     }
     }
 
 
@@ -96,14 +91,229 @@
   }
   }
 }
 }
 
 
-.labs-workbench-logo {
-  width: 32px;
-  height: 32px;
-  padding: var(--spacing-01);
+// Feature Preview Modal
+.labs-feature-preview-modal {
+  .modal-header {
+    padding-bottom: var(--spacing-04);
+    border-bottom: 1px solid var(--border-divider);
+  }
+
+  .modal-body {
+    padding: 0;
+  }
+}
+
+.labs-feature-preview-empty {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: var(--spacing-09);
+  color: var(--content-secondary);
+}
+
+// Experiment Browser (two-panel layout)
+.lab-experiment-modal-content {
+  container-type: inline-size;
+  container-name: lab-experiment-modal-content;
+}
+
+.lab-experiment-modal-content-inner {
+  display: flex;
+  height: 100%;
+}
+
+.labs-experiment-tab-content {
+  flex: 1;
+  min-width: 0;
+}
+
+// Fixed height in the feature preview modal, where the modal body constrains the layout
+.labs-feature-preview-modal .lab-experiment-modal-content-inner {
+  height: 500px;
+}
+
+.labs-experiment-sidebar {
+  width: 320px;
+  border-right: 1px solid var(--border-divider);
+  display: flex;
+  flex-direction: column;
+  overflow-y: auto;
+}
+
+.labs-experiment-sidebar-item {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-04);
+  padding: var(--spacing-04) var(--spacing-05);
+  text-align: left;
+  color: var(--content-primary);
+  font-size: var(--font-size-02);
+  min-height: 44px;
+  text-decoration: none;
+
+  &:hover {
+    background: var(--bg-light-secondary);
+    color: var(--content-primary);
+  }
+
+  &.active {
+    background: var(--bg-accent-03);
+    color: var(--content-primary);
+
+    .labs-experiment-sidebar-title {
+      color: var(--green-70);
+      font-weight: 600;
+    }
+  }
+}
+
+.labs-experiment-sidebar-icon {
+  font-size: 20px;
+  color: var(--content-primary);
+}
+
+.labs-experiment-sidebar-title {
+  flex: 1;
+  min-width: 0;
+  white-space: normal;
+  overflow-wrap: anywhere;
+}
+
+.labs-experiment-sidebar-badge {
+  // Reserve a fixed width so layout is stable whether badge is shown or not
+  min-width: 60px;
+  text-align: right;
+}
+
+.labs-settings-link {
+  display: grid;
+  grid-template-columns: auto 1fr auto;
+  align-items: center;
+  gap: var(--spacing-04);
+  padding: var(--spacing-04) var(--spacing-05);
+  text-align: left;
+  color: var(--content-primary);
+  font-size: var(--font-size-02);
+  min-height: 44px;
+  width: 100%;
+  text-decoration: none;
+
+  &:visited {
+    color: var(--content-primary);
+  }
+
+  &:hover {
+    background: var(--bg-light-secondary);
+    color: var(--content-primary);
+  }
+}
+
+.labs-settings-link-external-icon {
+  font-size: 20px;
+  color: var(--content-secondary);
+}
+
+.labs-badge {
+  font-size: 14px;
+  font-weight: 700;
+  line-height: 1;
+  padding: 3px 8px;
+}
+
+.labs-experiment-sidebar-arrow {
+  display: none;
+  font-size: 18px;
+  color: var(--content-secondary);
+}
+
+// !important needed to override Bootstrap's d-inline-grid utility on OLIconButton
+.labs-experiment-detail-back {
+  display: none !important;
+}
+
+.labs-experiment-detail {
+  padding: var(--spacing-06);
+  overflow-y: auto;
+
+  > .notification {
+    margin-bottom: var(--spacing-06);
+
+    .notification-cta {
+      align-self: center;
+      height: auto;
+    }
+  }
+}
+
+.labs-experiment-detail-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: var(--spacing-06);
+
+  h3 {
+    margin: 0;
+    font-size: var(--font-size-05);
+  }
+}
 
 
-  img {
-    max-width: 100%;
-    max-height: 100%;
-    object-fit: contain;
+.labs-experiment-detail-actions {
+  display: flex;
+  align-items: center;
+  gap: var(--spacing-04);
+  margin-bottom: var(--spacing-06);
+}
+
+.labs-experiment-detail-description {
+  margin-bottom: var(--spacing-06);
+  color: var(--content-secondary);
+  font-size: var(--font-size-02);
+  line-height: var(--line-height-03);
+}
+
+// Narrow layout: drill-down navigation instead of side-by-side panels
+// Uses container queries so this triggers based on the browser component's own width,
+// not the viewport — works correctly whether embedded in the labs modal or the settings modal.
+@container lab-experiment-modal-content (width < 600px) {
+  .lab-experiment-modal-content-inner {
+    flex-direction: column;
+    min-height: auto;
+  }
+
+  .labs-experiment-sidebar {
+    width: 100%;
+    border-right: none;
+  }
+
+  .labs-experiment-sidebar-item {
+    padding: var(--spacing-05) var(--spacing-06);
+  }
+
+  .labs-experiment-sidebar-arrow {
+    display: block;
+  }
+
+  .labs-experiment-sidebar-badge {
+    min-width: auto;
+  }
+
+  .labs-experiment-tab-content {
+    display: none;
+  }
+
+  .labs-experiment-detail-back {
+    display: inline-flex !important;
+    margin-bottom: var(--spacing-05);
+  }
+
+  // When an experiment is selected, show detail and hide sidebar
+  .lab-experiment-modal-content.mobile-detail-active {
+    .labs-experiment-sidebar {
+      display: none;
+    }
+
+    .labs-experiment-tab-content {
+      display: block;
+    }
   }
   }
 }
 }

+ 5 - 0
services/web/locales/en.json

@@ -844,6 +844,7 @@
   "exclusive_access_with_labs": "Exclusive access to early-stage experiments",
   "exclusive_access_with_labs": "Exclusive access to early-stage experiments",
   "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.",
   "existing_plan_active_until_term_end": "Your existing plan and its features will remain active until the end of the current billing period.",
   "expand": "Expand",
   "expand": "Expand",
+  "experiment_enabled_refresh_page": "Experiment is enabled! You need to refresh the page to see the changes.",
   "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
   "experiment_full_check_back_soon": "Sorry, this experiment is full. Spaces may become available, so check back soon.",
   "expired": "Expired",
   "expired": "Expired",
   "expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.",
   "expired_confirmation_code": "Your confirmation code has expired. Click <0>Resend confirmation code</0> to get a new one.",
@@ -1351,7 +1352,9 @@
   "labels_help_you_to_easily_reference_your_figures": "Labels help you to easily reference your figures throughout your document. To reference a figure within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference figures without needing to manually remember the figure numbering. <1>Learn more</1>",
   "labels_help_you_to_easily_reference_your_figures": "Labels help you to easily reference your figures throughout your document. To reference a figure within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference figures without needing to manually remember the figure numbering. <1>Learn more</1>",
   "labels_help_you_to_reference_your_tables": "Labels help you to reference your tables throughout your document easily. To reference a table within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference tables without manually remembering the table numbering. <1>Read about labels and cross-references</1>.",
   "labels_help_you_to_reference_your_tables": "Labels help you to reference your tables throughout your document easily. To reference a table within the text, reference the label using the <0>\\ref{...}</0> command. This makes it easy to reference tables without manually remembering the table numbering. <1>Read about labels and cross-references</1>.",
   "labs": "Labs",
   "labs": "Labs",
+  "labs_ongoing_experiments": "Overleaf Labs - ongoing experiments",
   "labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. It’s important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.",
   "labs_program_benefits": "By signing up for Overleaf Labs you can get your hands on in-development features and try them out as much as you like. All we ask in return is your honest feedback to help us develop and improve. It’s important to note that features available in this program are still being tested and actively developed. This means they could change, be removed, or become part of a premium plan.",
+  "labs_settings": "Labs settings",
   "language": "Language",
   "language": "Language",
   "language_suggestions": "Language suggestions",
   "language_suggestions": "Language suggestions",
   "larger_discounts_available": "Larger discounts available",
   "larger_discounts_available": "Larger discounts available",
@@ -1619,6 +1622,7 @@
   "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
   "need_to_add_new_primary_before_remove": "You’ll need to add a new primary email address before you can remove this one.",
   "need_to_leave": "Need to leave?",
   "need_to_leave": "Need to leave?",
   "neither_agree_nor_disagree": "Neither agree nor disagree",
   "neither_agree_nor_disagree": "Neither agree nor disagree",
+  "new": "New",
   "new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
   "new_compile_domain_notice": "Something might be blocking your browser from accessing Overleaf’s PDF download location, <0>__compilesUserContentDomain__</0>. This could be caused by network blocking or a strict browser plugin rule. Please follow our <1>troubleshooting guide</1>.",
   "new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings</0>",
   "new_compiles_in_this_project_will_automatically_use_the_newest_version": "New compiles in this project will automatically use the newest version. <0>Learn how to change compiler settings</0>",
   "new_file": "New file",
   "new_file": "New file",
@@ -2593,6 +2597,7 @@
   "thank_you_email_confirmed": "Thank you, your email is now confirmed",
   "thank_you_email_confirmed": "Thank you, your email is now confirmed",
   "thank_you_exclamation": "Thank you!",
   "thank_you_exclamation": "Thank you!",
   "thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our beta program, where you can have <0>early access to new features</0> and help us understand your needs better",
   "thank_you_for_being_part_of_our_beta_program": "Thank you for being part of our beta program, where you can have <0>early access to new features</0> and help us understand your needs better",
+  "thank_you_for_participating_feedback": "Thank you for participating in this experiment. We’d appreciate your feedback.",
   "thank_you_for_your_feedback": "Thank you for your feedback!",
   "thank_you_for_your_feedback": "Thank you for your feedback!",
   "thanks": "Thanks",
   "thanks": "Thanks",
   "thanks_for_confirming_your_email_address": "Thanks for confirming your email address",
   "thanks_for_confirming_your_email_address": "Thanks for confirming your email address",

+ 249 - 0
services/web/test/unit/src/SplitTests/SplitTestHandler.test.mjs

@@ -664,6 +664,255 @@ describe('SplitTestHandler', function () {
     })
     })
   })
   })
 
 
+  describe('_loadSplitTestInfoInLocals labsDetails population', function () {
+    beforeEach(function (ctx) {
+      ctx.AnalyticsManager.getIdsFromSession.returns({
+        userId: 'abc123abc123',
+      })
+    })
+
+    it('populates labsDetails for a labs-phase split test', async function (ctx) {
+      const createdAt = new Date('2024-06-15T12:00:00.000Z')
+      ctx.cachedSplitTests.set('labs-info-test', {
+        name: 'labs-info-test',
+        labsTitle: 'My Labs Feature',
+        labsDescription: 'A great feature',
+        labsIcon: 'star',
+        badgeInfo: { labs: { url: 'https://example.com/survey' } },
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt,
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'labs-info-test'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'labs-info-test',
+        sinon.match({
+          phase: 'labs',
+          labsDetails: sinon.match({
+            isFull: false,
+            versionCreatedAt: createdAt.toISOString(),
+            title: 'My Labs Feature',
+            description: 'A great feature',
+            icon: 'star',
+            surveyLink: 'https://example.com/survey',
+          }),
+        })
+      )
+    })
+
+    it('handles missing labsTitle defensively', async function (ctx) {
+      const createdAt = new Date('2024-01-01T00:00:00.000Z')
+      ctx.cachedSplitTests.set('my-labs-experiment', {
+        name: 'my-labs-experiment',
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt,
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'my-labs-experiment'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'my-labs-experiment',
+        sinon.match({
+          labsDetails: sinon.match({ title: '' }),
+        })
+      )
+    })
+
+    it('sets description to empty string when labsDescription is absent', async function (ctx) {
+      ctx.cachedSplitTests.set('labs-nodesc', {
+        name: 'labs-nodesc',
+        labsTitle: 'No Description',
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt: new Date(),
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'labs-nodesc'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'labs-nodesc',
+        sinon.match({ labsDetails: sinon.match({ description: '' }) })
+      )
+    })
+
+    it('marks isFull true when userCount >= userLimit', async function (ctx) {
+      ctx.cachedSplitTests.set('labs-full', {
+        name: 'labs-full',
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt: new Date(),
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+                userLimit: 10,
+                userCount: 10,
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'labs-full'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'labs-full',
+        sinon.match({ labsDetails: sinon.match({ isFull: true }) })
+      )
+    })
+
+    it('marks isFull false when userCount < userLimit', async function (ctx) {
+      ctx.cachedSplitTests.set('labs-not-full', {
+        name: 'labs-not-full',
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt: new Date(),
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+                userLimit: 10,
+                userCount: 5,
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'labs-not-full'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'labs-not-full',
+        sinon.match({ labsDetails: sinon.match({ isFull: false }) })
+      )
+    })
+
+    it('marks isFull false when no userLimit is set', async function (ctx) {
+      ctx.cachedSplitTests.set('labs-no-limit', {
+        name: 'labs-no-limit',
+        versions: [
+          {
+            active: true,
+            analyticsEnabled: true,
+            phase: 'labs',
+            versionNumber: 1,
+            createdAt: new Date(),
+            variants: [
+              {
+                name: 'variant-1',
+                rolloutPercent: 100,
+                rolloutStripes: [{ start: 0, end: 100 }],
+              },
+            ],
+          },
+        ],
+      })
+
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'labs-no-limit'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'labs-no-limit',
+        sinon.match({ labsDetails: sinon.match({ isFull: false }) })
+      )
+    })
+
+    it('does not set labsDetails for a release-phase split test', async function (ctx) {
+      await ctx.SplitTestHandler.promises.getAssignment(
+        ctx.req,
+        ctx.res,
+        'active-test'
+      )
+
+      expect(ctx.LocalsHelper.setSplitTestInfo).to.have.been.calledWith(
+        ctx.res.locals,
+        'active-test',
+        sinon.match(info => info.labsDetails === undefined)
+      )
+    })
+  })
+
   describe('labs phase assignment (gradual rollout)', function () {
   describe('labs phase assignment (gradual rollout)', function () {
     beforeEach(function (ctx) {
     beforeEach(function (ctx) {
       ctx.AnalyticsManager.getIdsFromSession.returns({
       ctx.AnalyticsManager.getIdsFromSession.returns({

+ 112 - 0
services/web/test/unit/src/SplitTests/SplitTestUtils.test.mjs

@@ -0,0 +1,112 @@
+import { expect } from 'vitest'
+import SplitTestUtils from '../../../../app/src/Features/SplitTests/SplitTestUtils.mjs'
+
+describe('SplitTestUtils', function () {
+  describe('isExperimentFull', function () {
+    describe('when userLimit is null or undefined', function () {
+      it('should return false when userLimit is null', function () {
+        const variant = {
+          userLimit: null,
+          userCount: 5,
+        }
+        expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+      })
+
+      it('should return false when userLimit is undefined', function () {
+        const variant = {
+          userCount: 5,
+        }
+        expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+      })
+    })
+
+    describe('when userLimit is not a number', function () {
+      it.each([
+        { label: 'NaN', userLimit: NaN },
+        { label: 'an object', userLimit: {} },
+        { label: 'a string', userLimit: 'string' },
+      ])(
+        'should return false when userLimit is $label',
+        function ({ userLimit }) {
+          const variant = {
+            userLimit,
+            userCount: 50,
+          }
+          expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+        }
+      )
+    })
+
+    it('should return true when userLimit is 0', function () {
+      const variant = {
+        userLimit: 0,
+        userCount: 5,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
+    })
+
+    it('should treat undefined userCount as 0 and return false when under limit', function () {
+      const variant = {
+        userLimit: 100,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+    })
+
+    it('should treat null userCount as 0 and return false when under limit', function () {
+      const variant = {
+        userLimit: 100,
+        userCount: null,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+    })
+
+    it('should treat undefined userCount as 0 and return true when limit is 0', function () {
+      const variant = {
+        userLimit: 0,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
+    })
+
+    describe('when userCount is below the limit', function () {
+      it('should return false when userCount is less than userLimit', function () {
+        const variant = {
+          userLimit: 100,
+          userCount: 50,
+        }
+        expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+      })
+
+      it('should return false when userCount is 1 and userLimit is 100', function () {
+        const variant = {
+          userLimit: 100,
+          userCount: 1,
+        }
+        expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+      })
+
+      it('should return false when userCount is 0 and userLimit is 100', function () {
+        const variant = {
+          userLimit: 100,
+          userCount: 0,
+        }
+        expect(SplitTestUtils.isExperimentFull(variant)).toBe(false)
+      })
+    })
+
+    it('should return true when userCount equals userLimit', function () {
+      const variant = {
+        userLimit: 100,
+        userCount: 100,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
+    })
+
+    it('should return true when userCount is greater than userLimit', function () {
+      const variant = {
+        userLimit: 100,
+        userCount: 150,
+      }
+      expect(SplitTestUtils.isExperimentFull(variant)).toBe(true)
+    })
+  })
+})

+ 10 - 0
services/web/types/split-test.ts

@@ -12,4 +12,14 @@ export type SplitTestInfo = {
     url?: string
     url?: string
     tooltipText?: string
     tooltipText?: string
   }
   }
+  labsDetails?: LabsDetails
+}
+
+export type LabsDetails = {
+  isFull: boolean
+  versionCreatedAt: string
+  title: string
+  description: string
+  icon: string
+  surveyLink: string
 }
 }