@deepfuture/dui-components 1.1.1 → 1.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@deepfuture/dui-components",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "DUI styled web components — extends dui-primitives with design tokens and variant CSS",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -170,6 +170,10 @@
170
170
  "import": "./textarea/index.js",
171
171
  "types": "./textarea/index.d.ts"
172
172
  },
173
+ "./theme": {
174
+ "import": "./theme/index.js",
175
+ "types": "./theme/index.d.ts"
176
+ },
173
177
  "./toggle": {
174
178
  "import": "./toggle/index.js",
175
179
  "types": "./toggle/index.d.ts"
@@ -2,6 +2,16 @@ import { css } from "lit";
2
2
  import { DuiSidebarPrimitive } from "@deepfuture/dui-primitives/sidebar";
3
3
  import "../_install.js";
4
4
  const styles = css `
5
+ /* ── Host: sticky full-viewport height ── */
6
+ /* Percentage heights on internal elements need a definite parent.
7
+ Using height: 100dvh + sticky keeps the sidebar pinned while
8
+ main content scrolls freely — no parent height context needed. */
9
+ :host {
10
+ position: sticky;
11
+ top: 0;
12
+ height: 100dvh;
13
+ }
14
+
5
15
  /* ── Desktop Outer ── */
6
16
 
7
17
  .DesktopOuter {
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Theme configuration API — bridges DESIGN.md intent to DUI runtime tokens.
3
+ *
4
+ * Usage:
5
+ * import { applyTheme } from "@deepfuture/dui-components/theme";
6
+ *
7
+ * applyTheme({
8
+ * light: { accent: "oklch(0.55 0.25 160)" },
9
+ * dark: { accent: "oklch(0.75 0.18 160)" },
10
+ * fonts: { sans: "Inter", mono: "Geist Mono" },
11
+ * radius: "0.5rem",
12
+ * });
13
+ *
14
+ * Call after importing any DUI component. Appends an adopted stylesheet
15
+ * that overrides DUI's defaults in the correct cascade position.
16
+ */
17
+ export type ThemePrimitives = {
18
+ background?: string;
19
+ foreground?: string;
20
+ accent?: string;
21
+ destructive?: string;
22
+ };
23
+ export type ThemeFonts = {
24
+ sans?: string;
25
+ mono?: string;
26
+ serif?: string;
27
+ };
28
+ export type ThemeConfig = {
29
+ /** Light mode color primitives. */
30
+ light?: ThemePrimitives;
31
+ /** Dark mode color primitives. If omitted, dark mode is derived from light. */
32
+ dark?: ThemePrimitives;
33
+ /** Font family overrides. Values are family names (e.g. "Inter"), not full stacks. */
34
+ fonts?: ThemeFonts;
35
+ /** Base border-radius (e.g. "0.5rem" or "8px"). The full scale is derived from this value. */
36
+ radius?: string;
37
+ };
38
+ export declare function applyTheme(config: ThemeConfig): void;
package/theme/index.js ADDED
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Theme configuration API — bridges DESIGN.md intent to DUI runtime tokens.
3
+ *
4
+ * Usage:
5
+ * import { applyTheme } from "@deepfuture/dui-components/theme";
6
+ *
7
+ * applyTheme({
8
+ * light: { accent: "oklch(0.55 0.25 160)" },
9
+ * dark: { accent: "oklch(0.75 0.18 160)" },
10
+ * fonts: { sans: "Inter", mono: "Geist Mono" },
11
+ * radius: "0.5rem",
12
+ * });
13
+ *
14
+ * Call after importing any DUI component. Appends an adopted stylesheet
15
+ * that overrides DUI's defaults in the correct cascade position.
16
+ */
17
+ /**
18
+ * Apply a theme configuration to the document.
19
+ *
20
+ * Creates a CSSStyleSheet and appends it to `document.adoptedStyleSheets`
21
+ * after DUI's token sheet, so overrides cascade correctly.
22
+ *
23
+ * Safe to call multiple times — each call replaces the previous theme sheet.
24
+ */
25
+ let themeSheet = null;
26
+ export function applyTheme(config) {
27
+ const rules = [];
28
+ // --- Color primitives ---
29
+ const lightVars = buildColorVars(config.light);
30
+ const darkVars = buildColorVars(config.dark ?? deriveDark(config.light));
31
+ if (lightVars) {
32
+ rules.push(`:root:not([data-theme="dark"]) { ${lightVars} }`);
33
+ }
34
+ if (darkVars) {
35
+ rules.push(`:root[data-theme="dark"] { ${darkVars} }`);
36
+ }
37
+ // --- Fonts and radius (theme-independent, go on :root) ---
38
+ const rootVars = buildRootVars(config);
39
+ if (rootVars) {
40
+ rules.push(`:root { ${rootVars} }`);
41
+ }
42
+ const css = rules.join("\n");
43
+ if (!css)
44
+ return;
45
+ // Replace previous theme sheet if one exists
46
+ if (themeSheet) {
47
+ const idx = document.adoptedStyleSheets.indexOf(themeSheet);
48
+ if (idx !== -1) {
49
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter((s) => s !== themeSheet);
50
+ }
51
+ }
52
+ themeSheet = new CSSStyleSheet();
53
+ themeSheet.replaceSync(css);
54
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, themeSheet];
55
+ }
56
+ // --- Internals ---
57
+ function buildColorVars(primitives) {
58
+ if (!primitives)
59
+ return "";
60
+ const entries = [];
61
+ if (primitives.background)
62
+ entries.push(`--background: ${primitives.background};`);
63
+ if (primitives.foreground)
64
+ entries.push(`--foreground: ${primitives.foreground};`);
65
+ if (primitives.accent)
66
+ entries.push(`--accent: ${primitives.accent};`);
67
+ if (primitives.destructive)
68
+ entries.push(`--destructive: ${primitives.destructive};`);
69
+ return entries.join(" ");
70
+ }
71
+ function buildRootVars(config) {
72
+ const entries = [];
73
+ // Fonts
74
+ if (config.fonts?.sans) {
75
+ entries.push(`--font-sans: '${config.fonts.sans}', system-ui, -apple-system, sans-serif;`);
76
+ }
77
+ if (config.fonts?.mono) {
78
+ entries.push(`--font-mono: '${config.fonts.mono}', ui-monospace, SFMono-Regular, monospace;`);
79
+ }
80
+ if (config.fonts?.serif) {
81
+ entries.push(`--font-serif: '${config.fonts.serif}', ui-serif, Georgia, serif;`);
82
+ }
83
+ // Radius scale derived from base
84
+ if (config.radius) {
85
+ const base = parseRadiusToRem(config.radius);
86
+ if (base !== null) {
87
+ entries.push(`--radius-xs: ${rem(Math.max(base * 0.25, 0))};`);
88
+ entries.push(`--radius-sm: ${rem(Math.max(base * 0.5, 0))};`);
89
+ entries.push(`--radius-md: ${rem(base)};`);
90
+ entries.push(`--radius-lg: ${rem(base * 2)};`);
91
+ entries.push(`--radius-xl: ${rem(base * 3)};`);
92
+ entries.push(`--radius-2xl: ${rem(base * 4)};`);
93
+ }
94
+ }
95
+ return entries.join(" ");
96
+ }
97
+ /**
98
+ * Derive dark mode primitives from light mode values.
99
+ *
100
+ * Strategy: parse OKLCH values and adjust lightness.
101
+ * - background: invert L (0.97 → 0.15), add slight chroma from accent hue
102
+ * - foreground: invert L (0.15 → 0.93)
103
+ * - accent: boost L (+0.20), reduce C slightly
104
+ * - destructive: boost L (+0.15), reduce C slightly
105
+ *
106
+ * Falls back to DUI's built-in dark defaults if parsing fails.
107
+ */
108
+ function deriveDark(light) {
109
+ if (!light)
110
+ return undefined;
111
+ const dark = {};
112
+ if (light.background) {
113
+ const parsed = parseOklch(light.background);
114
+ if (parsed) {
115
+ // Invert: light bg → dark bg. Add a hint of accent hue chroma.
116
+ const accentParsed = light.accent ? parseOklch(light.accent) : null;
117
+ const hue = accentParsed?.h ?? 0;
118
+ dark.background = `oklch(${(1 - parsed.l).toFixed(2)} 0.015 ${hue})`;
119
+ }
120
+ }
121
+ if (light.foreground) {
122
+ const parsed = parseOklch(light.foreground);
123
+ if (parsed) {
124
+ dark.foreground = `oklch(${(1 - parsed.l + 0.08).toFixed(2)} 0 0)`;
125
+ }
126
+ }
127
+ if (light.accent) {
128
+ const parsed = parseOklch(light.accent);
129
+ if (parsed) {
130
+ dark.accent = `oklch(${Math.min(parsed.l + 0.20, 0.90).toFixed(2)} ${Math.max(parsed.c - 0.07, 0.05).toFixed(2)} ${parsed.h})`;
131
+ }
132
+ }
133
+ if (light.destructive) {
134
+ const parsed = parseOklch(light.destructive);
135
+ if (parsed) {
136
+ dark.destructive = `oklch(${Math.min(parsed.l + 0.15, 0.85).toFixed(2)} ${Math.max(parsed.c - 0.04, 0.05).toFixed(2)} ${parsed.h})`;
137
+ }
138
+ }
139
+ return Object.keys(dark).length > 0 ? dark : undefined;
140
+ }
141
+ /** Parse an oklch(...) string into { l, c, h } numbers. */
142
+ function parseOklch(value) {
143
+ const match = value.match(/oklch\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)\s*\)/);
144
+ if (!match)
145
+ return null;
146
+ return {
147
+ l: parseFloat(match[1]),
148
+ c: parseFloat(match[2]),
149
+ h: parseFloat(match[3]),
150
+ };
151
+ }
152
+ /** Parse a radius value (rem or px) to rem number. */
153
+ function parseRadiusToRem(value) {
154
+ const remMatch = value.match(/^([\d.]+)\s*rem$/);
155
+ if (remMatch)
156
+ return parseFloat(remMatch[1]);
157
+ const pxMatch = value.match(/^([\d.]+)\s*px$/);
158
+ if (pxMatch)
159
+ return parseFloat(pxMatch[1]) / 16;
160
+ return null;
161
+ }
162
+ /** Format a number as a rem string. */
163
+ function rem(value) {
164
+ return value === 0 ? "0" : `${Number(value.toFixed(4))}rem`;
165
+ }