@djangocfg/ui-core 2.1.418 → 2.1.420

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.
@@ -0,0 +1,5 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ export { BalancedText } from './BalancedText';
4
+ export type { BalancedTextProps, BalancedFont } from './types';
5
+ export { DEFAULT_BALANCED_FONT, DEFAULT_BALANCED_MAX_WIDTH } from './types';
@@ -0,0 +1,59 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ import type { ComponentPropsWithoutRef, ElementType, ReactNode } from 'react';
4
+
5
+ /**
6
+ * CSS font shorthand for Pretext measurement.
7
+ *
8
+ * Must match the actually rendered font, otherwise the balanced width will be
9
+ * off. When `font` is omitted, the component reads `getComputedStyle()` on the
10
+ * element after mount and falls back to {@link DEFAULT_BALANCED_FONT} on the
11
+ * server.
12
+ */
13
+ export type BalancedFont = string;
14
+
15
+ export interface BalancedTextOwnProps {
16
+ /** Text to balance. Required as a string — pretext measures runs of glyphs, not React trees. */
17
+ children: string;
18
+ /** CSS font shorthand (`'16px Inter, sans-serif'`). When omitted, reads `getComputedStyle`. */
19
+ font?: BalancedFont;
20
+ /**
21
+ * Hard cap on width in px. Balanced width never exceeds this. When omitted,
22
+ * the parent container's measured `clientWidth` is used. `maxLines` cannot
23
+ * make the text wider than this.
24
+ * @default 600
25
+ */
26
+ maxWidth?: number;
27
+ /**
28
+ * Cap on number of lines. When set, the balanced width is the *narrowest*
29
+ * width that still keeps ≤ `maxLines` lines (instead of preserving the
30
+ * natural line count at `maxWidth`). Useful for headings.
31
+ */
32
+ maxLines?: number;
33
+ /** Element to render as. @default 'span' */
34
+ as?: ElementType;
35
+ }
36
+
37
+ /** Props for {@link BalancedText}. */
38
+ export type BalancedTextProps = BalancedTextOwnProps &
39
+ Omit<ComponentPropsWithoutRef<'span'>, keyof BalancedTextOwnProps | 'children'> & {
40
+ children: string;
41
+ };
42
+
43
+ /** Fallback used on SSR and when `font` is not provided + measurement hasn't run. */
44
+ export const DEFAULT_BALANCED_FONT: BalancedFont =
45
+ '16px ui-sans-serif, system-ui, -apple-system, sans-serif';
46
+
47
+ export const DEFAULT_BALANCED_MAX_WIDTH = 600;
48
+
49
+ /** Internal: BalancedText render shape (used by subcomponents). */
50
+ export interface InternalRenderProps {
51
+ Tag: ElementType;
52
+ balancedWidth: number;
53
+ hasMeasured: boolean;
54
+ text: string;
55
+ className?: string;
56
+ style?: React.CSSProperties;
57
+ rest: Record<string, unknown>;
58
+ textNode: ReactNode;
59
+ }
@@ -187,6 +187,8 @@ export { CircularProgress, CircularProgressCombined, CircularProgressIndicator,
187
187
  export type { CircularProgressProps } from './data/circular-progress';
188
188
  export { RelativeTimeCard, relativeTimeCardVariants } from './data/relative-time-card';
189
189
  export type { RelativeTimeCardProps } from './data/relative-time-card';
190
+ export { BalancedText, DEFAULT_BALANCED_FONT, DEFAULT_BALANCED_MAX_WIDTH } from './data/BalancedText';
191
+ export type { BalancedTextProps, BalancedFont } from './data/BalancedText';
190
192
 
191
193
  // ─────────────────────────────────────────────────────────────────────────────
192
194
  // Chart Components
@@ -12,5 +12,7 @@ export {
12
12
  export type { ScrollSnapshot, ScrollDirection, ScrollTarget } from './useScroll';
13
13
  export { useLayoutEffect } from './useLayoutEffect';
14
14
  export { useSize } from './useSize';
15
+ export { useResizeObserver } from './useResizeObserver';
16
+ export type { Size } from './useResizeObserver';
15
17
  export { useFormReset } from './useFormReset';
16
18
  export type { UseFormResetParams } from './useFormReset';
@@ -0,0 +1,117 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+ //
3
+ // Shared ResizeObserver store with refcounting.
4
+ //
5
+ // Why a shared observer (vs one per `useSize`/`useResizeObserver` call):
6
+ // - A single ResizeObserver instance is cheaper than N when many
7
+ // components subscribe (charts, sparklines, masonry items, …).
8
+ // - Avoids "ResizeObserver loop completed with undelivered notifications"
9
+ // warnings that pile up when many tiny observers fire in the same frame.
10
+ //
11
+ // The store keeps one global RO; per-element listener sets are reference
12
+ // counted so the observer detaches from elements once nobody listens.
13
+ //
14
+ // Inspired by the pattern at activity-graph.tsx:180-188 in jalcoui.
15
+
16
+ 'use client';
17
+
18
+ import * as React from 'react';
19
+ import { useLayoutEffect } from './useLayoutEffect';
20
+
21
+ export interface Size {
22
+ width: number;
23
+ height: number;
24
+ }
25
+
26
+ type Listener = (size: Size) => void;
27
+
28
+ interface Entry {
29
+ listeners: Set<Listener>;
30
+ last: Size;
31
+ }
32
+
33
+ let sharedObserver: ResizeObserver | null = null;
34
+ const entries = new WeakMap<Element, Entry>();
35
+ // Mirror keyset so we can resolve elements from RO entries.
36
+ const elementByEntry = new WeakMap<Element, Entry>();
37
+
38
+ function getObserver(): ResizeObserver {
39
+ if (sharedObserver) return sharedObserver;
40
+ sharedObserver = new ResizeObserver((roEntries) => {
41
+ for (const roEntry of roEntries) {
42
+ const el = roEntry.target;
43
+ const entry = elementByEntry.get(el);
44
+ if (!entry) continue;
45
+
46
+ let width: number;
47
+ let height: number;
48
+ const box = (roEntry as ResizeObserverEntry).borderBoxSize;
49
+ if (box) {
50
+ const b = Array.isArray(box) ? box[0] : box;
51
+ width = b.inlineSize;
52
+ height = b.blockSize;
53
+ } else {
54
+ width = (el as HTMLElement).offsetWidth;
55
+ height = (el as HTMLElement).offsetHeight;
56
+ }
57
+
58
+ entry.last = { width, height };
59
+ for (const listener of entry.listeners) listener(entry.last);
60
+ }
61
+ });
62
+ return sharedObserver;
63
+ }
64
+
65
+ function subscribe(element: Element, listener: Listener): () => void {
66
+ const observer = getObserver();
67
+ let entry = entries.get(element);
68
+ if (!entry) {
69
+ entry = {
70
+ listeners: new Set<Listener>(),
71
+ last: {
72
+ width: (element as HTMLElement).offsetWidth ?? 0,
73
+ height: (element as HTMLElement).offsetHeight ?? 0,
74
+ },
75
+ };
76
+ entries.set(element, entry);
77
+ elementByEntry.set(element, entry);
78
+ observer.observe(element);
79
+ }
80
+ entry.listeners.add(listener);
81
+ // Deliver current size synchronously so consumers don't render with 0.
82
+ listener(entry.last);
83
+
84
+ return () => {
85
+ const e = entries.get(element);
86
+ if (!e) return;
87
+ e.listeners.delete(listener);
88
+ if (e.listeners.size === 0) {
89
+ observer.unobserve(element);
90
+ entries.delete(element);
91
+ }
92
+ };
93
+ }
94
+
95
+ /**
96
+ * Observe an element's border-box size via a shared ResizeObserver.
97
+ *
98
+ * Returns `{ width: 0, height: 0 }` until the first measurement lands.
99
+ * Pass either a ref or a callback-style ref result.
100
+ *
101
+ * @example
102
+ * const ref = React.useRef<HTMLDivElement>(null);
103
+ * const { width, height } = useResizeObserver(ref);
104
+ */
105
+ export function useResizeObserver<T extends Element = Element>(
106
+ ref: React.RefObject<T | null>,
107
+ ): Size {
108
+ const [size, setSize] = React.useState<Size>({ width: 0, height: 0 });
109
+
110
+ useLayoutEffect(() => {
111
+ const el = ref.current;
112
+ if (!el) return;
113
+ return subscribe(el, setSize);
114
+ }, [ref]);
115
+
116
+ return size;
117
+ }
package/src/lib/index.ts CHANGED
@@ -15,3 +15,4 @@ export * from "./compose-refs";
15
15
  export * from "./get-element-ref";
16
16
  export * from "./compose-event-handlers";
17
17
  export { createContext } from "./create-context";
18
+ export { getIntensity } from "./intensity";
@@ -0,0 +1,40 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+ //
3
+ // Quantize a numeric value into a discrete intensity bucket. Used by
4
+ // heatmaps (activity-graph), gauges, and any thermal-style visualization
5
+ // that needs N colored steps.
6
+ //
7
+ // `thresholds` are upper bounds in ascending order, expressed as fractions
8
+ // of `max` or as absolute values — caller picks. The returned bucket index
9
+ // is in `[0, thresholds.length]`.
10
+ //
11
+ // Inspired by `getIntensity` at activity-graph.tsx:72-80.
12
+
13
+ /**
14
+ * @param value Current value.
15
+ * @param thresholds Ascending upper bounds. The returned bucket is the
16
+ * index of the first threshold `value` does not exceed,
17
+ * or `thresholds.length` if it exceeds them all.
18
+ * @returns Bucket index in `[0, thresholds.length]`. `0` for non-positive
19
+ * values (treated as "empty").
20
+ *
21
+ * @example
22
+ * getIntensity(0, [0.25, 0.5, 0.75]) // 0 (empty)
23
+ * getIntensity(0.1, [0.25, 0.5, 0.75]) // 1
24
+ * getIntensity(0.5, [0.25, 0.5, 0.75]) // 2
25
+ * getIntensity(0.8, [0.25, 0.5, 0.75]) // 3
26
+ * getIntensity(1.0, [0.25, 0.5, 0.75]) // 3
27
+ *
28
+ * @example
29
+ * // Quantize commits/day into 4 buckets relative to the busiest day:
30
+ * const max = Math.max(...counts);
31
+ * const ratio = count / max;
32
+ * const step = getIntensity(ratio, [0.25, 0.5, 0.75]); // 0..3
33
+ */
34
+ export function getIntensity(value: number, thresholds: number[]): number {
35
+ if (!(value > 0)) return 0;
36
+ for (let i = 0; i < thresholds.length; i++) {
37
+ if (value <= thresholds[i]) return i + 1;
38
+ }
39
+ return thresholds.length;
40
+ }
@@ -0,0 +1,18 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+
3
+ export {
4
+ usePretext,
5
+ usePretextWithSegments,
6
+ usePretextLayout,
7
+ usePretextLines,
8
+ useShrinkwrap,
9
+ useBalancedWidth,
10
+ } from './use-pretext';
11
+
12
+ export type {
13
+ PreparedText,
14
+ PreparedTextWithSegments,
15
+ LayoutResult,
16
+ LayoutLinesResult,
17
+ PrepareOptions,
18
+ } from './use-pretext';
@@ -0,0 +1,72 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+ //
3
+ // Local mirror of the @chenglou/pretext public API. The runtime dependency
4
+ // is declared `optional` (see ui-tools/package.json), so consumers that
5
+ // never use BalancedText / pretext-powered tools don't pay for it. To keep
6
+ // `pnpm check` green without the dependency installed, we redeclare just
7
+ // the types we touch — no `import type from '@chenglou/pretext'`.
8
+ //
9
+ // Mirrors the public surface as of @chenglou/pretext 0.0.7. If the upstream
10
+ // types drift, update here.
11
+
12
+ export interface PrepareOptions {
13
+ whiteSpace?: 'normal' | 'pre' | 'pre-wrap' | 'pre-line';
14
+ }
15
+
16
+ // Opaque handles — internal shape doesn't matter to consumers of the hooks.
17
+ // We brand them so the two flavours don't get accidentally swapped.
18
+ export interface PreparedText {
19
+ readonly __brand: 'PreparedText';
20
+ }
21
+
22
+ export interface PreparedTextWithSegments {
23
+ readonly __brand: 'PreparedTextWithSegments';
24
+ }
25
+
26
+ export interface LayoutResult {
27
+ lineCount: number;
28
+ height: number;
29
+ }
30
+
31
+ export interface LayoutLine {
32
+ text: string;
33
+ width: number;
34
+ start: number;
35
+ end: number;
36
+ }
37
+
38
+ export interface LayoutLinesResult {
39
+ lineCount: number;
40
+ height: number;
41
+ lines: LayoutLine[];
42
+ }
43
+
44
+ export interface WalkLineRange {
45
+ width: number;
46
+ start: number;
47
+ end: number;
48
+ }
49
+
50
+ export interface PretextModule {
51
+ prepare(text: string, font: string, options?: PrepareOptions): PreparedText;
52
+ prepareWithSegments(
53
+ text: string,
54
+ font: string,
55
+ options?: PrepareOptions,
56
+ ): PreparedTextWithSegments;
57
+ layout(
58
+ prepared: PreparedText,
59
+ maxWidth: number,
60
+ lineHeight: number,
61
+ ): LayoutResult;
62
+ layoutWithLines(
63
+ prepared: PreparedTextWithSegments,
64
+ maxWidth: number,
65
+ lineHeight: number,
66
+ ): LayoutLinesResult;
67
+ walkLineRanges(
68
+ prepared: PreparedTextWithSegments,
69
+ maxWidth: number,
70
+ visit: (line: WalkLineRange) => void,
71
+ ): void;
72
+ }
@@ -0,0 +1,211 @@
1
+ // Adapted from jalcoui (MIT) — github.com/jal-co/ui
2
+ //
3
+ // React hooks wrapping @chenglou/pretext for DOM-free text measurement.
4
+ // Provides prepare/layout lifecycle management, shrinkwrap search,
5
+ // and line-balanced width computation.
6
+ //
7
+ // Powered by Pretext by Cheng Lou — github.com/chenglou/pretext
8
+ //
9
+ // `@chenglou/pretext` is declared as an optionalDependency in
10
+ // `packages/ui-tools/package.json` — consumers that never use BalancedText
11
+ // (or other pretext-based tools) can install ui-tools without it. The
12
+ // `getPretext()` helper uses a CommonJS `require()` so the optional
13
+ // dependency is resolved lazily at first use; if it is missing, the
14
+ // `require` throws at call site, never at module-load.
15
+
16
+ 'use client';
17
+
18
+ import * as React from 'react';
19
+ import type {
20
+ PreparedText,
21
+ PreparedTextWithSegments,
22
+ LayoutResult,
23
+ LayoutLinesResult,
24
+ PrepareOptions,
25
+ PretextModule,
26
+ } from './pretext.types';
27
+
28
+ export type {
29
+ PreparedText,
30
+ PreparedTextWithSegments,
31
+ LayoutResult,
32
+ LayoutLinesResult,
33
+ PrepareOptions,
34
+ };
35
+
36
+ const isBrowser = typeof window !== 'undefined';
37
+
38
+ let cachedPretext: PretextModule | null = null;
39
+
40
+ function getPretext(): PretextModule {
41
+ if (cachedPretext) return cachedPretext;
42
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
43
+ cachedPretext = require('@chenglou/pretext') as PretextModule;
44
+ return cachedPretext;
45
+ }
46
+
47
+ const EMPTY_LAYOUT: LayoutResult = { lineCount: 0, height: 0 };
48
+ const EMPTY_LINES: LayoutLinesResult = { lineCount: 0, height: 0, lines: [] };
49
+
50
+ /**
51
+ * Prepare text for Pretext measurement. Runs `prepare()` once and caches
52
+ * the result until `text`, `font`, or `options` change.
53
+ *
54
+ * The returned handle is opaque — pass it to `usePretextLayout`.
55
+ */
56
+ export function usePretext(
57
+ text: string,
58
+ font: string,
59
+ options?: PrepareOptions,
60
+ ): PreparedText | null {
61
+ const whiteSpace = options?.whiteSpace ?? 'normal';
62
+
63
+ return React.useMemo(() => {
64
+ if (!isBrowser) return null;
65
+ return getPretext().prepare(text, font, options);
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }, [text, font, whiteSpace]);
68
+ }
69
+
70
+ /**
71
+ * Prepare text with segment data for advanced layout (line-by-line rendering,
72
+ * shrinkwrap, balancing). Same as `usePretext` but returns the richer handle.
73
+ */
74
+ export function usePretextWithSegments(
75
+ text: string,
76
+ font: string,
77
+ options?: PrepareOptions,
78
+ ): PreparedTextWithSegments | null {
79
+ const whiteSpace = options?.whiteSpace ?? 'normal';
80
+
81
+ return React.useMemo(() => {
82
+ if (!isBrowser) return null;
83
+ return getPretext().prepareWithSegments(text, font, options);
84
+ // eslint-disable-next-line react-hooks/exhaustive-deps
85
+ }, [text, font, whiteSpace]);
86
+ }
87
+
88
+ /**
89
+ * Layout prepared text at a given width and line height. Pure arithmetic —
90
+ * no DOM reads. Returns line count and total height.
91
+ *
92
+ * Re-runs on every `maxWidth` or `lineHeight` change (~0.0002ms).
93
+ */
94
+ export function usePretextLayout(
95
+ prepared: PreparedText | null,
96
+ maxWidth: number,
97
+ lineHeight: number,
98
+ ): LayoutResult {
99
+ return React.useMemo(() => {
100
+ if (!prepared) return EMPTY_LAYOUT;
101
+ return getPretext().layout(prepared, maxWidth, lineHeight);
102
+ }, [prepared, maxWidth, lineHeight]);
103
+ }
104
+
105
+ /**
106
+ * Layout prepared text and return full line data (text, width, cursors).
107
+ * Heavier than `usePretextLayout` — use when you need per-line info
108
+ * for custom rendering.
109
+ */
110
+ export function usePretextLines(
111
+ prepared: PreparedTextWithSegments | null,
112
+ maxWidth: number,
113
+ lineHeight: number,
114
+ ): LayoutLinesResult {
115
+ return React.useMemo(() => {
116
+ if (!prepared) return EMPTY_LINES;
117
+ return getPretext().layoutWithLines(prepared, maxWidth, lineHeight);
118
+ }, [prepared, maxWidth, lineHeight]);
119
+ }
120
+
121
+ /**
122
+ * Find the tightest width that produces the same line count as `maxWidth`.
123
+ * Binary-searches widths using `walkLineRanges` — no DOM measurement.
124
+ *
125
+ * Returns the shrinkwrapped width in pixels.
126
+ */
127
+ export function useShrinkwrap(
128
+ prepared: PreparedTextWithSegments | null,
129
+ maxWidth: number,
130
+ ): number {
131
+ return React.useMemo(() => {
132
+ if (!prepared || maxWidth <= 0) return 0;
133
+
134
+ const { walkLineRanges } = getPretext();
135
+
136
+ let baseLineCount = 0;
137
+ walkLineRanges(prepared, maxWidth, () => {
138
+ baseLineCount++;
139
+ });
140
+ if (baseLineCount <= 1) {
141
+ let singleLineWidth = 0;
142
+ walkLineRanges(prepared, maxWidth, (line) => {
143
+ singleLineWidth = line.width;
144
+ });
145
+ return Math.ceil(singleLineWidth) || 0;
146
+ }
147
+
148
+ let lo = 1;
149
+ let hi = Math.ceil(maxWidth);
150
+
151
+ while (lo < hi) {
152
+ const mid = Math.floor((lo + hi) / 2);
153
+ let midLineCount = 0;
154
+ walkLineRanges(prepared, mid, () => {
155
+ midLineCount++;
156
+ });
157
+
158
+ if (midLineCount <= baseLineCount) {
159
+ hi = mid;
160
+ } else {
161
+ lo = mid + 1;
162
+ }
163
+ }
164
+
165
+ return lo;
166
+ }, [prepared, maxWidth]);
167
+ }
168
+
169
+ /**
170
+ * Find the width where all lines are roughly equal length (balanced text).
171
+ * Binary-searches for the narrowest width that keeps the same line count
172
+ * as `maxWidth`, then returns that width.
173
+ *
174
+ * CSS `text-wrap: balance` only works up to ~6 lines and is inconsistent
175
+ * cross-browser. This works on any length and is deterministic.
176
+ */
177
+ export function useBalancedWidth(
178
+ prepared: PreparedTextWithSegments | null,
179
+ maxWidth: number,
180
+ ): number {
181
+ return React.useMemo(() => {
182
+ if (!prepared || maxWidth <= 0) return 0;
183
+
184
+ const { walkLineRanges } = getPretext();
185
+
186
+ let baseLineCount = 0;
187
+ walkLineRanges(prepared, maxWidth, () => {
188
+ baseLineCount++;
189
+ });
190
+ if (baseLineCount <= 1) return maxWidth;
191
+
192
+ let lo = 1;
193
+ let hi = Math.ceil(maxWidth);
194
+
195
+ while (lo < hi) {
196
+ const mid = Math.floor((lo + hi) / 2);
197
+ let midLineCount = 0;
198
+ walkLineRanges(prepared, mid, () => {
199
+ midLineCount++;
200
+ });
201
+
202
+ if (midLineCount <= baseLineCount) {
203
+ hi = mid;
204
+ } else {
205
+ lo = mid + 1;
206
+ }
207
+ }
208
+
209
+ return lo;
210
+ }, [prepared, maxWidth]);
211
+ }
@@ -6,7 +6,8 @@ CSS architecture for `@djangocfg/ui-core` — **Tailwind v4** with semantic toke
6
6
 
7
7
  ```
8
8
  styles/
9
- ├── index.css # Main entryimports the chain below
9
+ ├── full.css # Golden path (recommended) Tailwind + tokens + base + utilities, cascade-layer-safe
10
+ ├── index.css # Plain entry (no Tailwind, unlayered) — you own layer ordering
10
11
  ├── theme.css # Imports tokens.css → animations → light → dark
11
12
  ├── base.css # Resets + `*` border-color + body bg/color
12
13
  ├── utilities.css # Custom utilities (.glass-*, .step, animations)
@@ -155,18 +156,55 @@ Each requires a **non-transparent parent** (something for the blur to chew on).
155
156
 
156
157
  ## App setup
157
158
 
158
- In your consuming app's `globals.css`:
159
+ ### Golden path (recommended) — one import, layer-safe
159
160
 
160
161
  ```css
161
- /* 1. ui-core theme tokens FIRST (they define every --color-*) */
162
- @import "@djangocfg/ui-core/styles";
162
+ /* Single line. Imports Tailwind + tokens + base + utilities in the
163
+ correct cascade layers. Put it FIRST; other package CSS after. */
164
+ @import "@djangocfg/ui-core/styles/full";
163
165
 
164
- /* 2. Other package styles after */
165
166
  @import "@djangocfg/layouts/styles";
166
167
  @import "@djangocfg/ui-tools/styles";
168
+ ```
167
169
 
168
- /* 3. Tailwind v4 core LAST so it sees the @theme tokens */
169
- @import "tailwindcss";
170
+ `…/styles/full` (`full.css`) pins ui-core's base resets to `@layer base`
171
+ and its custom utilities to `@layer utilities` via `@import "" layer(name)`.
172
+ A `layer()`-qualified import is folded into that layer **regardless of
173
+ import order or build tool**, so you cannot get the ordering wrong, and
174
+ you do not need to import `tailwindcss` yourself.
175
+
176
+ ### The cascade-layer rule (why ordering matters)
177
+
178
+ Tailwind v4 emits its utilities inside `@layer utilities`. **Unlayered
179
+ CSS beats any layered rule** in the cascade. So if a package's base
180
+ resets (here `* { border-color }` and the `body` background/font rules in
181
+ `base.css`) are emitted *unlayered*, they sit above `@layer utilities`
182
+ and silently defeat layout utilities (`gap`, `space-y`, `divide`, `flex`,
183
+ `border`, `padding`). Colors usually survive because they flow through
184
+ CSS vars (no cascade conflict), so the breakage is invisible in Chrome
185
+ but shows up in stricter engines (WKWebView).
186
+
187
+ Whether a *plain*, unlayered `@import` lands in a layer depends on its
188
+ position relative to `@import "tailwindcss"` **and** on the build tool
189
+ (Vite vs Next.js resolve `@import` differently). `full.css` removes that
190
+ dependency by binding each file to a layer explicitly. **Use `…/styles/full`
191
+ and this whole class of bug cannot occur.**
192
+
193
+ ### Manual ordering (only if you manage layers yourself)
194
+
195
+ The plain `@djangocfg/ui-core/styles` entry imports `theme.css` +
196
+ `sources.css` + `base.css` + `utilities.css` **unlayered** (it does not
197
+ import Tailwind). If you use it, you own the layer ordering. The Next.js
198
+ demo does this and works because PostCSS folds the trailing
199
+ `@import "tailwindcss"` such that the resets still end up benign — but a
200
+ Vite consumer that puts `@import "tailwindcss"` last hit exactly the bug
201
+ above. If you must hand-order, import `tailwindcss` **first**:
202
+
203
+ ```css
204
+ @import "tailwindcss"; /* establishes the layers first */
205
+ @import "@djangocfg/ui-core/styles";
206
+ @import "@djangocfg/layouts/styles";
207
+ @import "@djangocfg/ui-tools/styles";
170
208
  ```
171
209
 
172
210
  > **No `@plugin "tailwindcss-animate"` needed** in v4 — the keyframes ship via `theme/animations.css`. Use `tw-animate-css` instead if you need extra utilities.
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @djangocfg/ui-core — golden-path style entry (cascade-layer-safe)
3
+ *
4
+ * Import THIS file (`@djangocfg/ui-core/styles/full`) as the SINGLE,
5
+ * FIRST style import in a consumer app and Tailwind cascade-layer
6
+ * ordering cannot go wrong — regardless of build tool (Vite or Next.js)
7
+ * or where the consumer's own `@import "tailwindcss"` ends up.
8
+ *
9
+ * Why this file exists — the cascade-layer trap it removes
10
+ * --------------------------------------------------------
11
+ * Tailwind v4 emits its utilities inside `@layer utilities`. Any CSS
12
+ * that lands OUTSIDE a cascade layer beats every layered rule, because
13
+ * unlayered styles always win over layered ones in the cascade. So if a
14
+ * package's base resets (e.g. ui-core's `* { border-color }` and the
15
+ * `body` background/font rules in base.css) are emitted unlayered, they
16
+ * sit above `@layer utilities` and silently defeat layout utilities
17
+ * (gap, space-y, divide, flex, border, padding). Colors usually survive
18
+ * because they resolve through CSS vars, which have no cascade conflict,
19
+ * so the breakage is invisible until a stricter engine (WKWebView) shows
20
+ * it. The plain `@djangocfg/ui-core/styles` entry imports base.css and
21
+ * utilities.css unlayered, which means their position relative to
22
+ * `@import "tailwindcss"` decides whether they get folded into a layer —
23
+ * a footgun for consumers.
24
+ *
25
+ * This entry removes the footgun by binding each package file to an
26
+ * explicit cascade layer at import time via `@import "..." layer(name)`.
27
+ * A `layer()`-qualified import is folded into that named layer no matter
28
+ * the import order or build tool, so the consumer can put their own
29
+ * `@import "tailwindcss"` anywhere (or omit it — this file imports it
30
+ * first) and the resets stay in `@layer base`, the custom utilities stay
31
+ * in `@layer utilities`, both correctly under Tailwind's own emissions.
32
+ *
33
+ * theme.css and sources.css are intentionally NOT layered: `@theme`
34
+ * token blocks and the `:root` / `.dark` CSS variables must stay global
35
+ * so Tailwind collects the tokens and the variables apply everywhere,
36
+ * and `@source` is a build directive, not a style rule.
37
+ */
38
+
39
+ /* Tailwind v4 core first — establishes the properties/theme/base/components/utilities layer order. */
40
+ @import "tailwindcss";
41
+
42
+ /* Theme tokens + CSS variables — must stay global (unlayered) so Tailwind reads @theme and :root/.dark apply everywhere. */
43
+ @import "./theme.css";
44
+
45
+ /* @source directives for monorepo class detection — build directive, not a style rule. */
46
+ @import "./sources.css";
47
+
48
+ /* Base resets pinned to @layer base so they never escape above Tailwind's utilities. */
49
+ @import "./base.css" layer(base);
50
+
51
+ /* Custom utilities pinned to @layer utilities to sit alongside Tailwind's own. */
52
+ @import "./utilities.css" layer(utilities);