@uniflowed/stylex 0.0.0-alpha.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js ADDED
@@ -0,0 +1,158 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/stylex`: uf's style engine, and the preset it ships.
4
+ //
5
+ // Almost all of StyleX happens at compile time. `uf transform` rewrites every
6
+ // `stylex.create({ … })` into a plain object of class names, turns every
7
+ // `stylex.defineVars({ … })` into CSS custom properties, turns every
8
+ // `stylex.createTheme(tokens, { … })` into the classes that override them, and
9
+ // collects the rules into a stylesheet. By the time this module is loaded there
10
+ // are no style values left — only names.
11
+ //
12
+ // This module is the front door. The merge lives in `./props.js` beside its
13
+ // compile-time twin; the preset lives in `./tokens.stylex.js`, `./preset.js`
14
+ // and `./theme.js`. What is here is the merge re-exported, and the three
15
+ // functions that must never run.
16
+ //
17
+ // # What "preset" means here
18
+ //
19
+ // A framework's styling default is worth something only if it removes work
20
+ // without removing control, so uf's preset is three things and stops there:
21
+ //
22
+ // 1. **A token set that already exists.** `@uniflowed/stylex/tokens.stylex.js`
23
+ // is a real `stylex.defineVars` module — colour roles, a type scale,
24
+ // spacing, radii, elevation and motion — so a project has a coherent palette
25
+ // without authoring one, and gets it as `:root` custom properties the build
26
+ // inlined rather than as anything computed in a browser.
27
+ // 2. **A base layer over those tokens.** `@uniflowed/stylex/preset` is
28
+ // `stylex.create` namespaces for the surfaces a real application has, behind
29
+ // small functions that answer with `{ className }`. A project gets a default
30
+ // look by spreading them and nothing else.
31
+ // 3. **A way to replace any of it.** `stylex.createTheme(ufTokens, { … })`
32
+ // compiles to one class per overridden token; put that class on an ancestor
33
+ // and every `var(--…)` beneath it resolves differently. `./theme.js` ships
34
+ // two, and a project writes its own the same way.
35
+ //
36
+ // It is deliberately *not*: a global reset (uf emits no rule a project did not
37
+ // ask for), a component library (`@uniflowed/ui` ships behaviour and no
38
+ // styles, and must keep doing so — nothing here appears in its types), or a
39
+ // configuration flag. The preset is reached by importing it, so a project that
40
+ // does not import it pays nothing, and one that imports half of it keeps half.
41
+ //
42
+ // # Where the token values come from
43
+ //
44
+ // `@uniflowed/brand` owns uf's visual identity — the palette, the type,
45
+ // spacing and radius scales — and it stays there. It cannot own the token
46
+ // module: a StyleX token's name is computed by the compiler from the binding
47
+ // and key it was declared under, `defineVars` accepts only literals, and
48
+ // brand's `--uf-*` names are hand-written for a different consumer. So brand
49
+ // holds the identity values and `./tokens.stylex.js` is their StyleX-shaped
50
+ // projection into semantic roles — `accent`, `ink`, `canvas` — which is the
51
+ // layer a design system needs and an identity does not have.
52
+ //
53
+ // # Readiness
54
+ //
55
+ // **Implemented.** `props`, `create`, `defineVars`, `createTheme`, pseudo-class
56
+ // and pseudo-element conditions, at-rule conditions (`@media`, `@supports`),
57
+ // shorthand-versus-longhand ordering, unit inference, the preset tokens, the
58
+ // base layer, and the shipped themes. Every one of them is compiled: the
59
+ // runtime half of this package is `props` and three functions that throw.
60
+ //
61
+ // **Experimental.** The preset's own visual choices. The token *names* are the
62
+ // contract and are meant to be stable; the values behind them are uf's opinion
63
+ // and may change within the alpha series. A project that wants them pinned
64
+ // should say so with a theme.
65
+ //
66
+ // **Not there.** `keyframes`, `firstThatWorks` and `positionTry`. Each needs a
67
+ // rule shape the sheet does not have — a named `@keyframes` block, several
68
+ // declarations of one property in one rule, and an `@position-try` block
69
+ // respectively — and the sheet's whole guarantee is that a rule is one class,
70
+ // one declaration, one state, ordered by what it is. Widening that is a change
71
+ // to the ordering model, not an addition to it, so none of the three is
72
+ // half-present: writing any of them is a compile error today, not a call that
73
+ // silently does nothing. A preset does not need them, which is why they are
74
+ // not in the way.
75
+
76
+ import { nativeRuntimeRequired } from "@uniflowed/core/native";
77
+
78
+ import type { CompiledStyle } from "./props.js";
79
+ import { props } from "./props.js";
80
+
81
+ const MODULE = "@uniflowed/stylex";
82
+
83
+ export type { CompiledClasses, CompiledStyle, StyleArgument, StyleProps } from "./props.js";
84
+ export { props } from "./props.js";
85
+
86
+ /** A value a token may hold, and therefore a value a theme may give it. */
87
+ export type ThemeValue = string | number;
88
+
89
+ /**
90
+ * What `createTheme` accepts for one token set.
91
+ *
92
+ * Every key is optional — a theme that changes one colour is a theme — and no
93
+ * key outside the token set is allowed, so a typo is a Flow error rather than a
94
+ * custom property nothing reads. A value may carry states, which is how a theme
95
+ * follows `@media (prefers-color-scheme: dark)` without a second theme.
96
+ */
97
+ export type ThemeOverrides<Tokens extends { readonly [string]: ThemeValue }> = Partial<{
98
+ [Key in keyof Tokens]: ThemeValue | { readonly [state: string]: ThemeValue },
99
+ }>;
100
+
101
+ /**
102
+ * Declare a set of style namespaces.
103
+ *
104
+ * Never runs. `uf transform` replaces the whole call with the object it
105
+ * computed, so reaching this means the module was loaded without going through
106
+ * uf — a bundler configured by hand, a plain `node` invocation — and the styles
107
+ * it declares are in no stylesheet. Throwing says so; returning the input would
108
+ * render an application with no styles and no explanation.
109
+ */
110
+ export function create<T extends { readonly [string]: mixed }>(styles: T): T {
111
+ return nativeRuntimeRequired(MODULE, "stylex.create");
112
+ }
113
+
114
+ /**
115
+ * Declare design tokens, and hand back the `var(--…)` references to them.
116
+ *
117
+ * Compile-time, for the same reason as `create`.
118
+ */
119
+ export function defineVars<T extends { readonly [string]: ThemeValue }>(tokens: T): T {
120
+ return nativeRuntimeRequired(MODULE, "stylex.defineVars");
121
+ }
122
+
123
+ /**
124
+ * Override a set of tokens, and hand back the class that applies the override.
125
+ *
126
+ * The result is a compiled namespace like any other, so it is applied by
127
+ * spreading it — `<div {...props(ufDarkTheme)}>` — and it composes: a second
128
+ * theme merged after the first replaces the tokens it names and leaves the rest
129
+ * alone, because the merge's unit is the property and a token is a property.
130
+ *
131
+ * Compile-time, for the same reason as `create`.
132
+ */
133
+ export function createTheme<
134
+ Tokens extends { readonly [string]: ThemeValue },
135
+ Overrides extends ThemeOverrides<Tokens>,
136
+ >(tokens: Tokens, overrides: Overrides): CompiledStyle {
137
+ return nativeRuntimeRequired(MODULE, "stylex.createTheme");
138
+ }
139
+
140
+ /**
141
+ * The namespace form, so `stylex.create` and `stylex.props` read the way
142
+ * StyleX documents them.
143
+ *
144
+ * The named exports are the ones a bundler can drop individually; this object
145
+ * is for call sites that prefer the qualified spelling, and the compiler
146
+ * recognises both.
147
+ */
148
+ export const stylex: {
149
+ readonly create: typeof create,
150
+ readonly props: typeof props,
151
+ readonly defineVars: typeof defineVars,
152
+ readonly createTheme: typeof createTheme,
153
+ } = {
154
+ create,
155
+ props,
156
+ defineVars,
157
+ createTheme,
158
+ };
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@uniflowed/stylex",
3
+ "version": "0.0.0-alpha.10",
4
+ "description": "Flow declarations for @uniflowed/stylex, part of the Unified Toolchain for Flow.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "sideEffects": false,
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/ubugeeei-prod/uf.git",
11
+ "directory": "packages/stylex"
12
+ },
13
+ "exports": {
14
+ ".": "./index.js",
15
+ "./props": "./props.js",
16
+ "./preset": "./preset.js",
17
+ "./theme": "./theme.js",
18
+ "./tokens.stylex.js": "./tokens.stylex.js"
19
+ },
20
+ "files": [
21
+ "*.js"
22
+ ],
23
+ "dependencies": {
24
+ "@uniflowed/core": "0.0.0-alpha.10"
25
+ }
26
+ }
package/preset.js ADDED
@@ -0,0 +1,486 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/stylex/preset`: the default look, as props you spread.
4
+ //
5
+ // import { buttonStyles, cardStyles } from "@uniflowed/stylex/preset";
6
+ // import { Dialog } from "@uniflowed/ui";
7
+ //
8
+ // <div {...cardStyles()}>
9
+ // <button {...buttonStyles({ tone: "primary" })}>Save</button>
10
+ // </div>
11
+ //
12
+ // This is the second of the three things uf's preset is: a base layer over
13
+ // `./tokens.stylex.js`, so a project has a coherent default look without
14
+ // authoring a declaration. Every function answers with `{ className }` and
15
+ // nothing else, which is why a `@uniflowed/ui` primitive takes them without
16
+ // knowing they exist — `className` is a DOM prop, not a StyleX type, and
17
+ // keeping it that way is what lets the headless package stay headless.
18
+ //
19
+ // # Why this is a separate module
20
+ //
21
+ // It is the only module in the package that has opinions. `props` is a merge,
22
+ // the tokens are names, the themes are values; this is where uf says a card has
23
+ // a 16px radius. A project that wants its own look drops this import and keeps
24
+ // everything else, and a bundler drops the rules with it because nothing else
25
+ // references them.
26
+ //
27
+ // # Why functions rather than exported namespaces
28
+ //
29
+ // A namespace would make the caller responsible for the merge order —
30
+ // `props(button.base, button.primary, button.md)` — and getting that order
31
+ // wrong is silent. The functions own it, and they take the *variants* a caller
32
+ // actually has an opinion about. `match` makes each mapping exhaustive, so a
33
+ // tone added to the type without a rule is a Flow error rather than an
34
+ // unstyled button.
35
+ //
36
+ // # What the merge order here has to get right
37
+ //
38
+ // Every recipe puts `base` first and the variant after it, because the merge's
39
+ // unit is the property and the later argument wins. Two of them rely on that
40
+ // deliberately: `menuItemStyles({ active: true })` replaces the item's
41
+ // `backgroundColor` *including* its `:hover` state, which is what a selected
42
+ // row should do, and `buttonStyles({ disabled: true })` replaces the tone's
43
+ // cursor. Neither works if the arguments are swapped, and the compile-time
44
+ // model in `crates/uf_stylex/src/props.rs` is where that is pinned down.
45
+
46
+ // `stylex` comes from the package's own name because that is the specifier the
47
+ // compiler resolves a StyleX binding from; `props` and its types come from the
48
+ // module that owns them, which is the same module either way.
49
+ import { stylex } from "@uniflowed/stylex";
50
+
51
+ import type { StyleProps } from "./props.js";
52
+ import { props } from "./props.js";
53
+ import { ufTokens } from "./tokens.stylex.js";
54
+
55
+ /** How loud a control is, and therefore what it is for. */
56
+ export type Tone = "primary" | "neutral" | "ghost" | "danger";
57
+
58
+ /** The three sizes every control in the preset comes in. */
59
+ export type Size = "sm" | "md" | "lg";
60
+
61
+ /** How much a surface is lifted off the page. */
62
+ export type SurfaceKind = "page" | "card" | "panel" | "sunken";
63
+
64
+ /** Which of the type scale's steps a piece of text sits on. */
65
+ export type TextSize = "xs" | "sm" | "md" | "lg" | "xl" | "2xl";
66
+
67
+ /** What a piece of text is: body copy, a secondary note, or an error. */
68
+ export type TextTone = "ink" | "muted" | "danger";
69
+
70
+ const surfaces = stylex.create({
71
+ base: {
72
+ color: ufTokens.ink,
73
+ fontFamily: ufTokens.fontSans,
74
+ fontSize: ufTokens.textMd,
75
+ lineHeight: ufTokens.leadingBase,
76
+ },
77
+ page: {
78
+ backgroundColor: ufTokens.canvas,
79
+ },
80
+ card: {
81
+ backgroundColor: ufTokens.surface,
82
+ borderWidth: "1px",
83
+ borderStyle: "solid",
84
+ borderColor: ufTokens.border,
85
+ borderRadius: ufTokens.radiusLg,
86
+ padding: ufTokens.space6,
87
+ boxShadow: ufTokens.shadowCard,
88
+ },
89
+ panel: {
90
+ backgroundColor: ufTokens.surface,
91
+ borderRadius: ufTokens.radiusXl,
92
+ padding: ufTokens.space6,
93
+ boxShadow: ufTokens.shadowPanel,
94
+ },
95
+ sunken: {
96
+ backgroundColor: ufTokens.sunken,
97
+ borderRadius: ufTokens.radiusMd,
98
+ padding: ufTokens.space4,
99
+ },
100
+ });
101
+
102
+ const texts = stylex.create({
103
+ base: {
104
+ fontFamily: ufTokens.fontSans,
105
+ lineHeight: ufTokens.leadingBase,
106
+ margin: 0,
107
+ },
108
+ xs: { fontSize: ufTokens.textXs },
109
+ sm: { fontSize: ufTokens.textSm },
110
+ md: { fontSize: ufTokens.textMd },
111
+ lg: { fontSize: ufTokens.textLg, lineHeight: ufTokens.leadingTight },
112
+ xl: { fontSize: ufTokens.textXl, lineHeight: ufTokens.leadingTight },
113
+ xxl: { fontSize: ufTokens.text2Xl, lineHeight: ufTokens.leadingTight },
114
+ ink: { color: ufTokens.ink },
115
+ muted: { color: ufTokens.muted },
116
+ danger: { color: ufTokens.danger },
117
+ strong: { fontWeight: ufTokens.weightBold },
118
+ });
119
+
120
+ const buttons = stylex.create({
121
+ base: {
122
+ display: "inline-flex",
123
+ alignItems: "center",
124
+ justifyContent: "center",
125
+ gap: ufTokens.space2,
126
+ fontFamily: ufTokens.fontSans,
127
+ fontWeight: ufTokens.weightMedium,
128
+ lineHeight: ufTokens.leadingTight,
129
+ borderRadius: ufTokens.radiusMd,
130
+ borderWidth: "1px",
131
+ borderStyle: "solid",
132
+ borderColor: "transparent",
133
+ cursor: "pointer",
134
+ transitionProperty: "background-color, border-color, color",
135
+ transitionDuration: ufTokens.durationFast,
136
+ transitionTimingFunction: ufTokens.easing,
137
+ // The ring is drawn only for a keyboard focus, which is the whole reason
138
+ // `:focus-visible` exists: a mouse click should not light the control up.
139
+ outlineWidth: { default: "0", ":focus-visible": "2px" },
140
+ outlineStyle: "solid",
141
+ outlineColor: ufTokens.focus,
142
+ outlineOffset: "2px",
143
+ },
144
+ primary: {
145
+ backgroundColor: { default: ufTokens.accent, ":hover": ufTokens.accentHover },
146
+ color: ufTokens.accentInk,
147
+ },
148
+ neutral: {
149
+ backgroundColor: { default: ufTokens.surface, ":hover": ufTokens.surfaceHover },
150
+ borderColor: ufTokens.border,
151
+ color: ufTokens.ink,
152
+ },
153
+ ghost: {
154
+ backgroundColor: { default: "transparent", ":hover": ufTokens.surfaceHover },
155
+ color: ufTokens.ink,
156
+ },
157
+ danger: {
158
+ backgroundColor: { default: ufTokens.danger, ":hover": ufTokens.dangerHover },
159
+ color: ufTokens.dangerInk,
160
+ },
161
+ sm: {
162
+ fontSize: ufTokens.textSm,
163
+ paddingBlock: ufTokens.space1,
164
+ paddingInline: ufTokens.space3,
165
+ },
166
+ md: {
167
+ fontSize: ufTokens.textSm,
168
+ paddingBlock: ufTokens.space2,
169
+ paddingInline: ufTokens.space4,
170
+ },
171
+ lg: {
172
+ fontSize: ufTokens.textMd,
173
+ paddingBlock: ufTokens.space3,
174
+ paddingInline: ufTokens.space6,
175
+ },
176
+ disabled: {
177
+ opacity: 0.55,
178
+ cursor: "not-allowed",
179
+ },
180
+ });
181
+
182
+ const fields = stylex.create({
183
+ base: {
184
+ display: "block",
185
+ width: "100%",
186
+ fontFamily: ufTokens.fontSans,
187
+ fontSize: ufTokens.textSm,
188
+ lineHeight: ufTokens.leadingBase,
189
+ color: ufTokens.ink,
190
+ backgroundColor: ufTokens.surface,
191
+ borderWidth: "1px",
192
+ borderStyle: "solid",
193
+ borderColor: ufTokens.border,
194
+ borderRadius: ufTokens.radiusSm,
195
+ paddingBlock: ufTokens.space2,
196
+ paddingInline: ufTokens.space3,
197
+ outlineWidth: { default: "0", ":focus-visible": "2px" },
198
+ outlineStyle: "solid",
199
+ outlineColor: ufTokens.focus,
200
+ outlineOffset: "1px",
201
+ },
202
+ invalid: {
203
+ borderColor: ufTokens.danger,
204
+ backgroundColor: ufTokens.dangerSoft,
205
+ },
206
+ disabled: {
207
+ backgroundColor: ufTokens.sunken,
208
+ color: ufTokens.muted,
209
+ cursor: "not-allowed",
210
+ },
211
+ });
212
+
213
+ const overlays = stylex.create({
214
+ backdrop: {
215
+ position: "fixed",
216
+ inset: 0,
217
+ backgroundColor: ufTokens.scrim,
218
+ },
219
+ panel: {
220
+ position: "fixed",
221
+ top: "50%",
222
+ left: "50%",
223
+ transform: "translate(-50%, -50%)",
224
+ width: "calc(100% - 32px)",
225
+ maxWidth: "32rem",
226
+ backgroundColor: ufTokens.surface,
227
+ color: ufTokens.ink,
228
+ fontFamily: ufTokens.fontSans,
229
+ borderRadius: ufTokens.radiusXl,
230
+ padding: ufTokens.space6,
231
+ boxShadow: ufTokens.shadowPanel,
232
+ },
233
+ });
234
+
235
+ const menus = stylex.create({
236
+ list: {
237
+ minWidth: "12rem",
238
+ margin: 0,
239
+ padding: ufTokens.space1,
240
+ listStyle: "none",
241
+ backgroundColor: ufTokens.surface,
242
+ borderWidth: "1px",
243
+ borderStyle: "solid",
244
+ borderColor: ufTokens.border,
245
+ borderRadius: ufTokens.radiusMd,
246
+ boxShadow: ufTokens.shadowPanel,
247
+ },
248
+ item: {
249
+ display: "flex",
250
+ alignItems: "center",
251
+ gap: ufTokens.space2,
252
+ width: "100%",
253
+ paddingBlock: ufTokens.space2,
254
+ paddingInline: ufTokens.space3,
255
+ borderRadius: ufTokens.radiusSm,
256
+ fontFamily: ufTokens.fontSans,
257
+ fontSize: ufTokens.textSm,
258
+ textAlign: "start",
259
+ color: ufTokens.ink,
260
+ backgroundColor: { default: "transparent", ":hover": ufTokens.surfaceHover },
261
+ cursor: "pointer",
262
+ },
263
+ active: {
264
+ backgroundColor: ufTokens.accentSoft,
265
+ color: ufTokens.accent,
266
+ },
267
+ disabled: {
268
+ color: ufTokens.muted,
269
+ cursor: "not-allowed",
270
+ },
271
+ });
272
+
273
+ const tabs = stylex.create({
274
+ list: {
275
+ display: "flex",
276
+ gap: ufTokens.space1,
277
+ borderBottomWidth: "1px",
278
+ borderBottomStyle: "solid",
279
+ borderBottomColor: ufTokens.border,
280
+ },
281
+ tab: {
282
+ // `borderWidth: 0` before `borderBottomWidth: "2px"` is the case the sheet's
283
+ // ordering exists for: both are one class selector, and the longhand only
284
+ // survives because the shorthand is emitted before it.
285
+ borderWidth: 0,
286
+ borderStyle: "solid",
287
+ borderColor: "transparent",
288
+ borderBottomWidth: "2px",
289
+ backgroundColor: "transparent",
290
+ paddingBlock: ufTokens.space2,
291
+ paddingInline: ufTokens.space3,
292
+ fontFamily: ufTokens.fontSans,
293
+ fontSize: ufTokens.textSm,
294
+ fontWeight: ufTokens.weightMedium,
295
+ color: { default: ufTokens.muted, ":hover": ufTokens.ink },
296
+ cursor: "pointer",
297
+ },
298
+ selected: {
299
+ color: ufTokens.ink,
300
+ borderBottomColor: ufTokens.accent,
301
+ },
302
+ });
303
+
304
+ const controls = stylex.create({
305
+ base: {
306
+ display: "inline-flex",
307
+ alignItems: "center",
308
+ justifyContent: "center",
309
+ flexShrink: 0,
310
+ padding: 0,
311
+ borderWidth: "1px",
312
+ borderStyle: "solid",
313
+ borderColor: ufTokens.border,
314
+ backgroundColor: ufTokens.surface,
315
+ color: ufTokens.accentInk,
316
+ cursor: "pointer",
317
+ transitionProperty: "background-color, border-color",
318
+ transitionDuration: ufTokens.durationFast,
319
+ transitionTimingFunction: ufTokens.easing,
320
+ outlineWidth: { default: "0", ":focus-visible": "2px" },
321
+ outlineStyle: "solid",
322
+ outlineColor: ufTokens.focus,
323
+ outlineOffset: "2px",
324
+ },
325
+ box: {
326
+ width: ufTokens.sizeControl,
327
+ height: ufTokens.sizeControl,
328
+ borderRadius: "5px",
329
+ },
330
+ // A switch's track is the one piece of geometry here that is not a token:
331
+ // nothing but a switch reads it, and a theme that changed it would leave the
332
+ // thumb somewhere else.
333
+ track: {
334
+ width: "36px",
335
+ height: "20px",
336
+ justifyContent: "flex-start",
337
+ borderRadius: ufTokens.radiusPill,
338
+ },
339
+ on: {
340
+ backgroundColor: ufTokens.accent,
341
+ borderColor: ufTokens.accent,
342
+ },
343
+ disabled: {
344
+ backgroundColor: ufTokens.sunken,
345
+ cursor: "not-allowed",
346
+ opacity: 0.6,
347
+ },
348
+ });
349
+
350
+ /** A page or a card, with the preset's type already on it. */
351
+ export function surfaceStyles(options?: { readonly kind?: SurfaceKind }): StyleProps {
352
+ const kind = options?.kind ?? "card";
353
+ return props(
354
+ surfaces.base,
355
+ match (kind) {
356
+ "page" => surfaces.page,
357
+ "card" => surfaces.card,
358
+ "panel" => surfaces.panel,
359
+ "sunken" => surfaces.sunken,
360
+ },
361
+ );
362
+ }
363
+
364
+ /** A card: the surface most application chrome is made of. */
365
+ export function cardStyles(): StyleProps {
366
+ return surfaceStyles({ kind: "card" });
367
+ }
368
+
369
+ /** One step of the type scale, in one of the three text roles. */
370
+ export function textStyles(options?: {
371
+ readonly size?: TextSize,
372
+ readonly tone?: TextTone,
373
+ readonly strong?: boolean,
374
+ }): StyleProps {
375
+ const size = options?.size ?? "md";
376
+ const tone = options?.tone ?? "ink";
377
+ return props(
378
+ texts.base,
379
+ match (size) {
380
+ "xs" => texts.xs,
381
+ "sm" => texts.sm,
382
+ "md" => texts.md,
383
+ "lg" => texts.lg,
384
+ "xl" => texts.xl,
385
+ "2xl" => texts.xxl,
386
+ },
387
+ match (tone) {
388
+ "ink" => texts.ink,
389
+ "muted" => texts.muted,
390
+ "danger" => texts.danger,
391
+ },
392
+ options?.strong === true && texts.strong,
393
+ );
394
+ }
395
+
396
+ /** A button, in one of four tones and three sizes. */
397
+ export function buttonStyles(options?: {
398
+ readonly tone?: Tone,
399
+ readonly size?: Size,
400
+ readonly disabled?: boolean,
401
+ }): StyleProps {
402
+ const tone = options?.tone ?? "neutral";
403
+ const size = options?.size ?? "md";
404
+ return props(
405
+ buttons.base,
406
+ match (tone) {
407
+ "primary" => buttons.primary,
408
+ "neutral" => buttons.neutral,
409
+ "ghost" => buttons.ghost,
410
+ "danger" => buttons.danger,
411
+ },
412
+ match (size) {
413
+ "sm" => buttons.sm,
414
+ "md" => buttons.md,
415
+ "lg" => buttons.lg,
416
+ },
417
+ options?.disabled === true && buttons.disabled,
418
+ );
419
+ }
420
+
421
+ /** A text input, a select, or anything else that takes typing. */
422
+ export function fieldStyles(options?: {
423
+ readonly invalid?: boolean,
424
+ readonly disabled?: boolean,
425
+ }): StyleProps {
426
+ return props(
427
+ fields.base,
428
+ options?.invalid === true && fields.invalid,
429
+ options?.disabled === true && fields.disabled,
430
+ );
431
+ }
432
+
433
+ /** The wash behind a modal surface. */
434
+ export function backdropStyles(): StyleProps {
435
+ return props(overlays.backdrop);
436
+ }
437
+
438
+ /** A centred modal panel. */
439
+ export function dialogStyles(): StyleProps {
440
+ return props(overlays.panel);
441
+ }
442
+
443
+ /** The box a menu's options sit in. */
444
+ export function menuStyles(): StyleProps {
445
+ return props(menus.list);
446
+ }
447
+
448
+ /** One option in a menu. */
449
+ export function menuItemStyles(options?: {
450
+ readonly active?: boolean,
451
+ readonly disabled?: boolean,
452
+ }): StyleProps {
453
+ return props(
454
+ menus.item,
455
+ options?.active === true && menus.active,
456
+ options?.disabled === true && menus.disabled,
457
+ );
458
+ }
459
+
460
+ /** The row a set of tabs sits in. */
461
+ export function tabListStyles(): StyleProps {
462
+ return props(tabs.list);
463
+ }
464
+
465
+ /** One tab, selected or not. */
466
+ export function tabStyles(options?: { readonly selected?: boolean }): StyleProps {
467
+ return props(tabs.tab, options?.selected === true && tabs.selected);
468
+ }
469
+
470
+ /** A checkbox's box or a switch's track. */
471
+ export function controlStyles(options?: {
472
+ readonly shape?: "box" | "track",
473
+ readonly on?: boolean,
474
+ readonly disabled?: boolean,
475
+ }): StyleProps {
476
+ const shape = options?.shape ?? "box";
477
+ return props(
478
+ controls.base,
479
+ match (shape) {
480
+ "box" => controls.box,
481
+ "track" => controls.track,
482
+ },
483
+ options?.on === true && controls.on,
484
+ options?.disabled === true && controls.disabled,
485
+ );
486
+ }
package/props.js ADDED
@@ -0,0 +1,147 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/stylex/props`: the merge, and the only part of StyleX that runs.
4
+ //
5
+ // `uf transform` rewrites every `stylex.create({ … })` and
6
+ // `stylex.createTheme(tokens, { … })` into a plain object of class names, so by
7
+ // the time this module is loaded there are no style values left. What is left
8
+ // is the merge, and it is here because it cannot be anywhere else: a call site
9
+ // writes `active && styles.on`, and a compiler cannot fold a value it does not
10
+ // know.
11
+ //
12
+ // # This file has a twin
13
+ //
14
+ // The same merge is modelled in `crates/uf_stylex/src/props.rs`, at compile
15
+ // time, which is what lets its ordering be tested against the sheet the
16
+ // compiler emits. The two have to agree, and they agree on the two things that
17
+ // are observable:
18
+ //
19
+ // * **which classes survive.** The *property* is the unit of merging — a later
20
+ // namespace that sets `color` replaces everything an earlier one said about
21
+ // `color`, its `:hover` value included. That is what a later `color:` in a
22
+ // stylesheet does, and it is why a later namespace cannot leave a stray
23
+ // hover state behind.
24
+ // * **that every class of a surviving property survives.** A property written
25
+ // with states compiles to a *map* — `{ "default": "x1", ":hover": "x2" }` —
26
+ // and both classes belong in the class attribute. A runtime that kept only
27
+ // the first, or skipped the map because it is not a string, would silently
28
+ // drop every conditional style in an application while every test that used
29
+ // plain values kept passing.
30
+ //
31
+ // They deliberately do not agree on the *order* of the class attribute, and
32
+ // nothing depends on it: the compiler sorts globally by sheet position because
33
+ // it has the priorities to hand, this file emits in the order properties were
34
+ // first claimed because it does not. Which rule wins is decided by the sheet,
35
+ // never by the attribute, so the two orders render identically. Within one
36
+ // property the orders do match, because the compiler writes that property's
37
+ // states in sheet order and this file reads them in the order it finds them.
38
+
39
+ /** The class names one property sets, keyed by the state each applies in. */
40
+ export type CompiledClasses = { readonly [state: string]: string | null };
41
+
42
+ /**
43
+ * A compiled style namespace.
44
+ *
45
+ * `$$css` marks an object the compiler produced. Every other key is a CSS
46
+ * property — or a custom property, for a theme — mapped to the class name that
47
+ * sets it, to a map of class names when the property has states, or to `null`,
48
+ * which is how a namespace says it deliberately unsets that property.
49
+ */
50
+ export type CompiledStyle = {
51
+ readonly $$css: true,
52
+ readonly [property: string]: string | null | true | CompiledClasses,
53
+ };
54
+
55
+ /** What a call site may pass: a namespace, something falsy, or a list. */
56
+ export type StyleArgument = mixed;
57
+
58
+ /** What `props` hands to an element. */
59
+ export type StyleProps = { readonly className?: string };
60
+
61
+ /** What one property contributed, once the merge has picked a winner. */
62
+ type Winner = string | null | CompiledClasses;
63
+
64
+ /**
65
+ * Merge compiled namespaces into a `className`, left to right.
66
+ *
67
+ * Falsy arguments are skipped, because `active && styles.on` is the idiom this
68
+ * function exists for, and arrays are flattened so a list built elsewhere can
69
+ * be passed without spreading it.
70
+ *
71
+ * Returns an object rather than a string so the call site stays
72
+ * `<div {...stylex.props(a, b)} />` — the same shape whether or not anything
73
+ * survived.
74
+ */
75
+ export function props(...styles: $ReadOnlyArray<StyleArgument>): StyleProps {
76
+ const winners: { [string]: Winner } = {};
77
+ collect(styles, winners);
78
+
79
+ let className = "";
80
+ for (const property of Object.keys(winners)) {
81
+ const winner = winners[property];
82
+ // `null` is a deliberate unset: the property has an owner, and that owner
83
+ // said there should be no class for it.
84
+ if (winner == null) {
85
+ continue;
86
+ }
87
+ if (typeof winner === "string") {
88
+ className = join(className, winner);
89
+ continue;
90
+ }
91
+ // A property with states. Every one of its classes belongs in the
92
+ // attribute: the base rule and the `:hover` rule are separate rules in the
93
+ // sheet, and dropping either is a style that never applies.
94
+ for (const state of Object.keys(winner)) {
95
+ const name = winner[state];
96
+ if (name != null) {
97
+ className = join(className, name);
98
+ }
99
+ }
100
+ }
101
+
102
+ return className === "" ? {} : { className };
103
+ }
104
+
105
+ /**
106
+ * Fold arguments into `winners`, flattening arrays.
107
+ *
108
+ * Insertion order is the order a property was *first* claimed, and assigning
109
+ * over an existing key does not move it — so two namespaces that both set
110
+ * `color` produce one entry in the position the first one had. The class list
111
+ * is a function of the properties involved, not of how many namespaces
112
+ * mentioned them.
113
+ */
114
+ function collect(styles: $ReadOnlyArray<StyleArgument>, winners: { [string]: Winner }): void {
115
+ for (const style of styles) {
116
+ if (style == null || style === false || style === true) {
117
+ continue;
118
+ }
119
+ if (Array.isArray(style)) {
120
+ collect(style, winners);
121
+ continue;
122
+ }
123
+ if (typeof style !== "object") {
124
+ continue;
125
+ }
126
+ // The one place an untyped value enters. This object was written by `uf
127
+ // transform`, and its shape is the compiler's promise rather than something
128
+ // Flow can see from a call site; everything below reads it through
129
+ // `CompiledStyle`, so the trust boundary is this line and no wider.
130
+ const namespace: CompiledStyle = style as $FlowFixMe;
131
+ for (const property of Object.keys(namespace)) {
132
+ const value = namespace[property];
133
+ // `$$css` is the marker, not a property. Skipping it by value as well as
134
+ // by name means a plain object that happens to carry `true` somewhere
135
+ // cannot put `true` into a class attribute.
136
+ if (property === "$$css" || value === true) {
137
+ continue;
138
+ }
139
+ winners[property] = value;
140
+ }
141
+ }
142
+ }
143
+
144
+ /** Append one class name to a space-separated attribute. */
145
+ function join(className: string, name: string): string {
146
+ return className === "" ? name : className + " " + name;
147
+ }
package/theme.js ADDED
@@ -0,0 +1,112 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/stylex/theme`: the shipped themes, and how to replace them.
4
+ //
5
+ // import { props } from "@uniflowed/stylex";
6
+ // import { ufAutoTheme } from "@uniflowed/stylex/theme";
7
+ //
8
+ // <body {...props(ufAutoTheme)}>…</body>
9
+ //
10
+ // This is the third of the three things uf's preset is, and the one that makes
11
+ // the other two a default rather than a decoration. A theme is a set of
12
+ // overrides for `./tokens.stylex.js`; `uf transform` turns each override into
13
+ // one class whose rule writes that token's custom property, so putting the
14
+ // theme's class on an ancestor changes what every `var(--…)` beneath it
15
+ // resolves to. No rule in `preset.js` knows a theme happened.
16
+ //
17
+ // # Two themes, because there are two questions
18
+ //
19
+ // [`ufAutoTheme`] answers "follow the reader's operating system". It overrides
20
+ // nothing at all in light mode — every entry is written *only* under
21
+ // `@media (prefers-color-scheme: dark)`, so outside dark mode its class matches
22
+ // no rule and the tokens keep their declared values. That is why it costs one
23
+ // media block rather than two full sets of custom properties.
24
+ //
25
+ // [`ufDarkTheme`] answers "this subtree is dark, whatever the system says": a
26
+ // preview pane, a code surface, a reader who chose. Its overrides are
27
+ // unconditional, so it also works as the thing a stored preference applies.
28
+ //
29
+ // Both can be composed, and the merge decides the same way it decides anything
30
+ // else: `props(ufAutoTheme, ufDarkTheme)` is dark, because the later argument
31
+ // wins token by token.
32
+ //
33
+ // # Writing your own
34
+ //
35
+ // `createTheme` takes the token set and a subset of its keys, and Flow rejects
36
+ // a key the set does not declare — so a renamed token is a type error rather
37
+ // than a custom property nothing reads:
38
+ //
39
+ // export const brandTheme = stylex.createTheme(ufTokens, {
40
+ // accent: "#0f766e",
41
+ // accentHover: "#115e59",
42
+ // });
43
+ //
44
+ // Overriding four colours is a complete theme; there is no requirement to
45
+ // restate the set. Two themes that give the same token the same value compile
46
+ // to the same class and one rule, wherever they were written.
47
+ //
48
+ // # Contrast
49
+ //
50
+ // The dark values below meet WCAG AA at 4.5:1 for every pair the preset
51
+ // actually pairs, and `crates/uf_stylex/src/tests/preset.rs` computes those
52
+ // ratios from the compiled stylesheet.
53
+
54
+ import { stylex } from "@uniflowed/stylex";
55
+
56
+ import { ufTokens } from "./tokens.stylex.js";
57
+
58
+ /**
59
+ * The preset, following the reader's operating system.
60
+ *
61
+ * Every entry is conditional, so this theme is inert in light mode and costs
62
+ * exactly one `@media (prefers-color-scheme: dark)` block.
63
+ */
64
+ export const ufAutoTheme = stylex.createTheme(ufTokens, {
65
+ canvas: { "@media (prefers-color-scheme: dark)": "#0b1220" },
66
+ sunken: { "@media (prefers-color-scheme: dark)": "#0f1726" },
67
+ surface: { "@media (prefers-color-scheme: dark)": "#131c2e" },
68
+ surfaceHover: { "@media (prefers-color-scheme: dark)": "#1b2540" },
69
+ border: { "@media (prefers-color-scheme: dark)": "#26324a" },
70
+ ink: { "@media (prefers-color-scheme: dark)": "#e8eefc" },
71
+ muted: { "@media (prefers-color-scheme: dark)": "#9fb0cc" },
72
+ accent: { "@media (prefers-color-scheme: dark)": "#8f8bff" },
73
+ accentHover: { "@media (prefers-color-scheme: dark)": "#a49fff" },
74
+ accentInk: { "@media (prefers-color-scheme: dark)": "#0b1220" },
75
+ accentSoft: { "@media (prefers-color-scheme: dark)": "#1e2547" },
76
+ danger: { "@media (prefers-color-scheme: dark)": "#ff6b5e" },
77
+ dangerHover: { "@media (prefers-color-scheme: dark)": "#ff8378" },
78
+ dangerInk: { "@media (prefers-color-scheme: dark)": "#2a0b08" },
79
+ dangerSoft: { "@media (prefers-color-scheme: dark)": "#2a1512" },
80
+ focus: { "@media (prefers-color-scheme: dark)": "#7fb0ff" },
81
+ scrim: { "@media (prefers-color-scheme: dark)": "rgba(2, 6, 16, 0.62)" },
82
+ shadowCard: { "@media (prefers-color-scheme: dark)": "0 1px 2px rgba(0, 0, 0, 0.40)" },
83
+ shadowPanel: { "@media (prefers-color-scheme: dark)": "0 16px 48px rgba(0, 0, 0, 0.55)" },
84
+ });
85
+
86
+ /**
87
+ * The preset in dark, unconditionally.
88
+ *
89
+ * For a subtree that is dark whatever the system says, and for the stored
90
+ * preference a reader chose.
91
+ */
92
+ export const ufDarkTheme = stylex.createTheme(ufTokens, {
93
+ canvas: "#0b1220",
94
+ sunken: "#0f1726",
95
+ surface: "#131c2e",
96
+ surfaceHover: "#1b2540",
97
+ border: "#26324a",
98
+ ink: "#e8eefc",
99
+ muted: "#9fb0cc",
100
+ accent: "#8f8bff",
101
+ accentHover: "#a49fff",
102
+ accentInk: "#0b1220",
103
+ accentSoft: "#1e2547",
104
+ danger: "#ff6b5e",
105
+ dangerHover: "#ff8378",
106
+ dangerInk: "#2a0b08",
107
+ dangerSoft: "#2a1512",
108
+ focus: "#7fb0ff",
109
+ scrim: "rgba(2, 6, 16, 0.62)",
110
+ shadowCard: "0 1px 2px rgba(0, 0, 0, 0.40)",
111
+ shadowPanel: "0 16px 48px rgba(0, 0, 0, 0.55)",
112
+ });
@@ -0,0 +1,114 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/stylex/tokens.stylex.js`: the tokens a project gets for free.
4
+ //
5
+ // This is an ordinary `stylex.defineVars` module — the compiler binds it by its
6
+ // `.stylex.js` suffix like any other — and it is the first of the three things
7
+ // uf's preset is. Import it and every name below is a `var(--…)` the build
8
+ // already declared on `:root`; nothing here is computed in a browser, and a
9
+ // project that never imports it ships none of it.
10
+ //
11
+ // # Roles, not colours
12
+ //
13
+ // The names are what a token is *for*, never what it looks like. `accent` is
14
+ // the colour a primary action wears; it is indigo today and a theme can make it
15
+ // anything, and no rule in `preset.js` has to change when it does. A token
16
+ // named `indigo500` would have made the preset unthemeable the moment someone
17
+ // wanted a green product.
18
+ //
19
+ // The values are uf's identity, and `@uniflowed/brand` is where that identity
20
+ // is decided: `accent`, `ink`, `canvas` and `muted` are brand's Indigo, Ink,
21
+ // Mist and Slate, and the type, spacing and radius steps are brand's scales.
22
+ // Brand cannot hold the token module itself — a StyleX token's name is computed
23
+ // by the compiler, and `defineVars` takes literals, not an imported array — so
24
+ // this module is brand's projection into the semantic roles a design system
25
+ // needs and an identity does not have.
26
+ //
27
+ // # What is not a token
28
+ //
29
+ // Geometry that exactly one control uses. A switch's track is 36×20 because
30
+ // that is what a switch is, and promoting it to a token would invite a theme to
31
+ // change it into something the thumb no longer fits. A token is a decision more
32
+ // than one rule reads.
33
+ //
34
+ // # Contrast
35
+ //
36
+ // Every foreground/background pair the preset actually pairs meets WCAG AA at
37
+ // 4.5:1, in both the light default and the shipped dark theme, and
38
+ // `crates/uf_stylex/src/tests/preset.rs` computes the ratios from the compiled
39
+ // stylesheet rather than trusting this comment.
40
+
41
+ import { stylex } from "@uniflowed/stylex";
42
+
43
+ // Deliberately unannotated: `defineVars` hands back exactly what it was given,
44
+ // so the inferred type is the token set itself — which is what makes
45
+ // `ThemeOverrides<typeof ufTokens>` reject a token this module does not declare.
46
+ export const ufTokens = stylex.defineVars({
47
+ // Surfaces, from furthest back to nearest front.
48
+ canvas: "#f8fafc",
49
+ sunken: "#eef2f7",
50
+ surface: "#ffffff",
51
+ surfaceHover: "#f1f5f9",
52
+ border: "#dbe3ec",
53
+
54
+ // Text.
55
+ ink: "#0f172a",
56
+ muted: "#475569",
57
+
58
+ // The colour a primary action wears, and what is legible on it.
59
+ accent: "#5c49ff",
60
+ accentHover: "#4a37f0",
61
+ accentInk: "#ffffff",
62
+ accentSoft: "#eeecff",
63
+
64
+ // The colour a destructive action wears.
65
+ danger: "#b42318",
66
+ dangerHover: "#9a1c12",
67
+ dangerInk: "#ffffff",
68
+ dangerSoft: "#fef3f2",
69
+
70
+ // Focus ring, and the wash behind a modal surface.
71
+ focus: "#2677ff",
72
+ scrim: "rgba(15, 23, 42, 0.48)",
73
+
74
+ // Type.
75
+ fontSans: "ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif",
76
+ fontMono: "ui-monospace, SFMono-Regular, Menlo, monospace",
77
+ textXs: "12px",
78
+ textSm: "14px",
79
+ textMd: "16px",
80
+ textLg: "20px",
81
+ textXl: "28px",
82
+ text2Xl: "40px",
83
+ leadingTight: 1.25,
84
+ leadingBase: 1.55,
85
+ weightRegular: 400,
86
+ weightMedium: 500,
87
+ weightBold: 700,
88
+
89
+ // Space.
90
+ space1: "4px",
91
+ space2: "8px",
92
+ space3: "12px",
93
+ space4: "16px",
94
+ space6: "24px",
95
+ space8: "32px",
96
+ space12: "48px",
97
+
98
+ // Shape.
99
+ radiusSm: "8px",
100
+ radiusMd: "12px",
101
+ radiusLg: "16px",
102
+ radiusXl: "24px",
103
+ radiusPill: "999px",
104
+
105
+ // The size of a control a finger or a pointer aims at.
106
+ sizeControl: "18px",
107
+
108
+ // Elevation and motion.
109
+ shadowCard: "0 1px 2px rgba(15, 23, 42, 0.06), 0 1px 3px rgba(15, 23, 42, 0.10)",
110
+ shadowPanel: "0 16px 48px rgba(15, 23, 42, 0.24)",
111
+ durationFast: "120ms",
112
+ durationBase: "200ms",
113
+ easing: "cubic-bezier(0.2, 0, 0, 1)",
114
+ });