Source file theme.ml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
(** Shared theme variables for consistent ordering and avoiding conflicts *)

module Css = Cascade.Css

(** Main ordering scheme: 1. Font families (1) - basic font families 2. Colors
    (2) - color variables 3. Spacing (3) - spacing variables 4. Breakpoints (4)
    \- breakpoint variables 5. Containers (5) - container variables 6.
    Typography (6) - text sizes and other typography 7. Border radius (7) -
    border radius variables 8. Animation/timing (8) - animation variables 9.
    Default fonts (9) - default font family variables *)

(* Publish a theme variable's built-in default through the token registry. The
   text comes from the binding the utility itself emits, so [theme(--x)] in a
   class, [theme()] in a project's CSS and [theme(static)] all read exactly what
   the theme layer would declare for the token. A registry entry is a token
   stream rather than a typed value, and the printer leaves its numbers as
   written, so the shortest spelling is the one to store: it is what the sheet
   carries in either output mode. *)
let register_default var value =
  let decl = Var.set var value in
  Scheme.register_default_token (Var.name var)
    (Css.declaration_value ~minify:true decl)

(** {1 Spacing Variables} *)

(* Resolve the optionally-threaded theme, defaulting to the base scheme. *)

(* Shared spacing variable used across padding, margin, positioning, etc.
   Tailwind v4 uses a single --spacing: 0.25rem variable and calc() for
   values. *)
let spacing_var = Var.theme Css.Length "spacing" ~runtime:true ~order:(3, 0)

(* The base spacing value: 0.25rem *)
let spacing_base : Css.length = Rem 0.25

(* Publish the spacing step through the theme-token registry, the way rule.ml
   publishes the breakpoints, so [theme()] in a project's CSS resolves against
   the same value the utilities use. *)
let () = register_default spacing_var spacing_base

(* The spacing step times [n], rendered. Tailwind's v3 [spacing] and
   [lineHeight] scales are both that product, and v4 keeps no token per step, so
   a [theme(spacing.4)] has to be computed rather than looked up. *)
let spacing_times n =
  match spacing_base with
  | Css.Rem v -> Some (Css.Pp.to_string Css.pp_length (Css.Rem (v *. n)))
  | Css.Px v -> Some (Css.Pp.to_string Css.pp_length (Css.Px (v *. n)))
  | Css.Em v -> Some (Css.Pp.to_string Css.pp_length (Css.Em (v *. n)))
  | _ -> None

(* Create a spacing variable for explicit spacing values (e.g., --spacing-4) *)
let spacing_n_var n = Var.theme Css.Length ("spacing-" ^ Pp.int n) ~order:(3, n)

(* The length the theme binds to step [n] outright, as [--spacing-<n>]. Tailwind
   reads a bare step off that binding first and off the [--spacing] multiplier
   only when there is none. *)
let explicit_spacing scheme n =
  match Scheme.spacing scheme n with
  | Some _ as length -> length
  | None ->
      Option.bind (Scheme.token scheme ("spacing-" ^ Pp.int n)) Css.parse_length

(* Whether the bare step [n] of the spacing scale still resolves: [@theme {
   --spacing: initial }] removes the multiplier, and every step that relied on
   it stops being a utility. *)
let has_spacing_step ?theme n =
  let scheme = Scheme.or_default theme in
  Float.is_integer n
  && explicit_spacing scheme (int_of_float (Float.abs n)) <> None
  || Scheme.token scheme (Var.name spacing_var) <> None

(* The step the scheme binds outright, as var(--spacing-|n|) with a negative
   multiplier folded into calc(... * -1). None when the scheme binds no such
   step, or when the step is not a whole one: only whole steps get a token. *)
let explicit_spacing_length ?theme (n : float) =
  let abs_n = Float.abs n in
  if not (Float.is_integer abs_n) then None
  else
    let abs_n = int_of_float abs_n in
    match explicit_spacing (Scheme.or_default theme) abs_n with
    | None -> None
    | Some explicit_length ->
        let spacing_n = spacing_n_var abs_n in
        let decl, spacing_ref = Var.binding spacing_n explicit_length in
        if n < 0.0 then
          (* Negative: wrap in calc(... * -1) *)
          let neg_len : Css.length =
            Css.Calc
              (Css.Calc.mul
                 (Css.Calc.length (Css.Var spacing_ref))
                 (Css.Calc.float (-1.0)))
          in
          Some (decl, neg_len)
        else Some (decl, (Css.Var spacing_ref : Css.length))

