Liking cljdoc? Tell your friends :D

shadow.css - rough design draft

Trying to build a solution for CSS-in-CLJ(S).

Using tailwindcss style aliases to reduce user typing and improving consistency throughout styles. However, tailwind is rather constrained by the fact that it needs to fit in a class attribute string. We don't want that limitation, as sometimes it is more expressive to write actual CSS.

Writing actual CSS however requires context switching and jumping between files, we instead want styles to be defined where they are used. Directly in the namespaces, alongside other code.

Other CSS-in-X solution often require naming too many things which can get annoying, especially for utility layout elements. Names here are entirely optional and are covered by other CLJ constructs (eg. def, let, etc) instead.

Requirements

  • must be usable in CLJ, CLJS and any other dialect (for now focusing on CLJ+CLJS)
  • must be able to combine styles from all .clj, .cljc, .cljs, .cljd sources
  • each platform must be aware of other platforms styles (CLJ<->CLJS), if they may end up on the same context (eg. webpage)
  • must be usable in libraries
  • must have an option to generate zero client side JS code (and should do so by default)
  • should emit well-structured CSS
  • should be statically analyzable (better tool support)
  • should be combinable with other methods (post-compilation)
  • should be possible to DCE

Syntax

Abstract:

(css <root-part+>)

<root-part> =
    <alias>
  | <passthrough>
  | <css-map>
  | <nested-selector>
  
<part> =
    <alias>
  | <css-map>
  | <nested-selector>
  
<alias> = keyword?
<passthrough> = string?
<css-map> = map-of keyword? <css-val>
<css-val> = string? | number? | <alias>
<nested-selector = [<selector> <part+>]
<selector> = <css-string?> | <alias>
<css-string> = string including & | css media query starting with @

In Clojure terms, everything is done via the provided css macro.

;; keywords represent pre-defined aliases
;; maps are literal CSS definitions
(css :px-4 {:color "red"})

;; all the macro generates is a classname. the macro does not generate any css itself
"some_ns__Lx_Cx"
;; optionally, optimized combination of util classes (with minified names) may be generated instead
"px-4 aA"

;; strings are passed through as a convenience when using other CSS techs (eg. tailwind)
(css "px-4 my-2" {:background "#123"})
;; basically just a shorter version of
(str "px-4 my-2 " (css {:background "#123"}))

;; both yield
"px-4 my-2 some_ns__Lx_Cx"

;; map keys as keywords represent literal CSS rule keys, they are not modified in any way
;; map values are string, numbers, or aliases
;; strings are passed through as is
;; numbers are translated using the same numbering scheme the aliases use
;; there are exceptions where numbers are just used as is, eg {:flex 1}
;; if px or other specific units are required they should be expressed as a string
(css :px-4)
;; just short for
(css {:padding-left 4 :padding-right 4}) 

;; sub-selectors can be used to target nested elements or pseudo-classes
;; & is replaced with the actual generated classname and must be present
(css ["&:hover" {:color "green"}])

;; except for strings starting with @ representing media queries
(css ["@media (min-width: 1024px)" :px-8])

;; aliases are also allowed in the selector place for commonly used selectors
(css :px-4 [:lg :px-8])

;; if using an alias it must resolve to a string, otherwise yields an invalid style
(css :px-4 [:px-8]) ;; invalid

;; sub-selectors may be nested
(css
  :px-4
  ["&:hover" {:color "red"}]
  [:lg
   :px-8
   ["&:hover" {:color "green"}]])

All rules are combined sequentially per selector, later values may override previous ones.

Symbols are not used. They might confuse tools or users as to what they may resolve to, when in fact no resolving is ever done. No other forms are valid. css definitions are entirely static, no local code may influence them. Aliases however provide a way to customize what CSS is actually generated.

The intent here is to have something in the code that can be extracted just by parsing the source, without actually eval-ing anything. This is necessary because often styles need to be generated and served before the actual page HTML. Generating styles during evaluation is too late, but should be an option for more dynamic uses.

Library Use

A primary goal of this is making it usable in libraries, for any platform.

Traditionally libraries will just bundle their own required CSS, which often leads to unnecessary duplication or just makes things harder to optimize. With alias keywords it also becomes much easier to customize library css rules. CSS Variables can also be used of course, but aliases offer even more flexibility.

Instead, libraries will contain a resource file that should contain all their CSS definitions as their raw CLJ form. These resource files will be generated by the shadow.css tooling and loaded when the actual final .css file is generated in projects. Only styles from referenced namespaces will be included and everything can be optimized together if needed. The resource files reduce the need for additional processing of sources. Discovery of files in Jars can end up very expensive, and is wasted work for libraries that don't actually contain any CSS.

Cross Platform Use

CSS is generated as a build step, not at runtime.

