Skip to content

fix(studio): ship component styles as their own stylesheet for embedders - #4393

Merged
miguel-heygen merged 4 commits into
mainfrom
fix/studio-component-styles
Sep 24, 2026
Merged

miguel-heygen merged 4 commits into
mainfrom
fix/studio-component-styles

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

What changes

A page that embeds Studio's components can now style them. Studio's component rules (timeline clips and their labels, loaders, range inputs, toasts, preset shelf, CodeMirror overrides and the rest) lived only in Studio's app stylesheet. An embedder that imports @hyperframes/studio/theme.css got none of them: timeline clips drew as bare boxes, most disappeared against the row, and clip labels rendered black on a dark background.

Those rules now live in packages/studio/src/styles/components.css, exported as @hyperframes/studio/components.css. studio.css keeps the app shell (Tailwind, palette reset, theme, @source, the button cursor, color scheme, body, #root) and imports components.css, so each rule has one home and Studio itself is unchanged.

An embedder on Tailwind v4 imports:

@import "tailwindcss";
@import "@hyperframes/studio/theme.css";
@import "@hyperframes/studio/components.css";

The class names and @keyframes names in components.css are global, as they are in Studio: timeline-* and hf-* prefixed, so a clash with the host page is unlikely but not impossible.

How

  • The rules moved byte for byte. Nothing in them changed.
  • The export is added to exports, publishConfig.exports and package-subpaths.json, shaped like the existing ./theme.css (served from src).
  • hex-baseline.json: the 48 colour literals are now counted under the new file. The total is unchanged.
  • scripts/contrast-pairs.json: the 33 source pointers into studio.css now point at the same lines in components.css. Each one was checked by line content, not by arithmetic.
  • Two existing tests that read studio.css as text for rules that moved (VolumeControl.styles.test.ts, timelineMotionStyles.test.ts) now read components.css.
  • New componentStyles.test.ts:
    • studio.css may define no class rules at any depth, including inside @layer and @media, so a component rule can't drift back where an embedder never sees it.
    • Tailwind, theme.css and components.css alone must yield the timeline clip label rule.
    • Each was checked by breaking the code: a toast rule added to studio.css at top level and inside @layer base, the label rule added back, and the label rule removed from components.css each fail the matching test.

Evidence

  • Every Studio test file that references a stylesheet (17 files) passes one by one, as do the three typechecks and the package-subpath check.
  • Studio's compiled dist/styles.css, built before and after: the same 2 280 rules (identical as a multiset; only the order changed, because the imported rules now come before body and #root, which target different elements).
  • Studio screenshots on a 24-scene project, before and after, with scene thumbnails hidden (they finish loading at different times on every run): one before/after pair is byte-identical. The other runs differ by at most 30 pixels, the same as two runs of one build.
  • Embedder: a page with only Tailwind, theme.css and Studio's real timeline markup. The clip label's computed colour is rgb(0, 0, 0) before and rgb(255, 255, 255) after.

Before

Timeline markup embedded with theme.css only (today's embedder):

Embedder timeline before: clips missing or bare, no label

After

The same markup with theme.css and components.css:

Embedder timeline after: clips styled, label visible

Studio is unchanged

The same 24-scene project in Studio, scene thumbnails hidden. main's screenshot and this branch's screenshot are byte-identical (md5 412b74d27e7c2f8716ae46fa1b9e2245 for both), so it appears once:

Studio, identical on main and this branch

Review

An independent adversarial review over three passes:

  • Pass 1 found one blocker: two existing tests read studio.css as text for rules that moved. It also flagged a guard test that missed classes applied outside className literals, and a stale comment. All three are fixed here.
  • Pass 2 found that the guard missed class rules nested inside @layer or @media. That is fixed too.
  • At this head: no blockers, no majors, no minors.

The review also confirmed:

  • the byte-for-byte move (rebuilding the old file from the two new ones gives an empty diff);
  • equal compiled output apart from order;
  • that the order change cannot change any cascade winner;
  • that nothing page-global moved into components.css;
  • the published export;
  • the colour-literal and contrast-pointer updates.

Studio's component rules (timeline clips, loaders, range inputs, toasts and
the rest) lived only in the app sheet, so a page that embeds Studio's
components with theme.css got unstyled clips and dark-on-dark labels. They
move unchanged into components.css, exported as
@hyperframes/studio/components.css; studio.css keeps the app shell and
imports it, so Studio's compiled CSS has the same rules.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving at e519a60d. I checked the move and the guard directly.

Verified

  • The move is byte for byte. I sorted the lines of main's studio.css and compared them with the lines of this branch's studio.css (minus the new import) plus components.css. The only difference is the two lines of the new header comment in components.css.
  • The order change can't change which rule wins.
    • The moved rules now sit before body, :root and #root instead of around them.
    • Nothing in components.css selects an element, :root or *.
    • index.html puts no class on html, body or #root, and no code in src adds one. So no moved class rule and no app-shell rule can ever match the same element.
    • The @layer base cursor rule loses to unlayered rules wherever it sits.
  • components.css works on its own for an embedder.
    • It has no @apply, theme(), @utility or @variant.
    • Every var() it reads is either defined in theme.css or components.css, set inline by the component (--clip-* in TimelineClip.tsx, --timeline-transition-zone-width in TimelineTransitionBadge.tsx), or has a fallback (--hf-heading, --hf-text-*).
  • The export works. files includes src, so ./components.css is served from src in both exports and publishConfig.exports, the same as ./theme.css.

Tests

  • src/styles plus the two retargeted tests: 62/62 pass.
  • Mutation 1: I appended .hf-toast {} to studio.css nested inside @media and then @layer base. componentStyles.test.ts fails, as it should.
  • Mutation 2: I dropped the @import "./components.css" from studio.css. tokenGate.test.ts fails twice, so Studio can't silently lose its component styles.
  • The full studio suite with mutation 2 applied had 5460 passing and only those 2 failing.

Non-blocking

  • An embedder now gets Studio's class names and @keyframes names (hf-toast-in, hf-topology-*, …) in its global scope. With the hf- and timeline- prefixes, a clash is unlikely. It is worth one line in the embedder note that these names are global and unscoped.

— Rames

@miguel-heygen
miguel-heygen merged commit d1048cf into main Sep 24, 2026
55 checks passed
@miguel-heygen
miguel-heygen deleted the fix/studio-component-styles branch September 24, 2026 05:35
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.

2 participants