Skip to content

Conversation

@jonkafton
Copy link
Contributor

@jonkafton jonkafton commented Oct 29, 2025

What are the relevant tickets?

Closes https://github.com/mitodl/hq/issues/9059

BREAKING CHANGE: This boosts style specificity to ensure page styles are not applied unintentionally. This will impact consuming apps applying intentional styles. A helper method is provided on the Smoot Design API to facilitate upgrade. Component style specificity is not increased by default, however the AiChat and AiDrawer bundles are wrapped in a style isolation wrapper that applies reset styles scoped to the container and increases Smoot Design style specificity on Input and Button components. These should be checked carefully when upgrading in projects.

See comment below.

Description (What does it do?)

  • Provides a <StyleIsolation> wrapper that resets common text input and button styles.
  • Increases specificity on Input and Button styles with isolation scope, if the wrapper is a parent.
  • Adds <StyleIsolation> to the AiChat and AiDrawerManager bundles (AiChat at root and AiDrawerManager within the Drawer, wrapping content).

How can this be tested?

Run storybook with yarn start

Run the bundle preview with yarn bundle-preview

Additional Context

Uses && specificity stacking, which result in generated classnames being chained and repeated, e.g. .css-1s87gzz.css-1s87gzz..css-1s87gzz. The withStyleOverrides() helper wraps styles with the same, ```&& { ...styles }`.