Often there will be some CLJ server side code generating HTML+CSS, and then some additional CLJS client side code doing the same. The styles need to be generated before either is processed, so they can be served up as a regular (and cacheable) .css files. Dynamic generation at runtime should be possible, but not the default as it is much less efficient.

For CLJS :advanced compilation may help eliminate unused rules and purge them from the generated .css file.

CSS rules from namespaces that aren't referenced must not be included. References may come from source code or build configs.

Integration with other Systems

It is entirely fine to use the shadow.css output and feeding it into another CSS tool pipeline. Some library may just want to use some CSS but without (css ...). ns metadata or build configuration can be used to instruct the shadow.css tools to include CSS from other sources.

(ns some.library
  {:shadow.css/include
   ["some/library/static.css"]} ;; referencing other resources on the classpath
   ...)

These references however are not processed in any way, and are just included in the final output unmodified. There should be no assumption that these will be processed by some other specific tool again.

Problems

Static Analysis

The files are just parsed and not eval'd, so there can never be macros that can expand to include (css ...) forms. Location data would also be a problem for those. Not sure how much this will come back to haunt the whole thing. For now this is a trade-off I'm willing to try and see how it scales.

In theory the macro expansion itself could store information about what it did as a side effect somewhere. That data could then be used to generate the CSS. This data will become much harder to collect, but would be a little more flexible since it would allow macros to emit css rules.

REPL, actually just the Read part

One problem with analyzing the files statically and not at runtime is the REPL. We absolutely need the accurate source location (ns + line + column), since that is the css-id the runtime macro will generate.

load-file and require work fine since they parse the whole file on disk and have the correct locations.

Evaluating a single form however is a problem since the location data changes. ns is likely still accurate but line/column may be different. Either due to the editor not providing it, or being unable to. The reading usually starts at line 1 column 1 which does not match the actual file location and as such ends up generating the wrong css-id used for lookups later.

The problem will be gone if tooling/repl impls fix that. Until then forms using css cannot be evaluated in the REPL and still provide accurate CSS. This is probably fine for most things since CSS is never needed at the REPL directly.

Build Timing

Another issue is the problem of timing. This is not an issue for release builds since everything is just a part of the build step long before the code actually runs.

In development however the code often runs continuously and is eval'd on the fly via the REPL or hot-reload. Saving a file means the CSS processor needs to find that change and emit the proper updated CSS, and then the code needs to update. For CLJ these can just run side by side watches since the time the CSS is loaded counts, not the time the HTML is generated. For CLJS more integration is needed.

Self-Hosting

I don't have a clue how this would work in a self-hosted CLJS setup. Not sure there enough necessary hooks to get at the code at the correct time. Not sure how it would get the library indexes to it can generate CSS for those. Making the CSS compiler self-hostable shouldn't be an issue though.

Possible Optimizations

As of now each css will end up generating one classname, using the namespace and line/column information for the naming purposes. However, as mentioned earlier this doesn't need to be so. In addition to the .css file a lookup table may be generated. That way the class that ends up getting used in the code can be overridden. For CLJ that can be used at runtime. For CLJS this would need to be done at build time as it otherwise doesn't benefit from DCE.

Also, there may be multiple places in a codebase that have (css :px-4) and there is absolutely no need to have a specific class for each. So, one optimization is just emitting utility classnames ala tailwind. There'll also be common combinations that may also generate their own utility.

Instead of the long verbose names it could also generate short names, similar to what :advanced produces. For development however these long names are actually useful since they tell you exactly where they were defined just by looking at the name.

Testing should also be done if shortening is even necessary. GZIP is very good at optimizating repetition after all.

Might be useful to let use supply a list of prefixes that should be stripped. So instead of shadow_cljs_ui_components_common_LxCx you get common_LxCx with the rest stripped?

Observations

Observations made while porting the shadow-cljs UI from tailwind to purely shadow.css.

Sometimes becomes verbose

[:div.px-2]
[:div {:class "px-2"}]
;; becomes
[:div {:class (css :px-2)}]

Not bad compared to the :class variant which I prefer, so not a big deal overall. Could be alleviated by making the "indexer" extensible and able to collect other forms. As long as the picked id matches the id the macro will generate any form can be collected.

CSS Size

Prior with tailwind the generated CSS was GZIP 6.0KB (normal 23.4KB) including all the minification tailwind does.

The totally not-optimized-or-minified-still-includes-comments CSS from shadow.css gets up to GZIP 8.2 KB (normal 44.1 KB). So not at all bad. GZIP shines here, so the long classnames really don't seem to matter all that much. I expect that optimizations and duplicate removal will bring this at least to tailwind level, potentially smaller.