(* The step itself: the [var(--spacing)] reference, or the value the project
   gave the token when it declared it in an [@theme inline] block, which has no
   declaration of its own for a reference to read. The declaration carried
   beside it is the dependency carrier the utility attaches either way; an
   unreferenced runtime one is dropped at the sheet. *)
let spacing_step ?theme () : Css.declaration * Css.length =
  let decl, spacing_ref = Var.binding spacing_var spacing_base in
  let inline =
    match theme with
    | Some t when Scheme.is_inline_token t (Var.name spacing_var) ->
        Option.bind
          (Scheme.theme_value theme (Var.name spacing_var))
          Css.parse_length
    | Some _ | None -> None
  in
  match inline with
  | Some length -> (decl, length)
  | None -> (decl, (Css.Var spacing_ref : Css.length))

(* The spacing step times [n], written out as calc(var(--spacing) * n), or
   calc(.25rem * n) over an inline token. The product is what a utility emits
   when the scheme binds no token for the step and the multiplier is not one
   Tailwind folds. *)
let spacing_product ?theme (n : float) : Css.declaration * Css.length =
  match explicit_spacing_length ?theme n with
  | Some result -> result
  | None ->
      let decl, step = spacing_step ?theme () in
      let len : Css.length =
        Css.Calc (Css.Calc.mul (Css.Calc.length step) (Css.Calc.float n))
      in
      (decl, len)

(* Create a spacing length value. When scheme has explicit spacing for |n|,
   returns var(--spacing-|n|) or calc(var(--spacing-|n|) * -1) for negatives.
   Otherwise returns calc(var(--spacing) * n), with the zero and unit
   multipliers folded the way Tailwind folds them. *)
let spacing_calc ?theme n : Css.declaration * Css.length =
  match explicit_spacing_length ?theme (float_of_int n) with
  | Some result -> result
  | None ->
      (* Default: calc(var(--spacing) * n). For the unit multiplier we emit a
         bare var(--spacing) rather than calc(var(--spacing) * 1). This shortcut
         exists only to match Tailwind core byte-for-byte: the fixture has p-1
         producing "padding: var(--spacing)" next to p-4 producing
         "calc(var(--spacing) * 4)". Without it our output diverges and the
         examples/parity comparisons flag the difference.

         Runtime expectation: --spacing must resolve to a single length. That is
         the spacing-scale contract (the default 0.25rem and any single-value
         @theme override). The shortcut is NOT sound under a multi-term runtime
         redefinition such as ".dense { --spacing: 1px + 3px }": bare
         var(--spacing) then expands to invalid bare math and falls back to the
         initial value, whereas calc(var(--spacing) * 1) would still compute
         (4px). We inherit this fragility from Tailwind. cascade must not
         perform the equivalent calc(var(--spacing)) -> var(--spacing) rewrite,
         because it optimises arbitrary CSS and cannot assume that contract.

         The zero step is a plain [0px], as Tailwind emits it: the scale factor
         makes [calc(var(--spacing) * 0)] zero for any spacing, and only the
         optimiser can see that once [--spacing] is a literal. *)
      if n <> 0 && n <> 1 then spacing_product ?theme (float_of_int n)
      else
        let decl, step = spacing_step ?theme () in
        if n = 0 then (decl, (Px 0. : Css.length)) else (decl, step)

(* Create a spacing length value for float multipliers like 2.5. For integer
   values, checks scheme for explicit spacing. Otherwise uses calc. This handles
   cases like my-2.5 which need calc(var(--spacing) * 2.5). *)
let spacing_calc_float ?theme (n : float) : Css.declaration * Css.length =
  (* Only an integer step can be bound outright by the scheme, and only an
     integer step folds. *)
  if Float.is_integer n then spacing_calc ?theme (int_of_float n)
  else spacing_product ?theme n