@shiftbloom-studio/circadian-ui 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -4,13 +4,17 @@
4
4
  [![CI](https://github.com/shiftbloom-studio/circadian-ui/actions/workflows/ci.yml/badge.svg)](https://github.com/shiftbloom-studio/circadian-ui/actions/workflows/ci.yml)
5
5
  [![license](https://img.shields.io/npm/l/@shiftbloom-studio/circadian-ui)](LICENSE)
6
6
 
7
- Automatic, accessible, Tailwind-friendly time-of-day theming for React and Next.js. Circadian UI adapts your design tokens based on local time, optional sunrise/sunset data, system preferences, and user overrides — all while keeping contrast WCAG-conscious.
7
+ **Circadian UI** is a production‑ready, time‑aware theming engine for React and Tailwind. It adapts your design tokens across dawn/day/dusk/night based on local time, optional sunrise/sunset data, system preferences, and user overrides — while enforcing accessible contrast.
8
8
 
9
- ## Why this matters
9
+ ## What makes it special
10
10
 
11
- - **Readable at any hour**: avoid low-contrast screens at night or overly-bright palettes at dawn.
12
- - **Reduced eye strain**: thoughtful shifts in luminance and contrast help users stay comfortable.
13
- - **Consistent branding**: keep your token system intact while letting Circadian UI handle timing and accessibility.
11
+ - **Zero‑config start** install, wrap your app, and you’re done.
12
+ - **Circadian magic** automatic phase shifts based on time or sun data.
13
+ - **Accessible by default** WCAG‑conscious contrast adjustments.
14
+ - **Framework‑friendly** — Next.js (App/Pages), Vite, SSR or CSR.
15
+ - **Tailwind‑native** — tokens exposed as CSS variables + preset/plugin.
16
+
17
+ ---
14
18
 
15
19
  ## Install
16
20
 
@@ -18,7 +22,9 @@ Automatic, accessible, Tailwind-friendly time-of-day theming for React and Next.
18
22
  npm install @shiftbloom-studio/circadian-ui
19
23
  ```
20
24
 
21
- ## Quickstart (React)
25
+ ---
26
+
27
+ ## 30‑second quickstart (React)
22
28
 
23
29
  ```tsx
24
30
  import { CircadianProvider, CircadianScript } from "@shiftbloom-studio/circadian-ui";
@@ -33,7 +39,13 @@ export function App({ children }: { children: React.ReactNode }) {
33
39
  }
34
40
  ```
35
41
 
36
- ## Next.js (App Router)
42
+ > `CircadianScript` prevents theme flash by setting the initial phase before hydration.
43
+
44
+ ---
45
+
46
+ ## Next.js
47
+
48
+ ### App Router
37
49
 
38
50
  ```tsx
39
51
  // app/layout.tsx
@@ -52,7 +64,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
52
64
  }
53
65
  ```
54
66
 
55
- ## Next.js (Pages Router)
67
+ ### Pages Router
56
68
 
57
69
  ```tsx
58
70
  // pages/_document.tsx
@@ -76,9 +88,30 @@ export default class MyDocument extends Document {
76
88
  }
77
89
  ```
78
90
 
79
- ## Tailwind setup
91
+ ---
92
+
93
+ ## Vite / CSR apps
94
+
95
+ ```tsx
96
+ import { createRoot } from "react-dom/client";
97
+ import { CircadianProvider, CircadianScript } from "@shiftbloom-studio/circadian-ui";
98
+ import App from "./App";
99
+
100
+ createRoot(document.getElementById("root")!).render(
101
+ <>
102
+ <CircadianScript />
103
+ <CircadianProvider>
104
+ <App />
105
+ </CircadianProvider>
106
+ </>
107
+ );
108
+ ```
109
+
110
+ ---
80
111
 
81
- ### CSS variables only (recommended)
112
+ ## Tailwind integration
113
+
114
+ ### Option A — CSS variables (recommended)
82
115
 
83
116
  ```ts
84
117
  // tailwind.config.ts
@@ -109,7 +142,7 @@ const config: Config = {
109
142
  export default config;
110
143
  ```
111
144
 
112
- ### Tailwind preset + plugin
145
+ ### Option B — Preset + Plugin
113
146
 
114
147
  ```ts
115
148
  // tailwind.config.ts
@@ -125,9 +158,11 @@ const config: Config = {
125
158
  export default config;
126
159
  ```
127
160
 
161
+ ---
162
+
128
163
  ## Configuration examples
129
164
 
130
- ### Custom time windows
165
+ ### 1) Custom schedule windows
131
166
 
132
167
  ```tsx
133
168
  <CircadianProvider
@@ -144,46 +179,60 @@ export default config;
144
179
  </CircadianProvider>
145
180
  ```
146
181
 
147
- ### Manual override UI
182
+ ### 2) Sun‑aware schedule
183
+
184
+ ```ts
185
+ import type { SunTimesProvider } from "@shiftbloom-studio/circadian-ui";
186
+
187
+ const provider: SunTimesProvider = (date) => {
188
+ return {
189
+ sunrise: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 6, 12),
190
+ sunset: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 19, 48)
191
+ };
192
+ };
193
+
194
+ <CircadianProvider config={{ mode: "sun", sunTimesProvider: provider }} />;
195
+ ```
196
+
197
+ ### 3) Auto mode (recommended)
198
+
199
+ ```tsx
200
+ <CircadianProvider config={{ mode: "auto", sunTimesProvider: provider }} />
201
+ ```
202
+
203
+ If sun data is available, it uses `sun`; otherwise it falls back to `time`.
204
+
205
+ ### 4) Manual override UI
148
206
 
149
207
  ```tsx
150
208
  import { useCircadian } from "@shiftbloom-studio/circadian-ui";
151
209
 
152
210
  const ModeToggle = () => {
153
- const { mode, setMode, setPhaseOverride } = useCircadian();
211
+ const { mode, resolvedMode, setMode, setPhaseOverride } = useCircadian();
154
212
  return (
155
213
  <div>
156
- <button onClick={() => setMode("time")}>Auto</button>
214
+ <button onClick={() => setMode("auto")}>Auto</button>
157
215
  <button onClick={() => setPhaseOverride("night")}>Night</button>
158
- <span>Current mode: {mode}</span>
216
+ <p>Requested: {mode}</p>
217
+ <p>Resolved: {resolvedMode}</p>
159
218
  </div>
160
219
  );
161
220
  };
162
221
  ```
163
222
 
164
- ### Sun-times provider
165
-
166
- ```ts
167
- import type { SunTimesProvider } from "@shiftbloom-studio/circadian-ui";
168
-
169
- const provider: SunTimesProvider = (date) => {
170
- // Plug in your own sunrise/sunset provider
171
- return {
172
- sunrise: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 6, 12),
173
- sunset: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 19, 48)
174
- };
175
- };
223
+ ### 5) Initial phase hint (SSR)
176
224
 
177
- <CircadianProvider config={{ mode: "sun", sunTimesProvider: provider }} />;
225
+ ```tsx
226
+ <CircadianScript config={{ initialPhase: "night" }} />
178
227
  ```
179
228
 
180
- ### Disable persistence
229
+ ### 6) Disable persistence
181
230
 
182
231
  ```tsx
183
232
  <CircadianProvider config={{ persist: false }} />
184
233
  ```
185
234
 
186
- ### Strict contrast mode
235
+ ### 7) Strict contrast tuning
187
236
 
188
237
  ```tsx
189
238
  <CircadianProvider
@@ -196,9 +245,7 @@ const provider: SunTimesProvider = (date) => {
196
245
  />
197
246
  ```
198
247
 
199
- ## Accessibility notes
200
-
201
- Circadian UI enforces WCAG-conscious contrast by default. Foreground tokens are nudged in lightness until they meet the configured ratio. You can tune ratios for normal and large text via `accessibility.minimumRatio` and `accessibility.largeTextRatio`.
248
+ ---
202
249
 
203
250
  ## Design tokens
204
251
 
@@ -217,15 +264,17 @@ Circadian UI enforces WCAG-conscious contrast by default. Foreground tokens are
217
264
  | Destructive | `--cui-destructive` |
218
265
  | Destructive Foreground | `--cui-destructive-fg` |
219
266
 
267
+ ---
268
+
220
269
  ## API reference
221
270
 
222
271
  ### React
223
272
 
224
273
  - `CircadianProvider`
225
274
  - Props: `{ config?: CircadianConfig; children: React.ReactNode }`
226
- - Sets `data-cui-phase` and CSS vars on the document root.
275
+ - Applies phase + tokens to the document root (or body).
227
276
  - `useCircadian()`
228
- - Returns `{ phase, mode, setMode, setPhaseOverride, clearOverride, tokens, isAuto, nextChangeAt }`.
277
+ - Returns `{ phase, mode, resolvedMode, setMode, setPhaseOverride, clearOverride, tokens, isAuto, nextChangeAt }`.
229
278
  - `useCircadianTokens()`
230
279
  - Returns `{ tokens, cssVars, applyToStyle }` for inline usage.
231
280
  - `CircadianScript`
@@ -245,15 +294,18 @@ Circadian UI enforces WCAG-conscious contrast by default. Foreground tokens are
245
294
  - `circadianTailwindPreset()`
246
295
  - `circadianPlugin()` (wrap with `tailwindcss/plugin`)
247
296
 
297
+ ---
298
+
248
299
  ## Configuration schema
249
300
 
250
301
  ```ts
251
302
  interface CircadianConfig {
252
303
  schedule?: Partial<CircadianSchedule>;
253
304
  tokens?: Partial<Record<Phase, Partial<CircadianTokens>>>;
254
- mode?: "time" | "sun" | "manual";
305
+ mode?: "time" | "sun" | "manual" | "auto";
255
306
  sunTimesProvider?: SunTimesProvider;
256
307
  sunSchedule?: Partial<SunScheduleOptions>;
308
+ initialPhase?: Phase;
257
309
  persist?: boolean;
258
310
  storageKey?: string;
259
311
  accessibility?: Partial<AccessibilityOptions>;
@@ -264,6 +316,14 @@ interface CircadianConfig {
264
316
  }
265
317
  ```
266
318
 
319
+ ---
320
+
321
+ ## Accessibility notes
322
+
323
+ Circadian UI nudges foreground tokens until they meet your configured contrast ratio. You can tune ratios for normal and large text via `accessibility.minimumRatio` and `accessibility.largeTextRatio`.
324
+
325
+ ---
326
+
267
327
  ## Development
268
328
 
269
329
  ```bash
package/dist/index.cjs CHANGED
@@ -56,21 +56,24 @@ var isWithinRange = (minutes, start, end) => {
56
56
  }
57
57
  return minutes >= start || minutes < end;
58
58
  };
59
- var getPhaseFromTime = (date, schedule) => {
60
- const minutes = getMinutesFromDate(date);
61
- const normalized = normalizeSchedule(schedule);
59
+ var getPhaseFromMinutes = (minutes, schedule) => {
62
60
  const phases = ["dawn", "day", "dusk", "night"];
63
61
  for (const phase of phases) {
64
- const window2 = normalized[phase];
62
+ const window2 = schedule[phase];
65
63
  if (isWithinRange(minutes, window2.start, window2.end)) {
66
64
  return phase;
67
65
  }
68
66
  }
69
67
  return "night";
70
68
  };
69
+ var getPhaseFromTime = (date, schedule) => {
70
+ const minutes = getMinutesFromDate(date);
71
+ const normalized = normalizeSchedule(schedule);
72
+ return getPhaseFromMinutes(minutes, normalized);
73
+ };
71
74
  var computeNextTransition = (date, schedule) => {
72
75
  const normalized = normalizeSchedule(schedule);
73
- const currentPhase = getPhaseFromTime(date, schedule);
76
+ const currentPhase = getPhaseFromMinutes(getMinutesFromDate(date), normalized);
74
77
  const minutes = getMinutesFromDate(date);
75
78
  const endMinutes = normalized[currentPhase].end;
76
79
  let delta = endMinutes - minutes;
@@ -79,6 +82,16 @@ var computeNextTransition = (date, schedule) => {
79
82
  }
80
83
  return new Date(date.getTime() + delta * 60 * 1e3);
81
84
  };
85
+ var computeNextTransitionFromMinutes = (date, schedule) => {
86
+ const currentPhase = getPhaseFromMinutes(getMinutesFromDate(date), schedule);
87
+ const minutes = getMinutesFromDate(date);
88
+ const endMinutes = schedule[currentPhase].end;
89
+ let delta = endMinutes - minutes;
90
+ if (delta <= 0) {
91
+ delta += minutesInDay;
92
+ }
93
+ return new Date(date.getTime() + delta * 60 * 1e3);
94
+ };
82
95
 
83
96
  // src/core/sun.ts
84
97
  var minutesInDay2 = 24 * 60;
@@ -124,6 +137,10 @@ var getPhaseFromSunTimes = (date, sunTimes, options) => {
124
137
  }
125
138
  return "night";
126
139
  };
140
+ var computeNextSunTransition = (date, sunTimes, options) => {
141
+ const schedule = deriveSunSchedule(date, sunTimes, options);
142
+ return computeNextTransitionFromMinutes(date, schedule);
143
+ };
127
144
  var getScheduleFromProvider = (date, provider, options) => {
128
145
  if (!provider) {
129
146
  return null;
@@ -471,7 +488,7 @@ var defaultTransition = {
471
488
  durationMs: 200
472
489
  };
473
490
  var resolveMode = (userMode, system, config) => {
474
- return userMode ?? config?.mode ?? "time";
491
+ return userMode ?? config?.mode ?? "auto";
475
492
  };
476
493
  var resolveAccessibility = (system, config) => {
477
494
  const base = { ...defaultAccessibility, ...config?.accessibility };
@@ -496,11 +513,13 @@ var getMergedSchedule = (schedule) => ({
496
513
  dusk: { ...defaultSchedule.dusk, ...schedule?.dusk },
497
514
  night: { ...defaultSchedule.night, ...schedule?.night }
498
515
  });
499
- var getMode = (mode) => mode ?? "time";
516
+ var getMode = (mode) => mode ?? "auto";
500
517
  var createInlineScript = (config) => {
501
518
  const schedule = getMergedSchedule(config?.schedule);
502
519
  const storageKey = config?.storageKey ?? defaultStorageKey;
503
520
  const persist = config?.persist !== false;
521
+ const initialPhase = config?.initialPhase ?? null;
522
+ const setAttributeOn = config?.setAttributeOn ?? "html";
504
523
  const tokens = {
505
524
  dawn: tokensToCssVars({
506
525
  ...defaultTokens.dawn,
@@ -526,6 +545,8 @@ var createInlineScript = (config) => {
526
545
  const storageKey = ${serialize(storageKey)};
527
546
  const persist = ${serialize(persist)};
528
547
  const fallbackMode = ${serialize(getMode(config?.mode))};
548
+ const initialPhase = ${serialize(initialPhase)};
549
+ const setAttributeOn = ${serialize(setAttributeOn)};
529
550
  const now = new Date();
530
551
  const minutes = now.getHours() * 60 + now.getMinutes();
531
552
 
@@ -548,12 +569,14 @@ var createInlineScript = (config) => {
548
569
  };
549
570
 
550
571
  const order = ["dawn", "day", "dusk", "night"];
551
- let phase = "night";
552
- for (const key of order) {
553
- const window = normalized[key];
554
- if (isWithin(minutes, window.start, window.end)) {
555
- phase = key;
556
- break;
572
+ let phase = initialPhase || "night";
573
+ if (!initialPhase) {
574
+ for (const key of order) {
575
+ const window = normalized[key];
576
+ if (isWithin(minutes, window.start, window.end)) {
577
+ phase = key;
578
+ break;
579
+ }
557
580
  }
558
581
  }
559
582
 
@@ -569,7 +592,8 @@ var createInlineScript = (config) => {
569
592
  }
570
593
  }
571
594
 
572
- const root = document.documentElement;
595
+ const root =
596
+ setAttributeOn === "body" && document.body ? document.body : document.documentElement;
573
597
  root.setAttribute("data-cui-phase", phase);
574
598
  const vars = tokens[phase] || tokens.night;
575
599
  for (const key in vars) {
@@ -618,27 +642,21 @@ var getInitialSystemPreferences = () => {
618
642
  }
619
643
  return getSystemPreferences();
620
644
  };
621
- var computePhase = (date, mode, config, phaseOverride) => {
645
+ var computePhase = (date, mode, config, phaseOverride, sunTimes) => {
622
646
  if (mode === "manual" && phaseOverride) {
623
647
  return phaseOverride;
624
648
  }
625
- if (mode === "sun") {
626
- const sunTimes = config.sunTimesProvider?.(date);
627
- if (sunTimes) {
628
- return getPhaseFromSunTimes(date, sunTimes, config.sunSchedule);
629
- }
649
+ if (mode === "sun" && sunTimes) {
650
+ return getPhaseFromSunTimes(date, sunTimes, config.sunSchedule);
630
651
  }
631
652
  return getPhaseFromTime(date, config.schedule);
632
653
  };
633
- var computeNextChange = (date, mode, config) => {
654
+ var computeNextChange = (date, mode, config, sunTimes) => {
634
655
  if (mode === "manual") {
635
656
  return null;
636
657
  }
637
- if (mode === "sun") {
638
- const schedule = getScheduleFromProvider(date, config.sunTimesProvider, config.sunSchedule);
639
- if (schedule) {
640
- return computeNextTransition(date, schedule);
641
- }
658
+ if (mode === "sun" && sunTimes) {
659
+ return computeNextSunTransition(date, sunTimes, config.sunSchedule);
642
660
  }
643
661
  return computeNextTransition(date, config.schedule);
644
662
  };
@@ -648,17 +666,18 @@ var CircadianProvider = ({ config = {}, children }) => {
648
666
  const [systemPrefs, setSystemPrefs] = react.useState(getInitialSystemPreferences);
649
667
  const initialPersisted = typeof window !== "undefined" && shouldPersist ? loadPersistedState(storageKey) : null;
650
668
  const [mode, setModeState] = react.useState(
651
- initialPersisted?.mode ?? config.mode ?? "time"
669
+ initialPersisted?.mode ?? config.mode ?? "auto"
652
670
  );
653
671
  const [phaseOverride, setPhaseOverrideState] = react.useState(
654
672
  initialPersisted?.phase ?? null
655
673
  );
656
674
  const [phase, setPhase] = react.useState(
657
- () => computePhase(/* @__PURE__ */ new Date(), mode, config, phaseOverride)
675
+ () => config.initialPhase ?? computePhase(/* @__PURE__ */ new Date(), mode, config, phaseOverride, null)
658
676
  );
659
677
  const [nextChangeAt, setNextChangeAt] = react.useState(
660
- () => computeNextChange(/* @__PURE__ */ new Date(), mode, config)
678
+ () => computeNextChange(/* @__PURE__ */ new Date(), mode, config, null)
661
679
  );
680
+ const [resolvedMode, setResolvedMode] = react.useState(mode);
662
681
  const timerRef = react.useRef(null);
663
682
  const accessibility = react.useMemo(
664
683
  () => resolveAccessibility(systemPrefs, config),
@@ -684,18 +703,42 @@ var CircadianProvider = ({ config = {}, children }) => {
684
703
  systemOptions.respectColorScheme,
685
704
  colorBias
686
705
  ]);
706
+ const resolveModeWithSun = react.useCallback(
707
+ (date) => {
708
+ const desiredMode = resolveMode(mode, systemPrefs, config);
709
+ if (desiredMode === "auto") {
710
+ if (config.sunTimesProvider) {
711
+ const sunTimes = config.sunTimesProvider(date);
712
+ if (sunTimes) {
713
+ return { resolvedMode: "sun", sunTimes };
714
+ }
715
+ }
716
+ return { resolvedMode: "time", sunTimes: null };
717
+ }
718
+ if (desiredMode === "sun") {
719
+ const sunTimes = config.sunTimesProvider?.(date) ?? null;
720
+ if (!sunTimes) {
721
+ return { resolvedMode: "time", sunTimes: null };
722
+ }
723
+ return { resolvedMode: "sun", sunTimes };
724
+ }
725
+ return { resolvedMode: desiredMode, sunTimes: null };
726
+ },
727
+ [mode, systemPrefs, config]
728
+ );
687
729
  const updatePhase = react.useCallback(() => {
688
730
  const now = /* @__PURE__ */ new Date();
689
- const resolvedMode = resolveMode(mode, systemPrefs, config);
690
- const nextPhase = computePhase(now, resolvedMode, config, phaseOverride);
731
+ const { resolvedMode: resolvedMode2, sunTimes } = resolveModeWithSun(now);
732
+ const nextPhase = computePhase(now, resolvedMode2, config, phaseOverride, sunTimes);
733
+ setResolvedMode(resolvedMode2);
691
734
  setPhase(nextPhase);
692
- setNextChangeAt(computeNextChange(now, resolvedMode, config));
693
- }, [mode, systemPrefs, config, phaseOverride]);
735
+ setNextChangeAt(computeNextChange(now, resolvedMode2, config, sunTimes));
736
+ }, [config, phaseOverride, resolveModeWithSun]);
694
737
  react.useEffect(() => {
695
738
  updatePhase();
696
739
  }, [updatePhase]);
697
740
  react.useEffect(() => {
698
- if (mode === "manual") {
741
+ if (resolvedMode === "manual") {
699
742
  return;
700
743
  }
701
744
  if (timerRef.current) {
@@ -713,7 +756,7 @@ var CircadianProvider = ({ config = {}, children }) => {
713
756
  window.clearTimeout(timerRef.current);
714
757
  }
715
758
  };
716
- }, [mode, nextChangeAt, updatePhase]);
759
+ }, [resolvedMode, nextChangeAt, updatePhase]);
717
760
  react.useEffect(() => {
718
761
  const root = getRootElement(config.setAttributeOn);
719
762
  if (!root || !root.style) {
@@ -768,8 +811,8 @@ var CircadianProvider = ({ config = {}, children }) => {
768
811
  }, []);
769
812
  const clearOverride = react.useCallback(() => {
770
813
  setPhaseOverrideState(null);
771
- setModeState("time");
772
- }, []);
814
+ setModeState(config.mode ?? "auto");
815
+ }, [config.mode]);
773
816
  const contextValue = react.useMemo(
774
817
  () => ({
775
818
  phase,
@@ -779,9 +822,10 @@ var CircadianProvider = ({ config = {}, children }) => {
779
822
  setMode,
780
823
  setPhaseOverride,
781
824
  clearOverride,
782
- isAuto: mode !== "manual"
825
+ isAuto: mode !== "manual",
826
+ resolvedMode
783
827
  }),
784
- [phase, mode, tokens, nextChangeAt, setMode, setPhaseOverride, clearOverride]
828
+ [phase, mode, tokens, nextChangeAt, setMode, setPhaseOverride, clearOverride, resolvedMode]
785
829
  );
786
830
  return /* @__PURE__ */ jsxRuntime.jsx(CircadianContext.Provider, { value: contextValue, children });
787
831
  };
@@ -854,7 +898,9 @@ exports.applyTokensToElement = applyTokensToElement;
854
898
  exports.circadianPlugin = circadianPlugin;
855
899
  exports.circadianTailwindPreset = circadianTailwindPreset;
856
900
  exports.clearPersistedState = clearPersistedState;
901
+ exports.computeNextSunTransition = computeNextSunTransition;
857
902
  exports.computeNextTransition = computeNextTransition;
903
+ exports.computeNextTransitionFromMinutes = computeNextTransitionFromMinutes;
858
904
  exports.contrastRatio = contrastRatio;
859
905
  exports.createInlineScript = createInlineScript;
860
906
  exports.cssVarMap = cssVarMap;
@@ -869,6 +915,7 @@ exports.defaultTransition = defaultTransition;
869
915
  exports.deriveSunSchedule = deriveSunSchedule;
870
916
  exports.ensureContrast = ensureContrast;
871
917
  exports.getMinutesFromDate = getMinutesFromDate;
918
+ exports.getPhaseFromMinutes = getPhaseFromMinutes;
872
919
  exports.getPhaseFromSunTimes = getPhaseFromSunTimes;
873
920
  exports.getPhaseFromTime = getPhaseFromTime;
874
921
  exports.getScheduleFromProvider = getScheduleFromProvider;