Running the generated CSS through an online CSS minifier shrinks everything to GZIP 5.4KB (normal 26.9 KB), which is then already smaller than Tailwind without even trying to minify classnames, removing duplicates or emitting all media-query'd rules grouped. Seems very promising.

Build Tooling

Build tooling is rough, but I get warnings for undefined aliases and noticed that the code I had ported had some classes that aren't defined in tailwind and did nothing. Tailwind never warned me about those.

Includes are nice

(ns shadow.cljs.ui.components.code-editor
  {:shadow.css/include ["shadow/cljs/ui/components/code-editor.css"]}
  ...)

So, when this namespace is included in the build it just includes this resource in the generated CSS as well. Similar to what webpack require("./some.css") does I guess. In the above case this is including all the CodeMirror related styles which is nice since those are not portable to shadow.css.

They also provide a way to add CSS that shadow.css might not be capable of generating yet.

Flexibility

Loving the flexibility of just defining stuff on the fly and not being constrained to be something tailwind can recognize. No need for w-[30px] typeof classes when you can just {:width "30px"} in the css definition.

Having full access to CSS also means more freedom. Not fully taking advantage of that yet since I just wanted to port the code over simply without having to worry about styling for now. Yet, I already feel like I can do more than before without having to jump to actual CSS files.

Getting more Clojurey

I think I like a new naming pattern of using $whatever names for css classes. So that the $ differentiates them from other local names.

(let [$row (css ...)
      $key (css ...)
      $val (css ...)]

  (<< [:div {:class $row}
       [:div {:class $key} key]
       [:div {:class $val} val]]))

;; could extend the fragment macro magic to make things shorter again

(let [$row (css ...)
      $key (css ...)
      $val (css ...)]

  (<< [:div$row
       [:div$key key]
       [:div$val val]]))

Downside to this is that tools (eg. Cursive) don't recognize this, so it shows all the $ locals as unused which is not ideal. Maybe tools could be taught that in some way?

Using a new $ prefix since # or . wouldn't be valid clojure, and we also still want those available so :div$local#app.class is valid.

Introducing local names that way certainly beats having to come up with global names such as defstyled did. Local names actually makes things more readable and usable, making the intent of elements clearer. Of course that could have been done before with just (let [$row "..."]), (def $thing ...) of course also works, sometimes global names are desirable.

On Extensibility

Based on the rough draft of this document a few people have expressed concerns over the non-REPL friendliness and static analysis of (css ...) forms. After thinking about this for a while I believe this is an absolute none issue.

The tooling currently creates an "index" of namespaces and the css forms it contains. It currently is collected by a simple analysis pass that parses CLJ(S) source files and looks for (css ...) forms. That index can either be used directly in the build process or it can be stored in a EDN file to be shipped as part of a library for example. Currently, this would be a shadow-css-index.edn file at the classpath root, added to the published .jar. This is then read at build time and added to the build index.

Index is just Data

The index is just a CLJ map of {ns-sym ns-info}. ns-info is another map of roughly this structure

{:ns shadow.cljs.ui.components.inspect,
 :ns-meta {}, ;; for :shadow.css/include etc
 :requires [] ;; not collected yet, just the :require namespaces in order
 :css
 [{:line 46,
   :column 27,
   :end-line 46,
   :end-column 74,
   :form [:w-full :h-full :font-mono :border-t :p-4]}
  {:line 50,
   :column 27,
   :end-line 50,
   :end-column 74,
   :form [:w-full :h-full :font-mono :border-t :p-4]}]}

So, each (css ...) form is just collected and stored in the index with the relevant metadata. The current tooling will generate the classname to use from the :ns :line :column by default, it could just use a :class string if already provided instead.

Index + Config then make CSS

Only the index data and some configuration is then used to construct the actual CSS needed. The idea is that the build config specifies which namespaces should be included. It could supply custom aliases or override predefined ones.

I haven't figured out how to do the tooling part yet. So, this is all very rough. For development of the shadow-cljs UI I created this bit of helper code that just runs as part of my normal REPL workflow. It watches my src/main and updates each namespace in the index when the file is modified. To make a "proper" file I run this.

Not a great build API but works for now.

Indexing is customizable

All this really needs then is an extensible index generation mechanism. Instead of the regular index-file or index-path functions you could call your own. All you have to do is update a CLJ map, which is simple.

You don't even have to use the shadow.css/css macro at all. If you can provide data for the index you are good to go.

Maybe shadow.css then just becomes sort of a standard way to express CSS-in-CLJ.

Don't forget about includes

The index can also include the :ns-meta {:shadow.css/include ["already/generated.css"]} directive, which is currently collected from the actual ns metadata. If you generate the index yourself you can supply that from wherever.

So, there already is a way to have pre-generated CSS and still have it participate in the overall CSS building by shadow.css.build.

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close