Add Component-Scoped CSS Support#253
Conversation
|
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. |
|
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. |
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. |
|
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? |
|
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 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 Why this approach: It makes Tabs truly reusable because I can drop Does this example clarify the use case? Happy to provide more details if needed. |
|
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( |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Thinking aloud.
renderSitemap already accepts a folder:
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).
There was a problem hiding this comment.
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).
There was a problem hiding this comment.
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. 😁
There was a problem hiding this comment.
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
4cd9f41 to
e31d7e5
Compare
|
|
||
| // Continue normal rendering | ||
| try injectEnvironment(environment: environment).body.render(container, environment: environment) | ||
| } |
There was a problem hiding this comment.
When a view implements the render method itself will it break our injection behavior here?
There was a problem hiding this comment.
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
…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
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
Tabscomponent generates CSS based on its ID and tab count: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
HasComponentCSSare automatically discovered during rendering viaEnvironmentValuespropagation. No manual component tracking required.How it works:
When
renderSitemap()is called withcssConfiguration, it:ComponentLayerContextin the environmentImplementation: Tailwind Flexibility + Smart Deduplication
The
renderStyles()function (now internal) supports both Tailwind v3 and v4 with automatic CSS deduplication:Key features:
@layer componentsfor proper cascadeuseComponentLayer: falseTailwind v3 (default):
Tailwind v4 (opt-in):
Why File-Based > Inline Styles
While inline
<style>tags would work, the file-based approach offers:@apply/@screen@layer componentsensures utilities can override component stylesBuild Workflow: Before vs After
Before (Manual Collection - Sync Burden)
Problems:
After (Automatic Collection - Zero Maintenance)
Benefits:
Backward Compatibility
cssConfigurationparameter is optional (backward compatible)useComponentLayer: true(Tailwind v3 best practice)