Skip to content

Comments

Add Component-Scoped CSS Support#253

Open
csjones wants to merge 7 commits intoClutchEngineering:mainfrom
21-DOT-DEV:HasComponentCSS
Open

Add Component-Scoped CSS Support#253
csjones wants to merge 7 commits intoClutchEngineering:mainfrom
21-DOT-DEV:HasComponentCSS

Conversation

@csjones
Copy link
Contributor

@csjones csjones commented Nov 7, 2025

This PR introduces a component CSS system enabling Slipstream components to ship their own CSS, with automatic discovery during rendering to eliminate manual component tracking.

The Problem: Reusable Component CSS

Some reusable components need CSS that depends on their build-time configuration (when the Slipstream executable is ran). For example, the Tabs component generates CSS based on its ID and tab count:

public struct Tabs: View, HasComponentCSS {
    public let id: String        // e.g., "install", "docs"  
    public let tabs: [TabItem]   // Dynamic count
    
    public var componentCSS: String {
        """
        #\(id)-tab-0:checked ~ div #\(id)-content-0 { display: block; }
        \((0..<tabs.count).map { i in
            "#\(id)-tab-\(i):checked ~ div #\(id)-content-\(i) { display: block; }"
        }.joined(separator: "\n"))
        """
    }
}

Each instance needs unique selectors, so CSS must be generated at build time when components are instantiated.

Solution: Automatic CSS Collection via Environment

Components conforming to HasComponentCSS are automatically discovered during rendering via EnvironmentValues propagation. No manual component tracking required.

How it works:

extension View where Self: HasComponentCSS {
    public func render(_ container: Element, environment: EnvironmentValues) throws {
        // Automatically register this component's CSS
        environment.componentLayerContext?.add(self)
        
        // Continue normal rendering
        try injectEnvironment(environment: environment).body.render(container, environment: environment)
    }
}

When renderSitemap() is called with cssConfiguration, it:

  1. Creates a ComponentLayerContext in the environment
  2. Renders all pages, collecting components automatically
  3. Generates CSS with automatic deduplication
  4. Writes the single site-wide CSS file

Implementation: Tailwind Flexibility + Smart Deduplication

The renderStyles() function (now internal) supports both Tailwind v3 and v4 with automatic CSS deduplication:

internal func renderStyles(
    from components: [any HasComponentCSS],
    baseCSS: URL,
    to output: URL,
    useComponentLayer: Bool = true  // Flexible for v3/v4
) throws {
    // Deduplicate components by CSS content (first occurrence wins)
    var seenCSS = Set<String>()
    let uniqueComponents = components.filter { component in
        seenCSS.insert(component.componentCSS).inserted
    }
    
    // ... generate CSS with optional @layer wrapper
}

Key features:

  • Tailwind v3 (default) - Wraps in @layer components for proper cascade
  • Tailwind v4 (opt-in) - Works with automatic ordering via useComponentLayer: false
  • Automatic deduplication - Components with identical CSS only appear once (first occurrence wins)
  • Automatic discovery - Components register themselves during rendering

Tailwind v3 (default):

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  /* Tabs[install] */
  #install-tab-0:checked ~ div #install-content-0 { display: block; }
  
  /* Tabs[docs] */
  #docs-tab-0:checked ~ div #docs-content-0 { display: block; }
}

Tailwind v4 (opt-in):

@import "tailwindcss";

/* Tabs[install] */
#install-tab-0:checked ~ div #install-content-0 { display: block; }

/* Tabs[docs] */
#docs-tab-0:checked ~ div #docs-content-0 { display: block; }

Why File-Based > Inline Styles

While inline <style> tags would work, the file-based approach offers:

  1. Tailwind processing - Component CSS gets minification, autoprefixer, and can use @apply/@screen
  2. Proper cascade - @layer components ensures utilities can override component styles
  3. Automatic deduplication - Shared components (headers, footers) only appear once
  4. Single compiled file: Aligns with Tailwind's recommended production setup

Build Workflow: Before vs After

Before (Manual Collection - Sync Burden)

static func main() throws {
    // 1. Manually collect all components used across entire site
    let allComponents: [any HasComponentCSS] = 
        Homepage.cssComponents +        // [SiteHeader]
        P256KPage.cssComponents +       // [SiteHeader, Tabs, Accordion]
        BlogListingPage.cssComponents + // [SiteHeader]
        posts.flatMap { _ in BlogPostPage.cssComponents }
    
    // 2. Generate single site-wide CSS (automatic deduplication)
    try renderStyles(
        from: allComponents,  // SiteHeader automatically deduped!
        baseCSS: URL(filePath: "style.base.css"),
        to: URL(filePath: "static/style.input.css")
    )
    
    // 3. Render HTML (all pages reference same style.css)
    try renderSitemap(sitemap, to: outputURL)
}

Problems:

  • ❌ Must manually track all components used across entire site
  • ❌ Easy to forget components, causing missing styles
  • ❌ HTML and CSS can get out of sync
  • ❌ Boilerplate code in every page file

After (Automatic Collection - Zero Maintenance)

static func main() throws {
    // Build sitemap with all pages
    var sitemap: Sitemap = [
        "index.html": Homepage.page,
        "packages/p256k/index.html": P256KPage.page,
        "blog/index.html": BlogListingPage.page
    ]
    
    // Render site with automatic CSS collection and generation
    try renderSitemap(
        sitemap,
        to: outputURL,
        cssConfiguration: (
            baseCSS: URL(filePath: "style.base.css"),
            output: URL(filePath: "static/style.input.css")
        )
    )
}

Benefits:

  • ✅ Components automatically discovered during rendering
  • ✅ Impossible to have HTML/CSS sync issues
  • ✅ No manual tracking or boilerplate
  • ✅ Single function call handles both HTML and CSS

Backward Compatibility

  • cssConfiguration parameter is optional (backward compatible)
  • ✅ Defaults to useComponentLayer: true (Tailwind v3 best practice)
  • ✅ Opt-in to v4's automatic ordering if needed
  • ✅ No breaking changes to existing patterns

@jverkoey
Copy link
Collaborator

jverkoey commented Nov 7, 2025

This is interesting; are you also using Tailwind CSS when using Slipstream? Slipstream has been designed primarily for Tailwind CSS use where a single compiled css file is generated for a given website, so we might need to discuss the goals here of this new CSS approach.

@jverkoey
Copy link
Collaborator

jverkoey commented Nov 7, 2025

My first instinct is that this feels similar to the Script element, and that it might be preferable to have a Stylesheet initializer that accepts a string and renders an inline stylesheet.

public init(_ url: URL?, crossOrigin: CrossOrigin? = nil) {
self.url = url
self.crossOrigin = crossOrigin
}

@csjones
Copy link
Contributor Author

csjones commented Nov 8, 2025

Slipstream has been designed primarily for Tailwind CSS use where a single compiled css file is generated for a given website

Thanks for the feedback and yes, I'm using Tailwind CSS with Slipstream.

Based on what you said, I re-read the Tailwind docs which made me reconsider my approach. I’ve refactored it to use the single compiled CSS file philosophy you mentioned (not per-page) and adding an option for taking advantage of Tailwind v3/v4 flexibility. Also updated the original post with the detailed information.

@jverkoey
Copy link
Collaborator

jverkoey commented Nov 8, 2025

Thanks for updating this! I admit I'm actually not super familiar with Tailwind's CSS component solutions; in the problem section you mention "Dynamic Component CSS" and "runtime configuration", but Slipstream is fundamentally a static, no-runtime HTML generator, so I'm maybe not fully understanding the problem well enough to review whether this PR is moving in the right direction there.

Is there maybe another concrete example of the problem you're trying to solve here?

@csjones
Copy link
Contributor Author

csjones commented Nov 8, 2025

My bad for the confusion, by "runtime configuration" I meant build-time when the Swift executable runs to generate the static site, not browser runtime. 😅

Concrete example:

I built a reusable Tabs base component following Web Component library patterns. Each instance needs unique CSS selectors based on its ID and tab count, which must be generated when the Swift executable runs to build the static site.

Each Tabs instance needs unique CSS selectors based on its configuration. For example:

// Tabs(id: "install", tabs: 3) generates:
#install-tab-0:checked ~ div #install-content-0 { display: block; }
#install-tab-1:checked ~ div #install-content-1 { display: block; }
#install-tab-2:checked ~ div #install-content-2 { display: block; }

// Tabs(id: "usage", tabs: 2) generates:
#usage-tab-0:checked ~ div #usage-content-0 { display: block; }
#usage-tab-1:checked ~ div #usage-content-1 { display: block; }

This CSS-only tab switching relies on the :checked pseudo-selector and must be generated at build-time.

Why this approach:

It makes Tabs truly reusable because I can drop Tabs(id: "install", tabs: [...]) anywhere and add/remove an individual tab without needing to modify the CSS. The component encapsulates both its HTML structure and CSS requirements (no manual CSS editing needed for each instance).

Does this example clarify the use case? Happy to provide more details if needed.

@jverkoey
Copy link
Collaborator

jverkoey commented Nov 8, 2025

Ah! That makes a bunch more sense, thank you! Looking now.