@jonkafton jonkafton added the Needs Review An open Pull Request that is ready for review label Oct 29, 2025
flexShrink: 0,
}))
const CloseButton = styled(ActionButton)(({ theme }) =>
withStyleOverrides({
Copy link

@ahtesham-quraish ahtesham-quraish Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my knowledge!
This withStyleOverrides helper ensures that when you intentionally override design-system button styles, your styles always take effect — even in environments with strong competing CSS (like Canvas LMS). Right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not necessarily, but they will ensure the design system button styles themselves, which also now use &&. This raises specificity to ensure styles should not in most cases be unintentionally applied, but without raising it so high that the only way to override is with !important.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sounds good

RiDeleteBinLine,
RiTestTubeLine,
} from "@remixicon/react"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lets not remove this lets keep it here what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have any rules for newlines here, so I'm indifferent, however I don't see any grouping between the imports above and below to need one here.

* with potentially conflicting buttons styles from the parent
* page in a consuming application.
*/
export const PageStyleResistance: Story = {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to understand this!

  • PageStyleResistance is a Storybook scenario that:
  • Applies intentionally conflicting global button styles
  • Renders the design-system button inside that environment
  • Shows/proves that design-system styles still win

i am thinking of it like CSS unit testing.
Am i right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all correct. Not so much unit testing, though could be viewed as a visual form of unit testing - the Storybook stories provide a way to present the components in isolation with in their various variants and explore their behavior. In this case, we're validating correct render by adding intentionally conflicting styles in the controlled environment.

...sx,
})

return css`
Copy link

@ahtesham-quraish ahtesham-quraish Oct 31, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job increasing style-specificity using && — this will prevent LMS global button CSS from leaking into our components. This is key to ensuring Button styles don’t get overridden by host app CSS (e.g., LMS global button styles).

import type { ButtonLinkProps, ButtonProps } from "../Button/Button"
import { css } from "@emotion/react"
import type { Theme } from "@mui/material/styles"
import { withStyleOverrides } from "../../utils/styles"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question / clarity:

Can we document the withStyleOverrides() helper inside the design system README so future contributors know when to use it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We tend to document usage notes alongside the code itself (see src/utils/styles.ts), though this could be surfaced more prominently.

variant: ButtonVariant,
colors: Theme["custom"]["colors"],
) => {
if (variant === "primary") {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right now, variantStyles and sizeStyles are implemented as big conditional functions with multiple if/else branches. This works well today, but over time it makes maintenance harder because:

  • Themes and button variants grow → more conditions to manage
  • Styling rules are scattered across logic instead of a central config
  • It’s harder for designers/devs to customize in one place

Refactoring these into a theme token map (design-token-driven styling) would allow us to declare variants & sizes in a central theme object instead of conditional JS. This shifts styling from logic → configuration.

if (variant === 'primary') {
  return { backgroundColor: colors.mitRed }
} else if (variant === 'secondary') {
  return { borderColor: colors.silverGray }
}

We could move it into theme tokens:

theme.components.button = {
  variants: {
    primary: {
      backgroundColor: colors.mitRed,
      color: colors.white,
      border: 'none'
    },
    secondary: {
      backgroundColor: 'transparent',
      color: colors.red,
      border: `1px solid ${colors.silverGray}`
    }
  },
  sizes: {
    small: { padding: '8px 12px', ...theme.typography.buttonSmall },
    medium: { padding: '11px 16px', ...theme.typography.button },
    large: { padding: '14px 24px', ...theme.typography.buttonLarge }
  }
}

what do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We do provide a theme for common values, see https://github.com/mitodl/smoot-design/blob/main/src/components/ThemeProvider/ThemeProvider.tsx. These tie in with the naming in the Figma designs. For this case, the values are specific to the button component and self contained there - I'm not sure moving them out to the theme would add value. I'm not against specifying styles declaratively for the simpler cases, though we have places where the variant styling has fairly complex conditional combinations, context dependent styling and nested selectors where handling token objects will quickly become unwieldy - https://github.com/mitodl/smoot-design/blob/main/src/components/Input/Input.tsx#L54-L183 for example.

Copy link

@ahtesham-quraish ahtesham-quraish left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@arslanashraf7 arslanashraf7 added Waiting on Author and removed Needs Review An open Pull Request that is ready for review labels Nov 6, 2025
@ChristopherChudzicki
Copy link
Collaborator

ChristopherChudzicki commented Nov 7, 2025

@jonkafton Regarding the styling issues with OpenEdx in general:

  1. In some cases, we set a style AND openedx sets a style, and theirs overrides ours
  2. In some cases, we do NOT set a style, BUT they do, and it overrides ours. This happens when they target things like button (or maybe .their-div-class button).
    • Example: I think the feedback buttons are currently inheriting a background-image, at least within the OpenEdx canvas course.
    • ⚠️ I'm not sure this is addressed in this PR?

Increasing our specificity helps with (1) but not (2).

In slack, you mentioned layers as a possible solution. I don't think layers will help us (at least not without !important in each style) because

Styles that are not defined in a layer always override styles declared in named and anonymous layers.

To deal with issue (2), I think we need some css-resets, but they can only affect our own components, not openedx.

Regarding specificity boosting: It would be good to avoid increasing the specificity in Learn unnecessarily. What if

  • styled increased specificity of all components based on an env var (undefined in Learn, but defined in openedx bundle)
  • and we added a reset like .MitAi-chat button { background: unset }. The reset would override openedx styles, and with the specificity boost, our styles would override the reset.

Boy it would have been nice if the shadow dom approach had worked...totally isolated styling. Too bad it messed up keyboard controls.

@jonkafton
Copy link
Contributor Author

I'm struggling to find a good general solution.

  1. In some cases, we set a style AND openedx sets a style, and theirs overrides ours

For this case we need to increase specificity. The issue with doing that is we are introducing a breaking change for consuming apps that override Smoot Design styles intentionally - they would also need to increase specificity. The withStyleOverrides() provides an explicit API to do so, but it's cumbersome to use everywhere - as mentioned in Slack I'm not too happy with that solution.

  1. In some cases, we do NOT set a style, BUT they do, and it overrides ours. This happens when they target things like button (or maybe .their-div-class button).
    Example: I think the feedback buttons are currently inheriting a background-image, at least within the OpenEdx canvas course.
    ⚠️ I'm not sure this is addressed in this PR?

Increasing our specificity helps with (1) but not (2).

No, the primary intention in this PR is to address the conflicting action button styles in Canvas, though we are in need of a general solution to avoid chasing issues.

We do need reset styles, though these also come with the specificity issue. Canvas, for example, applies styles to form input[type="button"]. Our reset styles would need to target && button to tie and &&& button to win. Projects wanted to override these intentionally would need to go higher.

Our options:

  1. Increase specificity for Smoot styles and resets for styles that we know to conflict in current projects.
  2. Above plus pre-emptive best guesses on commonly used CSS rules that are likely to conflict.
  3. Wrap styles in && { ... } to increase specificity and provide API utility to do same (this PR in current state).
  4. I'd like thoughts on this - provide a wrapper that isolates styles at an container scope while keeping an override API.
const StyledButton = styled(Button)({
  backgroundColor: "blue",
})

<StyleIsolation>
  <Button variant="primary">Protected Button</Button>
  <StyledButton variant="secondary">Styled Secondary</Button>
</StyleIsolation>

The <StyleIsolation> component has resets e.g. "&& button": { backgroundImage: "unset" }. We can wrap the AiChat and AiDrawer bundle entrypoints only with the isolation wrapper, so projects using other components are unaffected.

@jonkafton
Copy link
Contributor Author

jonkafton commented Nov 18, 2025

Change of tack - implements option 4 in comment above.

  • A <StyleIsolation> wrapper component is added that resets common input, textarea and button styles, scoped to the wrapper.
  • This creates a React context that set the generated Emotion classname selector.
  • A useStyleIsolation hook is added, that is used within Smoot Design to conditionally increase the specificity of our component styles if contained within <StyleIsolation> (and the isolation classname set on the context). This wraps styles in .isolationClassName && { ... }.
  • The <StyleIsolation> wrapper is applied to the AiChat and AiDrawerManager bundles only.
  • To verify style isolation, bundle preview HTML files are included which import the LMS stylesheets used in Canvas that are causing conflicts.

@jonkafton
Copy link
Contributor Author

Pushed an additional change:

  • Provides a styled wrapper that applies the specificity increase if we are in a <StyleIsolation> context. We need to use this within Smoot Design to ensure that styled components get the necessary specificity over the isolation resets in the bundle code.
  • The wrapper is exported as styledWithIsolation. We would like to export as styled for immediate use in projects, however the wrapper has limitations. It does not work with shouldForwardProp and has not been tested with multiline string format styles.

Copy link
Collaborator

@ChristopherChudzicki ChristopherChudzicki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Requesting changes as a signal not to merge yet, since this was approved then changed a lot).

First: Do you know why the changes to main.ts are being picked up? That wasn't changed on your branch as far as i know. It's already merged.

Second: Really liked the new StyleIsolation tests! I'm still a bit wary of how far-reaching the code changes are. I asked ChatGPT for suggestions on how to solve this problem, assuming we are using emotion, and it suggested using a stylis plugin (emotion's css preprocessor).

Here's the discussion: https://chatgpt.com/share/691e4e95-ea74-800e-861d-691cfe03deb1

Did you look into a stylis plugin at all? It seems like a promising approach.

NOTES:

  • The code ChatGPT gave doesn't really work. A working version of the plugin is below.
  • In general, this approach seems a lot nicer... we wouldn't need to change our usage of styled at all. No need for useStyleIsolation.
  • That said, it's pretty low-level in emotion guts, I think, and I don't have a lot of experience with csss-preprocessors. (Though we can use the plugin only conditionally, which is nice.)

Here's a plugin that seemed to work well for me:

 import type { StylisPlugin, StylisElement } from "@emotion/cache"

/**
 * NOTE 
 * I would suggest just using a static class name like `.Mit-isolated`
 * No need for context.
 */
export const increaseSpecificity = (scope: string): StylisPlugin => {
  return (element: StylisElement) => {
    if (element.type === "rule" && Array.isArray(element.props)) {
      console.log(element.props)
      element.props = element.props.map((sel: string) => {
        if (
          sel.startsWith("@") ||
          sel.startsWith(":root") ||
          sel.includes(`${scope}${scope}${scope}`)
        ) {
          return sel
        }
        return `${sel}, ${scope}${scope}${scope} ${sel}`
      })
    }
  }
}

Open questions:

  1. Does this work? It seemed to. (Most of my testing was figuring out we needed to exclude boosting specificity of nested selects over and over again)
    • Possibly need a way to ensure the CSS resets don't have their specificity boosted?
  2. If we provide a plugins array, does that override the default? (E.g., are there default emotion plugs we'd need to re-enable?
    • Note: MUI has NextJsAppRouterCacheProvider, but that also takes a plugins array.
  3. One day in the future, we may want to replace emotion with MUI's Pigment. Unsure if there's a similar approach we could take in that world. (Though I imagine they'll support emotion for a long time, so you could image we use Pigment in Learn but Emotion in canvas/openedx.)

@ChristopherChudzicki
Copy link
Collaborator

@jonkafton One other thing... This isn't a breaking change anymore, right? Should change PR title.

@jonkafton
Copy link
Contributor Author

First: Do you know why the changes to main.ts are being picked up? That wasn't changed on your branch as far as i know. It's already merged.

I reset to main but wanted the history as it ties to the PR thread. Git is producing the diff based on the history even though the content is has not changed. A rebase should fix, though I tend to avoid.

Second: Really liked the new StyleIsolation tests! I'm still a bit wary of how far-reaching the code changes are. I asked ChatGPT for suggestions on how to solve this problem, assuming we are using emotion, and it suggested using a stylis plugin (emotion's css preprocessor).

It would definitely be good to reduce complexity. If I'm reading correctly, the plugin approach increases the specificity of all selectors, which we weren't wanting to do unless we are applying reset CSS. If the preprocessor also increases specificity when styled() is used in projects when overriding Smoot Design styles, it might not be an issue to always apply the reset, though this would be a problem for projects targeting our named classes.

The thinking for the current change:

  • If Smoot Design is used in projects when control the pages styles, no need to use style isolation / reset css
    • Selectors do not change.
    • useStyleIsolation finds no context and does not increase specificity.
  • Where we do need the reset css and need to add sufficient specificity to combat all likely cases
    • Uses of styled() inside smoot-design then also needs the additional specificity.
    • Issue: We need to use a hook to check the isolation context. Wrapping the style objects with the hook in all uses would be a pain and violates the lint rule, react-hooks/rules-of-hooks. Wrapping styled and running the hook let's us do this within smoot-design by only changing the import path and potentially everywhere if we export it as styled once we're confident. It still violates the lint rule, but we're always in the render phase, so it works. Ensuring the wrapper works for all uses of the overloaded styled API is difficult though and Emotion appears to create closures that we cannot access.

Running at AST level definitely helps us here a lot.

  • Are the selector changes applied conditionally only if the elements are inside a <CacheProvider cache={cache}>?

    Possibly need a way to ensure the CSS resets don't have their specificity boosted?

    If yes, the component with the reset styles and live outside the provider.

  • Are they also applied to projects using styled(SmootComponent)? I think the answer here is yes, but that's an important one.

Given the amount of time this has been in progress with respect to the request being to fix a bad background on a button, we might want to test out the above and make the change in a follow up.

@jonkafton One other thing... This isn't a breaking change anymore, right? Should change PR title.

It is aiming not to be, but given the breadth it should be at least a minor bump. I don't think Smoot is used anywhere with auto upgrade within minor range (or auto upgrade at all), but we'll want to take extra care when upgrading.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants