Repository: code-dot-org/code-dot-org
Pull request #65384: Lab2: Bubble choice descriptions
Author: login: breville
Created: 2025-04-21T04:57:34Z | Merged: 2025-04-28T18:54:48Z

This continues the work begun in https://github.com/code-dot-org/code-dot-org/pull/64344 to implement the `BubbleChoice` level type using Lab2.

This update uses a new layout to facilitate the inclusion of an optional Markdown description for each sublevel.

To ensure that each sublevel button has predictable content layout regardless of the current window dimensions, the text component uses container query length units to scale the content to the button size, similar to the text in `Panels` levels.  Also, the sublevel buttons are now grouped in a cluster in the middle, rather than spread apart.

Following https://github.com/code-dot-org/code-dot-org/pull/65437, this change makes use of design system semantic colors to render in light or dark mode.  

#### Light theme

<img width="1365" alt="Screenshot 2025-04-24 at 11 18 39 AM" src="https://github.com/user-attachments/assets/9f78ae88-f31e-4430-9f3b-5ed2d1f737ea" />

#### Dark theme

<img width="1365" alt="Screenshot 2025-04-24 at 11 19 52 AM" src="https://github.com/user-attachments/assets/d825f751-b284-4219-a9a8-91a20e5c440e" />

#### Dynamic layout

<img width="783" alt="Screenshot 2025-04-24 at 11 40 32 AM" src="https://github.com/user-attachments/assets/a9668769-d147-4cc1-9843-c83c8c7da536" />



Diff:
diff --git a/apps/src/bubbleChoice/BubbleChoice.module.scss b/apps/src/bubbleChoice/BubbleChoice.module.scss
index cbde08a0b98cd..fff57c51dc6b0 100644
--- a/apps/src/bubbleChoice/BubbleChoice.module.scss
+++ b/apps/src/bubbleChoice/BubbleChoice.module.scss
@@ -9,82 +9,112 @@
   margin: 0 auto;
   gap: 15px;
   position: relative;
+  color: var(--text-neutral-primary);
 
-  .headingLight, .textLight {
-    color: $neutral_dark;
-  }
-
-  .headingDark, .textDark {
-    color: $neutral_white;
-  }
-
-  .headingLight, .headingDark {
+  .heading {
     margin-top: 15px;
   }
 
-  .textLight, .textDark {
+  .text{
     font-size: 14px;
   }
 
-  .sublevelsContainer {
-    display: grid;
-    justify-content: space-around;
-    gap: 15px;
-    max-height: 100%;
-    overflow: hidden;
-    flex-grow: 1;
-    justify-items: center;
+  .subLevelsOuterContainer {
+    width: 100%;
+    height: 100%;
+    display: flex;
     align-items: center;
+    justify-content: center;
+    min-height: 0;
 
-    .sublevelButton {
-      display: flex;
-      flex-direction: column;
-      flex-basis: 0;
-      flex-grow: 1;
-      border-radius: 20px;
-      overflow: hidden;
-      background-color: inherit;
-      color: inherit;
-      font-size: 16px;
-      padding: inherit;
-      margin: 0;
-      aspect-ratio: 1;
-      max-width: 100%;
+    .sublevelsContainer {
+      display: grid;
+      justify-content: space-around;
+      gap: 15px;
       max-height: 100%;
+      overflow: hidden;
+      justify-items: center;
+      align-items: center;
+      width: fit-content;
+      height: fit-content;
 
-      &Light {
-        border: solid 1px $neutral_dark60;
-      }
+      .sublevelButton {
+        display: flex;
+        flex-direction: column;
+        flex-basis: 0;
+        flex-grow: 1;
+        border-radius: 20px;
+        overflow: hidden;
+        background-color: var(--background-neutral-secondary);
+        color: inherit;
+        font-size: 16px;
+        padding: inherit;
+        margin: 0;
+        max-width: 100%;
+        max-height: 100%;
+        container-type: size;
+        border: solid 1px var(--borders-neutral-primary);
 
-      &Dark {
-        border: solid 1px $neutral_dark80;
-      }
+        &:hover {
+          .sublevelImage {
+            transform: scale(1.02, 1.02);
+            transition: 0.1s ease-in-out;
+          }
+        }
+
+        .sublevelImageContainer {
+          overflow: hidden;
+          position: relative;
+
+          .sublevelImage {
+            width: 800px;
+            object-fit: contain;
+            opacity: 1;
+          }
 
-      &:hover {
-        .sublevelImage {
-          transform: scale(1.02, 1.02);
-          transition: 0.1s ease-in-out;
+          .sublevelProgressBubbleContainer {
+            position: absolute;
+            top: 4cqw;
+            right: 4cqw;
+          }
         }
-      }
 
-      .sublevelText {
-        width: 100%;
-        display: flex;
-        justify-content: center;
-        text-align: center;
-        margin: 15px 0;
-        align-items: center;
-        gap: 15px;
-      }
+        .sublevelTextContainer {
+          display: flex;
+          flex-direction: column;
+          flex: 1;
+          gap: 3cqw;
+          padding: 4cqw;
+          box-sizing: border-box;
+          container-type: size;
 
-      .sublevelImageContainer {
-        overflow: hidden;
+          .sublevelTextHeading {
+            font-size: 8cqw;
+            text-align: initial;
+            margin: 0;
+          }
+
+          .sublevelDescriptionMarkdown{
+            text-align: initial;
+            overflow-y: scroll;
+
+            p {
+              margin-bottom: 1cqw;
+              font-size: 6cqw;
+              line-height: 1.4em;
+            }
+
+            ol, ul {
+              font-size: 6cqw;
+              line-height: 0;
+              margin: 0 0 4cqw 8cqw;
+            }
 
-        .sublevelImage {
-          width: 800px;
-          object-fit: contain;
-          aspect-ratio: 1;
-          opacity: 1;
+            li {
+              margin-bottom: 1cqw;
+              line-height: 1.4em;
+            }
+          }
         }
       }
     }
diff --git a/apps/src/bubbleChoice/BubbleChoice.tsx b/apps/src/bubbleChoice/BubbleChoice.tsx
index 012c93fbded39..8db97bdf7a1dd 100644
--- a/apps/src/bubbleChoice/BubbleChoice.tsx
+++ b/apps/src/bubbleChoice/BubbleChoice.tsx
@@ -17,22 +17,27 @@ import {
   BubbleChoiceLevelData,
   BubbleChoiceSublevel,
 } from '@cdo/apps/lab2/types';
+import EnhancedSafeMarkdown from '@cdo/apps/templates/EnhancedSafeMarkdown';
 import ProgressBubble from '@cdo/apps/templates/progress/ProgressBubble';
-import {capitalizeFirstLetter} from '@cdo/apps/util/capitalizeFirstLetter';
 import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
 import {LevelStatus} from '@cdo/generated-scripts/sharedConstants';
 
-import {getCurrentLesson} from '../code-studio/progressReduxSelectors';
 import {commonI18n} from '../types/locale';
 
 import styles from './BubbleChoice.module.scss';
 
 const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
+  // The image has a 4:3 aspect ratio.
+  const imageAspectRatio = 4 / 3;
+
+  // The aspect ratio of each sublevel button.  It has an image above a text area,
+  // each with the same aspect ratio.
+  const aspectRatio = imageAspectRatio / 2;
+
+  // The gap (in pixels) between each sublevel button.
+  const gap = 15;
+
   const dispatch = useAppDispatch();
-  const background = useAppSelector(
-    state => getCurrentLesson(state)?.background || null
-  );
-  const backgroundSuffix = capitalizeFirstLetter(background || 'light');
   const levelBubbleChoice = levelProperties.levelData as BubbleChoiceLevelData;
   const sublevelsStatus = useAppSelector(state =>
     levelBubbleChoice.sublevels.map(
@@ -83,7 +88,7 @@ const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
 
   // Go through the candidates and find which will deliver the largest sublevel buttons, given
   // the current size of the container.
-  const [numRows, numColumns] = useMemo(() => {
+  const [numRows, numColumns, imageWidth] = useMemo(() => {
     let bestSize = -1;
     let bestNumRows = -1;
     for (const [
@@ -91,8 +96,10 @@ const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
       candidateLayoutColumns,
     ] of candidateLayouts.entries()) {
       const size = Math.min(
-        containerWidth / candidateLayoutColumns,
-        containerHeight / candidateLayoutRows
+        (containerWidth - (candidateLayoutColumns - 1) * gap) /
+          candidateLayoutColumns,
+        ((containerHeight - (candidateLayoutRows - 1) * gap) * aspectRatio) /
+          candidateLayoutRows
       );
       if (size > bestSize) {
         bestSize = size;
@@ -101,8 +108,10 @@ const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
     }
     const numRows = bestNumRows;
     const numColumns = candidateLayouts.get(bestNumRows);
-    return [numRows, numColumns];
-  }, [candidateLayouts, containerHeight, containerWidth]);
+    return [numRows, numColumns, bestSize];
+  }, [aspectRatio, candidateLayouts, containerHeight, containerWidth]);
+
+  const imageHeight = imageWidth / imageAspectRatio;
 
   const sublevelToProgressBubbleLevel = (index: number) => {
     const sublevel = levelBubbleChoice.sublevels[index];
@@ -126,52 +135,72 @@ const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
     <div id="bubble-choice" className={styles.bubbleChoiceContainer}>
       <div>
         {levelBubbleChoice.displayName && (
-          <Heading4 className={styles[`heading${backgroundSuffix}`]}>
+          <Heading4 className={styles.heading}>
             {levelBubbleChoice.displayName}
           </Heading4>
         )}
         {levelBubbleChoice.description && (
-          <div className={styles[`text${backgroundSuffix}`]}>
-            {levelBubbleChoice.description}
-          </div>
+          <div className={styles.text}>{levelBubbleChoice.description}</div>
         )}
       </div>
-      <div
-        className={styles.sublevelsContainer}
-        style={{
-          gridTemplateColumns: `repeat(${numColumns}, minmax(0,1fr))`,
-          gridTemplateRows: `repeat(${numRows}, minmax(0,1fr))`,
-        }}
-        ref={containerRef}
-      >
-        {levelBubbleChoice.sublevels.map((sublevel, index) => (
-          <button
-            type="button"
-            key={index}
-            className={classNames(
-              styles.sublevelButton,
-              styles[`sublevelButton${backgroundSuffix}`]
-            )}
-            onClick={() => navigateToSublevel(sublevel)}
-          >
-            <div className={styles.sublevelImageContainer}>
-              <img
-                alt=""
-                src={sublevel.thumbnail_url}
-                className={styles.sublevelImage}
-              />
-            </div>
-            <div className={styles.sublevelText}>
-              <ProgressBubble
-                level={sublevelToProgressBubbleLevel(index)}
-                disabled={true}
-                hideToolTips={true}
-                smallBubble={true}
-              />
-              {sublevel.display_name}
-            </div>
-          </button>
-        ))}
+      <div className={styles.subLevelsOuterContainer} ref={containerRef}>
+        <div
+          className={styles.sublevelsContainer}
+          style={{
+            gridTemplateColumns: `repeat(${numColumns}, minmax(0,1fr))`,
+            gridTemplateRows: `repeat(${numRows}, minmax(0,1fr))`,
+            gap: `${gap}px`,
+          }}
+        >
+          {levelBubbleChoice.sublevels.map((sublevel, index) => (
+            <button
+              type="button"
+              key={index}
+              className={styles.sublevelButton}
+              style={{
+                width: imageWidth,
+                height: imageWidth / aspectRatio,
+              }}
+              onClick={() => navigateToSublevel(sublevel)}
+            >
+              <div
+                className={styles.sublevelImageContainer}
+                style={{width: imageWidth, height: imageHeight}}
+              >
+                <img
+                  alt=""
+                  src={sublevel.thumbnail_url}
+                  className={styles.sublevelImage}
+                />
+                <div className={styles.sublevelProgressBubbleContainer}>
+                  <ProgressBubble
+                    level={sublevelToProgressBubbleLevel(index)}
+                    disabled={true}
+                    hideToolTips={true}
+                    smallBubble={true}
+                  />
+                </div>
+              </div>
+              <div className={styles.sublevelTextContainer}>
+                <Heading4
+                  className={classNames(
+                    styles.heading,
+                    styles.sublevelTextHeading
+                  )}
+                >
+                  {sublevel.display_name}
+                </Heading4>
+
+                {sublevel.description && (
+                  <EnhancedSafeMarkdown
+                    markdown={sublevel.description}
+                    className={styles.sublevelDescriptionMarkdown}
+                  />
+                )}
+              </div>
+            </button>
+          ))}
+        </div>
       </div>
       <div className={styles.buttonRow}>
         <Button
diff --git a/apps/src/lab2/types.ts b/apps/src/lab2/types.ts
index af6cf878137a0..de089631999ef 100644
--- a/apps/src/lab2/types.ts
+++ b/apps/src/lab2/types.ts
@@ -243,6 +243,7 @@ export interface BubbleChoiceLevelData {
 // Bubble Choice specific property
 export interface BubbleChoiceSublevel {
   display_name: string;
+  description?: string;
   level_id: string;
   thumbnail_url: string;
   url: string;


Reviews:
user: login: sanchitmalhotra126
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-21T15:08:52Z
user: login: moshebaricdo
body: Ty for the tag! I like these! Some small suggestions:
- Can we use a secondary bg for the cards to add some contrast against the primary bg?
- I think the bubble may look better in the top right corner and a little bigger, this way the sub-level text is all evenly aligned against left edge and the bubble is a bit easier to parse as a distinct element.
- I think the left-padding of the text container could be bumped up a little.
- I would say that 4px border-radius is our standardized value, but I do like the more rounded corners here 😄 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T19:26:51Z
user: login: sanchitmalhotra126
body: LGTM!
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-28T18:49:30Z

Review comments:
diff_hunk: @@ -9,82 +9,112 @@
   margin: 0 auto;
   gap: 15px;
   position: relative;
+  color: var(--text-neutral-primary);
 
-  .headingLight, .textLight {
-    color: $neutral_dark;
-  }
-
-  .headingDark, .textDark {
-    color: $neutral_white;
-  }
-
-  .headingLight, .headingDark {
+  .heading {
     margin-top: 15px;
   }
 
-  .textLight, .textDark {
+  .text{
     font-size: 14px;
   }
 
-  .sublevelsContainer {
-    display: grid;
-    justify-content: space-around;
-    gap: 15px;
-    max-height: 100%;
-    overflow: hidden;
-    flex-grow: 1;
-    justify-items: center;
+  .subLevelsOuterContainer {
+    width: 100%;
+    height: 100%;
+    display: flex;
     align-items: center;
+    justify-content: center;
+    min-height: 0;
 
-    .sublevelButton {
-      display: flex;
-      flex-direction: column;
-      flex-basis: 0;
-      flex-grow: 1;
-      border-radius: 20px;
-      overflow: hidden;
-      background-color: inherit;
-      color: inherit;
-      font-size: 16px;
-      padding: inherit;
-      margin: 0;
-      aspect-ratio: 1;
-      max-width: 100%;
+    .sublevelsContainer {
+      display: grid;
+      justify-content: space-around;
+      gap: 15px;
       max-height: 100%;
+      overflow: hidden;
+      justify-items: center;
+      align-items: center;
+      width: fit-content;
+      height: fit-content;
 
-      &Light {
-        border: solid 1px $neutral_dark60;
-      }
+      .sublevelButton {
+        display: flex;
+        flex-direction: column;
+        flex-basis: 0;
+        flex-grow: 1;
+        border-radius: 20px;
+        overflow: hidden;
+        background-color: var(--background-neutral-secondary);
+        color: inherit;
+        font-size: 16px;
+        padding: inherit;
+        margin: 0;
+        max-width: 100%;
+        max-height: 100%;
+        container-type: size;
+        border: solid 1px var(--borders-neutral-primary);
 
-      &Dark {
-        border: solid 1px $neutral_dark80;
-      }
+        &:hover {
+          .sublevelImage {
+            transform: scale(1.02, 1.02);
+            transition: 0.1s ease-in-out;
+          }
+        }
+
+        .sublevelImageContainer {
+          overflow: hidden;
+          position: relative;
+
+          .sublevelImage {
+            width: 800px;
+            object-fit: contain;
+            opacity: 1;
+          }
 
-      &:hover {
-        .sublevelImage {
-          transform: scale(1.02, 1.02);
-          transition: 0.1s ease-in-out;
+          .sublevelProgressBubbleContainer {
+            position: absolute;
+            top: 4cqw;
+            right: 4cqw;
+          }
         }
-      }
 
-      .sublevelText {
-        width: 100%;
-        display: flex;
-        justify-content: center;
-        text-align: center;
-        margin: 15px 0;
-        align-items: center;
-        gap: 15px;
-      }
+        .sublevelTextContainer {
+          display: flex;
+          flex-direction: column;
+          flex: 1;
+          gap: 3cqw;
+          padding: 4cqw;
path: apps/src/bubbleChoice/BubbleChoice.module.scss
user: login: moshebaricdo
body: Love seeing some cqw action
created_at: 2025-04-24T19:24:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -17,22 +17,27 @@ import {
   BubbleChoiceLevelData,
   BubbleChoiceSublevel,
 } from '@cdo/apps/lab2/types';
+import EnhancedSafeMarkdown from '@cdo/apps/templates/EnhancedSafeMarkdown';
 import ProgressBubble from '@cdo/apps/templates/progress/ProgressBubble';
-import {capitalizeFirstLetter} from '@cdo/apps/util/capitalizeFirstLetter';
 import {useAppDispatch, useAppSelector} from '@cdo/apps/util/reduxHooks';
 import {LevelStatus} from '@cdo/generated-scripts/sharedConstants';
 
-import {getCurrentLesson} from '../code-studio/progressReduxSelectors';
 import {commonI18n} from '../types/locale';
 
 import styles from './BubbleChoice.module.scss';
 
 const BubbleChoice: React.FC<LabProps> = ({levelProperties}) => {
+  // The image has a 4:3 aspect ratio.
+  const imageAspectRatio = 4 / 3;
+
+  // The aspect ratio of each sublevel button.  It has an image above a text area,
+  // each with the same aspect ratio.
+  const aspectRatio = imageAspectRatio / 2;
+
+  // The gap (in pixels) between each sublevel button.
+  const gap = 15;
path: apps/src/bubbleChoice/BubbleChoice.tsx
user: login: sanchitmalhotra126
body: nit: might be good to move these constants out of the component body
created_at: 2025-04-28T18:49:03Z
author_association: CONTRIBUTOR

PR comments:
user: login: breville
created_at: 2025-04-21T05:10:26Z
body: Note: easiest to review with **Hide whitespace**.  
author_association: MEMBER
user: login: markabarrett
created_at: 2025-04-21T17:12:36Z
body: I like the layout! Do these use existing DSCO styles/variables, or are they completely custom?
author_association: NONE
user: login: markabarrett
created_at: 2025-04-21T17:15:53Z
body: I think these still need to be labeled A,B,C,D...right?

author_association: NONE
user: login: dancodedotorg
created_at: 2025-04-21T18:11:22Z
body: > I think these still need to be labeled A,B,C,D...right?

Just chiming in to add that the teacher dashboard still labels these as "abcde" etc.

![image](https://github.com/user-attachments/assets/4e087773-6b76-4d46-baba-67ecac73a51a)

(I don't know if that's enough to push one way or another; just highlighting that there's at least one other place that uses these abcd identifiers)
author_association: CONTRIBUTOR
user: login: breville
created_at: 2025-04-24T18:24:04Z
body: Just noting some design changes from the initial version of this PR.  After discussing with @markabarrett and @dju90, we moved away from the horizontal sublevel buttons here, to the vertical sublevel buttons now seen in the PR description:

<img width="1362" alt="Screenshot 2025-04-20 at 9 49 33 PM" src="https://github.com/user-attachments/assets/6269f8ea-3c14-4d36-b3f4-9bfa2baf7e8c" />

### 

> I think these still need to be labeled A,B,C,D...right?

It was also decided to hold off on showing the labels in the progress bubbles for now, and we will see how things go with just the bubbles.  The bubbles themselves are now rendered over the corner of the sublevel image.  The image itself is now cropped with a 4:3 aspect ratio.  
author_association: MEMBER
user: login: breville
created_at: 2025-04-24T20:34:31Z
body: > Ty for the tag! I like these! Some small suggestions

We realised these were for the older version of this UI before the latest updates. 
author_association: MEMBER