/// cascade ordering. Defaults to `true`. Set to `false` for Tailwind CSS v4's automatic ordering.
/// - Throws: `CocoaError` if the base CSS file cannot be read, or if the output file or directory cannot be created or written.
@available(iOS 17.0, macOS 14.0, *)
public func renderStyles(
Copy link
Collaborator

Choose a reason for hiding this comment

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

The existence of this method implies that this is a sibling to the other top-level render methods for generating HTML given a sitemap / View type. Is the intended use here then to provide a sitemap and crawl it for all HasComponentCSS-conforming views?

Asking because I'd expect it to be a bit annoying to have to keep this renderStyles method in sync with the sitemap renderer; ideally you can pass the same sitemap instance to both methods and get the corresponding output written out.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You're absolutely right, this creates an unnecessary sync burden. At the time, I wasn't sure how to best automatically extract HasComponentCSS conforming views. Now I am thinking I could make these changes:

extension View {
    /// Components that require CSS to be rendered for this view.
    /// Override in views that conform to or contain HasComponentCSS components.
    var cssComponents: [any HasComponentCSS] {
        []  // Default: no components
    }
}
extension Sitemap {
    /// Extracts all CSS components from views in the sitemap.
    var allCSSComponents: [any HasComponentCSS] {
        values.flatMap { $0.cssComponents }
    }
}

...then the site generator would look more liek this:

var sitemap: Sitemap = [
    "index.html": Homepage.page,
    "packages/p256k/index.html": P256KPage.page,
    // ...
]

try renderStyles(from: sitemap.allCSSComponents, baseCSS: ..., to: ...)
try renderSitemap(sitemap, to: ...)

Happy to continue refactoring if you have a better suggestion on the approach.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Thinking aloud.

renderSitemap already accepts a folder:

public func renderSitemap(_ sitemap: Sitemap, to folder: URL, encoding: String.Encoding = .utf8) throws {

which makes me wonder if renderSitemap should automatically handle CSS extraction as it traverses the view tree. Slipstream already has an Environment-analog for injecting state down the view hierarchy, but this approach would require some way of propagating information (i.e. the css data) up the view tree instead.

Alternatively, renderSitemap could just do a two-pass solution, first doing the render pass as it does today, and then again with a new recursive renderStyles method that generates the css and merges the results together (or writes them to adjacent css files if we want to allow more configuration somehow).

Copy link
Collaborator

Choose a reason for hiding this comment

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

The downside to the latter is that every view will get initialized twice, which shouldn't technically be a problem but I know it's easy to get lazy and start doing expensive processing in the view initializers (Sidecar's supported cars directory and leaderboard does this).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I am leaning towards the "automatically handle CSS extraction as it traverses the view tree" and going refactor the code to see where the solution goes. 😁

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Pushed a version using the automatic CSS extraction approach.

- Wrap component styles in `@layer components` by default for proper Tailwind CSS v3 cascade ordering
- Add `useComponentLayer` parameter to support Tailwind CSS v4's automatic ordering when set to false
- Automatically deduplicate components with identical CSS content (first occurrence wins)
- Introduced ComponentLayerContext to automatically collect HasComponentCSS components during view rendering
- Extended renderSitemap with optional cssConfiguration parameter to generate combined CSS output
- Changed renderStyles visibility to internal as it's now called automatically by renderSitemap

// Continue normal rendering
try injectEnvironment(environment: environment).body.render(container, environment: environment)
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

When a view implements the render method itself will it break our injection behavior here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is a really insightful observation that I missed, and I appreciate the feedback. You’re right that custom render methods would have broken this injection behavior. I tried a few different approaches, but the injection behavior in render() was closest to an ideal automatic CSS extraction (even if you were okay with that limitation, it didn’t feel right to couple a custom render to the StyleModifier, even if it only has this limitation when both are used on a View).

Now that I know this limitation, implementing this change cleanly required a two-pass solution. I simply tried to emulate render() on the View protocol and called it style(). This also made it easier to be fully asynchronous, but it required creating a new renderSitemap to avoid the @unchecked Sendable usage.

…S → StyleModifier per maintainer feedback

- Rename ComponentLayerContext to StyleContext
- Rename HasComponentCSS protocol to StyleModifier for idiomatic SwiftUI naming
- Rename componentCSS property to style
- Remove redundant internal keyword from renderStyles
- Update all references across codebase and tests
- Separated style collection from rendering by adding dedicated `style()` method to View protocol
- Changed StyleContext from @unchecked Sendable class to actor for proper concurrent access
- Updated renderSitemap to traverse views for style collection before HTML rendering begins
@csjones csjones requested a review from jverkoey November 12, 2025 02:59
…ollection

- Implement style() method in AttributeModifierView to propagate style collection through attribute modifiers
- Add regression tests verifying CSS collection works through .language(), .id(), and other attribute modifiers
- Add integration tests for deeply nested wrappers and ForEach with attributes
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