@godxjp/ui 13.13.0 → 13.15.0

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.
@@ -1,7 +1,7 @@
1
1
  import { type AppDateFormat, type AppLocale, type AppTimeFormat, type AppTimezone } from "./types";
2
2
  import type { AppContextValue, AppProviderProp } from "../props/components/app.prop";
3
3
  export type { AppProviderProp, AppContextValue } from "../props/components/app.prop";
4
- export declare function AppProvider({ children, defaultLocale, fallbackLocale, defaultTimezone, systemTimezone, defaultTimeFormat, defaultDateFormat, timezoneOptions, storageKey, persist, onLocaleChange, onTimezoneChange, onTimeFormatChange, onDateFormatChange, }: AppProviderProp): import("react/jsx-runtime").JSX.Element;
4
+ export declare function AppProvider({ children, defaultLocale, fallbackLocale, defaultTimezone, systemTimezone, defaultTimeFormat, defaultDateFormat, timezoneOptions, storageKey, persist, theme: initialTheme, brand: initialBrand, density: initialDensity, fontSize: initialFontSize, onLocaleChange, onTimezoneChange, onTimeFormatChange, onDateFormatChange, onThemeChange, onBrandChange, onDensityChange, onFontSizeChange, }: AppProviderProp): import("react/jsx-runtime").JSX.Element;
5
5
  export declare function useAppContext(): AppContextValue;
6
6
  /** Returns null outside AppProvider — used by pickers for optional context. */
7
7
  export declare function useOptionalAppContext(): AppContextValue | null;
@@ -14,6 +14,9 @@ import {
14
14
  syncDatetimeContext
15
15
  } from "../lib/datetime";
16
16
  import { DEFAULT_STORAGE_KEY, readStoredPreferences, writeStoredPreferences } from "./storage";
17
+ import {
18
+ applyThemeAxes
19
+ } from "./theme-axes";
17
20
  import { resolveDefaultTimeFormat } from "./time-format-labels";
18
21
  import { resolveDefaultTimezone } from "./timezones";
19
22
  import {
@@ -56,10 +59,18 @@ function AppProvider({
56
59
  timezoneOptions,
57
60
  storageKey = DEFAULT_STORAGE_KEY,
58
61
  persist = true,
62
+ theme: initialTheme = "light",
63
+ brand: initialBrand = null,
64
+ density: initialDensity = "default",
65
+ fontSize: initialFontSize = "default",
59
66
  onLocaleChange,
60
67
  onTimezoneChange,
61
68
  onTimeFormatChange,
62
- onDateFormatChange
69
+ onDateFormatChange,
70
+ onThemeChange,
71
+ onBrandChange,
72
+ onDensityChange,
73
+ onFontSizeChange
63
74
  }) {
64
75
  const initialLocale = defaultLocale;
65
76
  const [locale, setLocaleState] = React.useState(initialLocale);
@@ -72,8 +83,21 @@ function AppProvider({
72
83
  const [dateFormat, setDateFormatState] = React.useState(
73
84
  () => resolveInitialDateFormat(void 0, defaultDateFormat, initialLocale)
74
85
  );
86
+ const [theme, setThemeState] = React.useState(initialTheme);
87
+ const [brand, setBrandState] = React.useState(initialBrand);
88
+ const [density, setDensityState] = React.useState(initialDensity);
89
+ const [fontSize, setFontSizeState] = React.useState(initialFontSize);
75
90
  const hasMountedRef = React.useRef(false);
76
- const prefsRef = React.useRef({ locale, timezone, timeFormat, dateFormat });
91
+ const prefsRef = React.useRef({
92
+ locale,
93
+ timezone,
94
+ timeFormat,
95
+ dateFormat,
96
+ theme,
97
+ brand,
98
+ density,
99
+ fontSize
100
+ });
77
101
  React.useEffect(() => {
78
102
  const stored = persist ? readStoredPreferences(storageKey) : {};
79
103
  const nextLocale = stored.locale ?? defaultLocale;
@@ -88,28 +112,48 @@ function AppProvider({
88
112
  defaultDateFormat,
89
113
  nextLocale
90
114
  );
115
+ const nextTheme = stored.theme ?? initialTheme;
116
+ const nextBrand = stored.brand ?? initialBrand;
117
+ const nextDensity = stored.density ?? initialDensity;
118
+ const nextFontSize = stored.fontSize ?? initialFontSize;
91
119
  prefsRef.current = {
92
120
  locale: nextLocale,
93
121
  timezone: nextTimezone,
94
122
  timeFormat: nextTimeFormat,
95
- dateFormat: nextDateFormat
123
+ dateFormat: nextDateFormat,
124
+ theme: nextTheme,
125
+ brand: nextBrand,
126
+ density: nextDensity,
127
+ fontSize: nextFontSize
96
128
  };
97
129
  setLocaleState(nextLocale);
98
130
  setTimezoneState(nextTimezone);
99
131
  setTimeFormatState(nextTimeFormat);
100
132
  setDateFormatState(nextDateFormat);
133
+ setThemeState(nextTheme);
134
+ setBrandState(nextBrand);
135
+ setDensityState(nextDensity);
136
+ setFontSizeState(nextFontSize);
101
137
  }, [
102
138
  defaultDateFormat,
103
139
  defaultLocale,
104
140
  defaultTimeFormat,
105
141
  defaultTimezone,
142
+ initialTheme,
143
+ initialBrand,
144
+ initialDensity,
145
+ initialFontSize,
106
146
  persist,
107
147
  storageKey,
108
148
  systemTimezone
109
149
  ]);
110
150
  React.useEffect(() => {
111
- prefsRef.current = { locale, timezone, timeFormat, dateFormat };
112
- }, [locale, timezone, timeFormat, dateFormat]);
151
+ prefsRef.current = { locale, timezone, timeFormat, dateFormat, theme, brand, density, fontSize };
152
+ }, [locale, timezone, timeFormat, dateFormat, theme, brand, density, fontSize]);
153
+ React.useEffect(() => {
154
+ if (typeof document === "undefined") return;
155
+ applyThemeAxes(document.documentElement, { theme, brand, density, fontSize });
156
+ }, [theme, brand, density, fontSize]);
113
157
  const setLocale = React.useCallback(
114
158
  (next) => {
115
159
  prefsRef.current = { ...prefsRef.current, locale: next };
@@ -146,6 +190,42 @@ function AppProvider({
146
190
  },
147
191
  [onDateFormatChange, persist, storageKey]
148
192
  );
193
+ const setTheme = React.useCallback(
194
+ (next) => {
195
+ prefsRef.current = { ...prefsRef.current, theme: next };
196
+ setThemeState(next);
197
+ onThemeChange?.(next);
198
+ if (persist) writeStoredPreferences(storageKey, prefsRef.current);
199
+ },
200
+ [onThemeChange, persist, storageKey]
201
+ );
202
+ const setBrand = React.useCallback(
203
+ (next) => {
204
+ prefsRef.current = { ...prefsRef.current, brand: next };
205
+ setBrandState(next);
206
+ onBrandChange?.(next);
207
+ if (persist) writeStoredPreferences(storageKey, prefsRef.current);
208
+ },
209
+ [onBrandChange, persist, storageKey]
210
+ );
211
+ const setDensity = React.useCallback(
212
+ (next) => {
213
+ prefsRef.current = { ...prefsRef.current, density: next };
214
+ setDensityState(next);
215
+ onDensityChange?.(next);
216
+ if (persist) writeStoredPreferences(storageKey, prefsRef.current);
217
+ },
218
+ [onDensityChange, persist, storageKey]
219
+ );
220
+ const setFontSize = React.useCallback(
221
+ (next) => {
222
+ prefsRef.current = { ...prefsRef.current, fontSize: next };
223
+ setFontSizeState(next);
224
+ onFontSizeChange?.(next);
225
+ if (persist) writeStoredPreferences(storageKey, prefsRef.current);
226
+ },
227
+ [onFontSizeChange, persist, storageKey]
228
+ );
149
229
  const requestHeaders = React.useMemo(
150
230
  () => buildRequestHeaders(locale, timezone, timeFormat, dateFormat),
151
231
  [locale, timezone, timeFormat, dateFormat]
@@ -185,10 +265,18 @@ function AppProvider({
185
265
  dayPickerLocale: getDayPickerLocale(locale),
186
266
  requestHeaders,
187
267
  timezoneOptions,
268
+ theme,
269
+ brand,
270
+ density,
271
+ fontSize,
188
272
  setLocale,
189
273
  setTimezone,
190
274
  setTimeFormat,
191
- setDateFormat
275
+ setDateFormat,
276
+ setTheme,
277
+ setBrand,
278
+ setDensity,
279
+ setFontSize
192
280
  }),
193
281
  [
194
282
  locale,
@@ -199,10 +287,18 @@ function AppProvider({
199
287
  dateFnsLocale,
200
288
  requestHeaders,
201
289
  timezoneOptions,
290
+ theme,
291
+ brand,
292
+ density,
293
+ fontSize,
202
294
  setLocale,
203
295
  setTimezone,
204
296
  setTimeFormat,
205
- setDateFormat
297
+ setDateFormat,
298
+ setTheme,
299
+ setBrand,
300
+ setDensity,
301
+ setFontSize
206
302
  ]
207
303
  );
208
304
  return /* @__PURE__ */ jsx(AppContext.Provider, { value, children });
@@ -6,6 +6,7 @@ export * from "./date-formats";
6
6
  export * from "./time-format-labels";
7
7
  export * from "./date-format-labels";
8
8
  export * from "./storage";
9
+ export * from "./theme-axes";
9
10
  export * from "./request-headers";
10
11
  export { AppProvider, useAppContext, useOptionalAppContext, useAppLocale, useAppTimezone, useAppTimeFormat, useAppDateFormat, } from "./app-provider";
11
12
  export { useFormatting, useDateTime } from "./use-formatting";
package/dist/app/index.js CHANGED
@@ -6,6 +6,7 @@ export * from "./date-formats";
6
6
  export * from "./time-format-labels";
7
7
  export * from "./date-format-labels";
8
8
  export * from "./storage";
9
+ export * from "./theme-axes";
9
10
  export * from "./request-headers";
10
11
  import {
11
12
  AppProvider,
@@ -1,3 +1,4 @@
1
+ import { type AppBrand, type AppDensity, type AppFontSize, type AppTheme } from "./theme-axes";
1
2
  import type { AppLocale, AppTimezone, AppTimeFormat, AppDateFormat } from "./types";
2
3
  declare const DEFAULT_STORAGE_KEY = "godxjp.app";
3
4
  export type StoredAppPreferences = {
@@ -5,6 +6,11 @@ export type StoredAppPreferences = {
5
6
  timezone?: AppTimezone;
6
7
  timeFormat?: AppTimeFormat;
7
8
  dateFormat?: AppDateFormat;
9
+ theme?: AppTheme;
10
+ /** `null` = explicit opt-out (keep app token); on read, null/invalid → undefined. */
11
+ brand?: AppBrand | null;
12
+ density?: AppDensity;
13
+ fontSize?: AppFontSize;
8
14
  };
9
15
  export declare function readStoredPreferences(storageKey: string): StoredAppPreferences;
10
16
  export declare function writeStoredPreferences(storageKey: string, preferences: StoredAppPreferences): void;
@@ -1,6 +1,12 @@
1
1
  import { isAppLocale } from "./locales";
2
2
  import { isAppDateFormat } from "./date-formats";
3
3
  import { isAppTimeFormat } from "./time-formats";
4
+ import {
5
+ isAppBrand,
6
+ isAppDensity,
7
+ isAppFontSize,
8
+ isAppTheme
9
+ } from "./theme-axes";
4
10
  const DEFAULT_STORAGE_KEY = "godxjp.app";
5
11
  function readStoredPreferences(storageKey) {
6
12
  if (typeof window === "undefined") return {};
@@ -12,7 +18,11 @@ function readStoredPreferences(storageKey) {
12
18
  locale: isAppLocale(parsed.locale) ? parsed.locale : void 0,
13
19
  timezone: typeof parsed.timezone === "string" ? parsed.timezone : void 0,
14
20
  timeFormat: isAppTimeFormat(parsed.timeFormat) ? parsed.timeFormat : void 0,
15
- dateFormat: isAppDateFormat(parsed.dateFormat) ? parsed.dateFormat : void 0
21
+ dateFormat: isAppDateFormat(parsed.dateFormat) ? parsed.dateFormat : void 0,
22
+ theme: isAppTheme(parsed.theme) ? parsed.theme : void 0,
23
+ brand: isAppBrand(parsed.brand) ? parsed.brand : void 0,
24
+ density: isAppDensity(parsed.density) ? parsed.density : void 0,
25
+ fontSize: isAppFontSize(parsed.fontSize) ? parsed.fontSize : void 0
16
26
  };
17
27
  } catch {
18
28
  return {};
@@ -0,0 +1,30 @@
1
+ import type { PageDensityProp } from "../props/vocabulary/layout.prop";
2
+ /** Light / dark spine — `data-theme` (alias of the legacy `.dark` class). */
3
+ export type AppTheme = "light" | "dark";
4
+ /** Primary-palette preset — `data-brand`. `null` = keep the app's own `--primary`. */
5
+ export type AppBrand = "brand" | "crm" | "logistics" | "partner" | "slate";
6
+ /** Control / table / spacing density — `data-density` (same vocab as PageContainer). */
7
+ export type AppDensity = PageDensityProp;
8
+ /** Base type size — `data-font-size`; a preset rescales the whole golden scale. */
9
+ export type AppFontSize = "sm" | "default" | "lg";
10
+ export declare const APP_THEMES: readonly ["light", "dark"];
11
+ export declare const APP_BRANDS: readonly ["brand", "crm", "logistics", "partner", "slate"];
12
+ export declare const APP_DENSITIES: readonly ["compact", "default", "comfortable"];
13
+ export declare const APP_FONT_SIZES: readonly ["sm", "default", "lg"];
14
+ /** The four axes as held by AppProvider. `brand: null` opts out (app token wins). */
15
+ export type AppThemeAxes = {
16
+ theme: AppTheme;
17
+ brand: AppBrand | null;
18
+ density: AppDensity;
19
+ fontSize: AppFontSize;
20
+ };
21
+ export declare const isAppTheme: (v: unknown) => v is AppTheme;
22
+ export declare const isAppBrand: (v: unknown) => v is AppBrand;
23
+ export declare const isAppDensity: (v: unknown) => v is AppDensity;
24
+ export declare const isAppFontSize: (v: unknown) => v is AppFontSize;
25
+ /**
26
+ * Write the axis attributes onto a root element (default `<html>`). Pass `brand: null`
27
+ * to remove `data-brand` so the app's own `--primary` token applies. SSR-safe: callers
28
+ * guard `typeof document`. Exported for non-React / imperative consumers too.
29
+ */
30
+ export declare function applyThemeAxes(el: HTMLElement, axes: Partial<AppThemeAxes>): void;
@@ -0,0 +1,38 @@
1
+ const APP_THEMES = ["light", "dark"];
2
+ const APP_BRANDS = [
3
+ "brand",
4
+ "crm",
5
+ "logistics",
6
+ "partner",
7
+ "slate"
8
+ ];
9
+ const APP_DENSITIES = [
10
+ "compact",
11
+ "default",
12
+ "comfortable"
13
+ ];
14
+ const APP_FONT_SIZES = ["sm", "default", "lg"];
15
+ const isAppTheme = (v) => APP_THEMES.includes(v);
16
+ const isAppBrand = (v) => APP_BRANDS.includes(v);
17
+ const isAppDensity = (v) => APP_DENSITIES.includes(v);
18
+ const isAppFontSize = (v) => APP_FONT_SIZES.includes(v);
19
+ function applyThemeAxes(el, axes) {
20
+ if (axes.theme !== void 0) el.dataset.theme = axes.theme;
21
+ if (axes.density !== void 0) el.dataset.density = axes.density;
22
+ if (axes.fontSize !== void 0) el.dataset.fontSize = axes.fontSize;
23
+ if (axes.brand !== void 0) {
24
+ if (axes.brand === null) delete el.dataset.brand;
25
+ else el.dataset.brand = axes.brand;
26
+ }
27
+ }
28
+ export {
29
+ APP_BRANDS,
30
+ APP_DENSITIES,
31
+ APP_FONT_SIZES,
32
+ APP_THEMES,
33
+ applyThemeAxes,
34
+ isAppBrand,
35
+ isAppDensity,
36
+ isAppFontSize,
37
+ isAppTheme
38
+ };
@@ -34,9 +34,13 @@ function Toaster({ ...props }) {
34
34
  loading: /* @__PURE__ */ jsx(Loader2, { className: "size-4 animate-spin", "aria-hidden": "true" })
35
35
  },
36
36
  style: {
37
- "--normal-bg": "var(--popover)",
38
- "--normal-text": "var(--popover-foreground)",
39
- "--normal-border": "var(--border)",
37
+ // Color tokens are raw HSL triplets (consumed as hsl(var(--token)));
38
+ // sonner uses these vars verbatim as CSS colors, so wrap with hsl()
39
+ // here — unwrapped they are invalid values and the toast renders
40
+ // transparent.
41
+ "--normal-bg": "hsl(var(--popover))",
42
+ "--normal-text": "hsl(var(--popover-foreground))",
43
+ "--normal-border": "hsl(var(--border))",
40
44
  "--border-radius": "var(--radius)"
41
45
  },
42
46
  position: "bottom-right",
@@ -2,7 +2,7 @@ import type { PageContainerProp, PageInsetProp } from "../../props/components/la
2
2
  export type { PageContainerProp, PageContainerProp as PageContainerProps, } from "../../props/components/layout.prop";
3
3
  export type { BreadcrumbItemProp, BreadcrumbItemProp as BreadcrumbItem, } from "../../props/vocabulary/navigation.prop";
4
4
  export declare function PageContainerInset({ className, children, ...props }: PageInsetProp): import("react/jsx-runtime").JSX.Element;
5
- declare function PageContainerRoot({ title, subtitle, extra, footer, breadcrumb, linkComponent: LinkComponent, density, variant, stickyFooter, fill, children, className, }: PageContainerProp): import("react/jsx-runtime").JSX.Element;
5
+ declare function PageContainerRoot({ title, subtitle, extra, footer, breadcrumb, linkComponent: LinkComponent, density, variant, stickyFooter, footerReveal, fill, children, className, }: PageContainerProp): import("react/jsx-runtime").JSX.Element;
6
6
  export declare const PageContainer: typeof PageContainerRoot & {
7
7
  Inset: typeof PageContainerInset;
8
8
  };
@@ -1,7 +1,33 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
+ import { useEffect, useRef, useState } from "react";
2
3
  import { ChevronRight } from "lucide-react";
3
4
  import { cn } from "../../lib/utils";
4
5
  import { densityClass, pageContainerVariantClass } from "../../lib/variants";
6
+ function scrollParent(el) {
7
+ let node = el?.parentElement ?? null;
8
+ while (node) {
9
+ const overflowY = getComputedStyle(node).overflowY;
10
+ if (overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay") return node;
11
+ node = node.parentElement;
12
+ }
13
+ return null;
14
+ }
15
+ function useFooterReveal(enabled) {
16
+ const headerRef = useRef(null);
17
+ const [revealed, setRevealed] = useState(false);
18
+ useEffect(() => {
19
+ if (!enabled) return;
20
+ const el = headerRef.current;
21
+ if (!el || typeof IntersectionObserver === "undefined") return;
22
+ const observer = new IntersectionObserver(
23
+ ([entry]) => setRevealed(!entry.isIntersecting),
24
+ { root: scrollParent(el), threshold: 0 }
25
+ );
26
+ observer.observe(el);
27
+ return () => observer.disconnect();
28
+ }, [enabled]);
29
+ return { headerRef, revealed: enabled && revealed };
30
+ }
5
31
  function PageContainerInset({ className, children, ...props }) {
6
32
  return /* @__PURE__ */ jsx("div", { className: cn("ui-page-container-inset", className), ...props, children });
7
33
  }
@@ -12,26 +38,33 @@ function PageContainerRoot({
12
38
  footer,
13
39
  breadcrumb,
14
40
  linkComponent: LinkComponent = "a",
15
- density = "default",
41
+ density,
16
42
  variant = "default",
17
43
  stickyFooter = false,
44
+ footerReveal = "always",
18
45
  fill = false,
19
46
  children,
20
47
  className
21
48
  }) {
49
+ const reveal = stickyFooter && footer != null && footerReveal === "onScroll";
50
+ const { headerRef, revealed } = useFooterReveal(reveal);
22
51
  return /* @__PURE__ */ jsxs(
23
52
  "div",
24
53
  {
54
+ "data-revealed": revealed ? "true" : void 0,
25
55
  className: cn(
26
56
  "ui-page-container",
27
- densityClass[density],
57
+ // Unset → no class, so the page inherits the global density axis
58
+ // (:root[data-density]); an explicit prop emits a class that overrides it.
59
+ density && densityClass[density],
28
60
  pageContainerVariantClass[variant],
29
61
  stickyFooter && "ui-page-container--sticky-footer",
62
+ reveal && "ui-page-container--reveal-footer",
30
63
  fill && "ui-page-container--fill",
31
64
  className
32
65
  ),
33
66
  children: [
34
- /* @__PURE__ */ jsxs("header", { className: "ui-page-header", children: [
67
+ /* @__PURE__ */ jsxs("header", { ref: headerRef, className: "ui-page-header", children: [
35
68
  breadcrumb && breadcrumb.length > 0 && /* @__PURE__ */ jsx("nav", { "aria-label": "Breadcrumb", className: "ui-breadcrumb", children: /* @__PURE__ */ jsx("ol", { className: "ui-breadcrumb-list", children: breadcrumb.map((item, i) => {
36
69
  const isLast = i === breadcrumb.length - 1;
37
70
  return /* @__PURE__ */ jsxs("li", { className: "ui-inline-xs", children: [
@@ -2,8 +2,9 @@ import * as React from "react";
2
2
  import type { AppSettingPickerProp } from "../../props/components/app.prop";
3
3
  export type { AppSettingKind, AppSettingPickerProp, AppSettingPickerProp as AppSettingPickerProps, } from "../../props/components/app.prop";
4
4
  /**
5
- * One provider-bound Select for any single AppProvider setting — replaces the former
6
- * Locale/Timezone/Date-format/Time-format pickers. Mount under `<AppProvider>` and it
7
- * reads/writes the matching context (`kind`); or pass value + onValueChange to control it.
5
+ * One provider-bound Select for any single AppProvider setting — locale / timezone /
6
+ * date-format / time-format and the four theme axes (theme / brand / density / fontSize).
7
+ * Mount under `<AppProvider>` and it reads/writes the matching context (`kind`); or pass
8
+ * value + onValueChange to control it.
8
9
  */
9
10
  export declare const AppSettingPicker: React.ForwardRefExoticComponent<AppSettingPickerProp & React.RefAttributes<HTMLButtonElement>>;
@@ -1,10 +1,25 @@
1
1
  import { jsx, jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
- import { CalendarDays, Clock, Globe, Languages } from "lucide-react";
3
+ import {
4
+ CalendarDays,
5
+ Clock,
6
+ Globe,
7
+ Languages,
8
+ Palette,
9
+ Rows3,
10
+ SunMoon,
11
+ Type
12
+ } from "lucide-react";
4
13
  import { APP_DATE_FORMAT_OPTIONS, getDateFormatLabel } from "../../app/date-format-labels";
5
14
  import { APP_TIME_FORMAT_OPTIONS, getTimeFormatLabel } from "../../app/time-format-labels";
6
15
  import { getTimezoneLabel, resolveTimezonePickerOptions } from "../../app/timezones";
7
16
  import { APP_LOCALES } from "../../app/types";
17
+ import {
18
+ APP_BRANDS,
19
+ APP_DENSITIES,
20
+ APP_FONT_SIZES,
21
+ APP_THEMES
22
+ } from "../../app/theme-axes";
8
23
  import { useOptionalAppContext } from "../../app/app-provider";
9
24
  import { useTranslation } from "../../i18n/use-translation";
10
25
  import { cn } from "../../lib/utils";
@@ -19,30 +34,48 @@ const ICON = {
19
34
  locale: Languages,
20
35
  timezone: Globe,
21
36
  dateFormat: CalendarDays,
22
- timeFormat: Clock
37
+ timeFormat: Clock,
38
+ theme: SunMoon,
39
+ brand: Palette,
40
+ density: Rows3,
41
+ fontSize: Type
23
42
  };
24
43
  const TRIGGER_WIDTH = {
25
44
  locale: "sm:w-40",
26
45
  timezone: "sm:w-56",
27
46
  dateFormat: "sm:w-44",
28
- timeFormat: "sm:w-44"
47
+ timeFormat: "sm:w-44",
48
+ theme: "sm:w-36",
49
+ brand: "sm:w-44",
50
+ density: "sm:w-40",
51
+ fontSize: "sm:w-36"
29
52
  };
30
53
  const ARIA_KEY = {
31
54
  locale: "navigation.localePicker.ariaLabel",
32
55
  timezone: "navigation.timezonePicker.ariaLabel",
33
56
  dateFormat: "navigation.dateFormatPicker.ariaLabel",
34
- timeFormat: "navigation.timeFormatPicker.ariaLabel"
57
+ timeFormat: "navigation.timeFormatPicker.ariaLabel",
58
+ theme: "navigation.themePicker.ariaLabel",
59
+ brand: "navigation.brandPicker.ariaLabel",
60
+ density: "navigation.densityPicker.ariaLabel",
61
+ fontSize: "navigation.fontSizePicker.ariaLabel"
35
62
  };
63
+ const BRAND_NONE = "__app__";
36
64
  const AppSettingPicker = React.forwardRef(
37
65
  function AppSettingPicker2({ kind, className, disabled, id, name, value, onValueChange }, ref) {
38
66
  const ctx = useOptionalAppContext();
39
67
  const { t, locale, fallbackLocale } = useTranslation();
40
- const current = value ?? ctx?.[kind];
68
+ const raw = value ?? ctx?.[kind];
69
+ const current = raw == null ? kind === "brand" ? BRAND_NONE : void 0 : String(raw);
41
70
  const setter = ctx ? {
42
71
  locale: ctx.setLocale,
43
72
  timezone: ctx.setTimezone,
44
73
  dateFormat: ctx.setDateFormat,
45
- timeFormat: ctx.setTimeFormat
74
+ timeFormat: ctx.setTimeFormat,
75
+ theme: ctx.setTheme,
76
+ density: ctx.setDensity,
77
+ fontSize: ctx.setFontSize,
78
+ brand: (next) => ctx.setBrand(next === BRAND_NONE ? null : next)
46
79
  }[kind] : void 0;
47
80
  const handleChange = onValueChange ?? setter;
48
81
  const items = React.useMemo(() => {
@@ -64,6 +97,23 @@ const AppSettingPicker = React.forwardRef(
64
97
  value: option.value,
65
98
  label: getTimeFormatLabel(option.value, locale, fallbackLocale)
66
99
  }));
100
+ case "theme":
101
+ return APP_THEMES.map((v) => ({ value: v, label: t(`navigation.themePicker.${v}`) }));
102
+ case "density":
103
+ return APP_DENSITIES.map((v) => ({
104
+ value: v,
105
+ label: t(`navigation.densityPicker.${v}`)
106
+ }));
107
+ case "fontSize":
108
+ return APP_FONT_SIZES.map((v) => ({
109
+ value: v,
110
+ label: t(`navigation.fontSizePicker.${v}`)
111
+ }));
112
+ case "brand":
113
+ return [
114
+ { value: BRAND_NONE, label: t("navigation.brandPicker.none") },
115
+ ...APP_BRANDS.map((v) => ({ value: v, label: t(`navigation.brandPicker.${v}`) }))
116
+ ];
67
117
  }
68
118
  }, [kind, ctx?.timezoneOptions, current, t, locale, fallbackLocale]);
69
119
  const unbound = current === void 0 || !handleChange;
@@ -201,6 +201,32 @@
201
201
  "one": "Total {total} item",
202
202
  "other": "Total {total} items"
203
203
  }
204
+ },
205
+ "themePicker": {
206
+ "ariaLabel": "Theme",
207
+ "light": "Light",
208
+ "dark": "Dark"
209
+ },
210
+ "densityPicker": {
211
+ "ariaLabel": "Density",
212
+ "compact": "Compact",
213
+ "default": "Default",
214
+ "comfortable": "Comfortable"
215
+ },
216
+ "fontSizePicker": {
217
+ "ariaLabel": "Font size",
218
+ "sm": "Small",
219
+ "default": "Default",
220
+ "lg": "Large"
221
+ },
222
+ "brandPicker": {
223
+ "ariaLabel": "Brand",
224
+ "none": "App default",
225
+ "brand": "GodX Navy",
226
+ "crm": "CRM Violet",
227
+ "logistics": "Logistics Teal",
228
+ "partner": "Partner Orange",
229
+ "slate": "HQ Slate"
204
230
  }
205
231
  },
206
232
  "locale": {
@@ -198,6 +198,32 @@
198
198
  "pageSize": "ページサイズ",
199
199
  "pageSizeOption": "{size} / ページ",
200
200
  "total": "全 {total} 件"
201
+ },
202
+ "themePicker": {
203
+ "ariaLabel": "テーマ",
204
+ "light": "ライト",
205
+ "dark": "ダーク"
206
+ },
207
+ "densityPicker": {
208
+ "ariaLabel": "密度",
209
+ "compact": "コンパクト",
210
+ "default": "標準",
211
+ "comfortable": "広め"
212
+ },
213
+ "fontSizePicker": {
214
+ "ariaLabel": "文字サイズ",
215
+ "sm": "小",
216
+ "default": "標準",
217
+ "lg": "大"
218
+ },
219
+ "brandPicker": {
220
+ "ariaLabel": "ブランド",
221
+ "none": "アプリ既定",
222
+ "brand": "GodX Navy",
223
+ "crm": "CRM Violet",
224
+ "logistics": "Logistics Teal",
225
+ "partner": "Partner Orange",
226
+ "slate": "HQ Slate"
201
227
  }
202
228
  },
203
229
  "locale": {
@@ -198,6 +198,32 @@
198
198
  "pageSize": "Số dòng mỗi trang",
199
199
  "pageSizeOption": "{size} / trang",
200
200
  "total": "Tổng {total} mục"
201
+ },
202
+ "themePicker": {
203
+ "ariaLabel": "Giao diện",
204
+ "light": "Sáng",
205
+ "dark": "Tối"
206
+ },
207
+ "densityPicker": {
208
+ "ariaLabel": "Mật độ",
209
+ "compact": "Gọn",
210
+ "default": "Mặc định",
211
+ "comfortable": "Thoáng"
212
+ },
213
+ "fontSizePicker": {
214
+ "ariaLabel": "Cỡ chữ",
215
+ "sm": "Nhỏ",
216
+ "default": "Mặc định",
217
+ "lg": "Lớn"
218
+ },
219
+ "brandPicker": {
220
+ "ariaLabel": "Thương hiệu",
221
+ "none": "Mặc định app",
222
+ "brand": "GodX Navy",
223
+ "crm": "CRM Violet",
224
+ "logistics": "Logistics Teal",
225
+ "partner": "Partner Orange",
226
+ "slate": "HQ Slate"
201
227
  }
202
228
  },
203
229
  "locale": {
@@ -2,6 +2,7 @@
2
2
  import type { Locale } from "date-fns";
3
3
  import type { DayPickerProps } from "react-day-picker";
4
4
  import type { AppLocale, AppRequestHeaders, AppTimeFormat, AppTimezone, AppTimezoneDefault, AppDateFormat } from "../../app/types";
5
+ import type { AppBrand, AppDensity, AppFontSize, AppTheme } from "../../app/theme-axes";
5
6
  import type { ChildrenProp, ClassNameProp, DisabledProp, IdProp, NameProp, OnValueChangeProp, ValueProp } from "../vocabulary";
6
7
  /** @see AppProvider */
7
8
  export type AppProviderProp = {
@@ -27,13 +28,31 @@ export type AppProviderProp = {
27
28
  storageKey?: string;
28
29
  /** Persist user choices. Default: true. */
29
30
  persist?: boolean;
31
+ /**
32
+ * Initial light/dark theme — written to `<html data-theme>`. Default: `"light"`.
33
+ * (The legacy `.dark` class still works as an equal alias.)
34
+ */
35
+ theme?: AppTheme;
36
+ /**
37
+ * Initial brand palette preset — written to `<html data-brand>`. OPT-IN: omit
38
+ * (or `null`) to keep the brand `--primary` your own `theme.css` defines.
39
+ */
40
+ brand?: AppBrand | null;
41
+ /** Initial density — written to `<html data-density>`. Default: `"default"`. */
42
+ density?: AppDensity;
43
+ /** Initial base type size — written to `<html data-font-size>`. Default: `"default"`. */
44
+ fontSize?: AppFontSize;
30
45
  onLocaleChange?: (locale: AppLocale) => void;
31
46
  onTimezoneChange?: (timezone: AppTimezone) => void;
32
47
  onTimeFormatChange?: (timeFormat: AppTimeFormat) => void;
33
48
  onDateFormatChange?: (dateFormat: AppDateFormat) => void;
49
+ onThemeChange?: (theme: AppTheme) => void;
50
+ onBrandChange?: (brand: AppBrand | null) => void;
51
+ onDensityChange?: (density: AppDensity) => void;
52
+ onFontSizeChange?: (fontSize: AppFontSize) => void;
34
53
  };
35
54
  /** Which AppProvider setting the {@link AppSettingPicker} reads/writes. */
36
- export type AppSettingKind = "locale" | "timezone" | "dateFormat" | "timeFormat";
55
+ export type AppSettingKind = "locale" | "timezone" | "dateFormat" | "timeFormat" | "theme" | "brand" | "density" | "fontSize";
37
56
  /**
38
57
  * @see AppSettingPicker — one provider-bound Select for any single AppProvider setting.
39
58
  * Replaces the former Locale/Timezone/Date-format/Time-format pickers; pick the target
@@ -62,8 +81,17 @@ export type AppContextValue = {
62
81
  requestHeaders: AppRequestHeaders;
63
82
  /** Configured timezone list; `undefined` → full IANA in the timezone-picker recipe. */
64
83
  timezoneOptions?: readonly AppTimezone[];
84
+ /** Current theme axes (mirror `<html data-*>`). */
85
+ theme: AppTheme;
86
+ brand: AppBrand | null;
87
+ density: AppDensity;
88
+ fontSize: AppFontSize;
65
89
  setLocale: (locale: AppLocale) => void;
66
90
  setTimezone: (timezone: AppTimezone) => void;
67
91
  setTimeFormat: (timeFormat: AppTimeFormat) => void;
68
92
  setDateFormat: (dateFormat: AppDateFormat) => void;
93
+ setTheme: (theme: AppTheme) => void;
94
+ setBrand: (brand: AppBrand | null) => void;
95
+ setDensity: (density: AppDensity) => void;
96
+ setFontSize: (fontSize: AppFontSize) => void;
69
97
  };
@@ -14,6 +14,16 @@ export type PageContainerProp = {
14
14
  variant?: PageContainerVariantProp;
15
15
  /** Pin footer to viewport bottom on scroll — pairs well with `variant="narrow"`. */
16
16
  stickyFooter?: boolean;
17
+ /**
18
+ * When the footer is sticky, control WHEN it shows. `"always"` (default)
19
+ * keeps it pinned the whole time. `"onScroll"` hides it until the header
20
+ * (title + `extra` actions) scrolls out of view, then slides it up — the
21
+ * standard edit/create "save bar" so the primary actions stay reachable as
22
+ * the form scrolls, without cluttering the top. The footer stays mounted
23
+ * (no layout reflow → no jitter); observed against the nearest scroll
24
+ * container.
25
+ */
26
+ footerReveal?: "always" | "onScroll";
17
27
  /**
18
28
  * Grow the body to fill the remaining shell height. Default `false` (top-packed,
19
29
  * content-height — short pages leave no stretched void, gh#103). Enable for a
@@ -1,10 +1,15 @@
1
1
  /*
2
- * DENSITY — one knob retunes φ-unit, controls, tables (PageContainer density prop).
3
- * Owner: .ui-density-* classes only. Apps use <PageContainer density>, never these classes.
2
+ * DENSITY — one knob retunes φ-unit, controls, tables. Two entry points, same vars:
3
+ * `<PageContainer density>` → `.ui-density-*` class (scoped, overrides global).
4
+ * • the density AXIS → `:root[data-density="*"]` on <html> (AppProvider density prop),
5
+ * so density cascades app-wide incl. portalled overlays. PageContainer inherits it
6
+ * when its `density` prop is unset (emits no class).
7
+ * Apps use the prop / axis, never these classes directly.
4
8
  */
5
9
 
6
10
  @layer components {
7
- .ui-density-compact {
11
+ .ui-density-compact,
12
+ :root[data-density="compact"] {
8
13
  --control-height: var(--control-height-compact);
9
14
  --control-padding-x: var(--control-padding-x-compact);
10
15
  --table-row-height: var(--table-row-height-compact);
@@ -19,14 +24,16 @@
19
24
  --space-chrome-y: var(--space-3);
20
25
  }
21
26
 
22
- .ui-density-default {
27
+ .ui-density-default,
28
+ :root[data-density="default"] {
23
29
  --control-height: var(--control-height-default);
24
30
  --control-padding-x: var(--control-padding-x-default);
25
31
  --table-row-height: var(--table-row-height-default);
26
32
  --table-cell-padding-y: var(--space-2);
27
33
  }
28
34
 
29
- .ui-density-comfortable {
35
+ .ui-density-comfortable,
36
+ :root[data-density="comfortable"] {
30
37
  --control-height: var(--control-height-comfortable);
31
38
  /* Comfortable steps 44 → 36 → 32 (design spacing-density), steeper than the
32
39
  * default 4px step so sm/xs aren't left 4px too tall. */
@@ -308,6 +308,31 @@
308
308
  background-color: hsl(var(--background));
309
309
  }
310
310
 
311
+ /* footerReveal="onScroll": the sticky footer stays mounted (reserving its
312
+ * flow space, so toggling never reflows the body → no scroll jitter) but is
313
+ * slid below the fold until the header leaves the viewport, then slides up.
314
+ * data-revealed is toggled by an IntersectionObserver on the header. */
315
+ .ui-page-container--reveal-footer .ui-page-footer {
316
+ transform: translateY(100%);
317
+ opacity: 0;
318
+ pointer-events: none;
319
+ transition:
320
+ transform 200ms ease-out,
321
+ opacity 200ms ease-out;
322
+ }
323
+
324
+ .ui-page-container--reveal-footer[data-revealed="true"] .ui-page-footer {
325
+ transform: none;
326
+ opacity: 1;
327
+ pointer-events: auto;
328
+ }
329
+
330
+ @media (prefers-reduced-motion: reduce) {
331
+ .ui-page-container--reveal-footer .ui-page-footer {
332
+ transition: none;
333
+ }
334
+ }
335
+
311
336
  .ui-page-header {
312
337
  display: flex;
313
338
  flex-direction: column;
@@ -0,0 +1,59 @@
1
+ /* THEME AXES — global consumer API. AppProvider's theme/brand/density/fontSize
2
+ * props write `data-*` attributes onto <html>; the selectors here bind each axis
3
+ * to tokens (Rule #21 "every component honours every theme axis"). Density lives
4
+ * in density.css (shared with the .ui-density-* classes); the dark theme aliases
5
+ * in foundation.css. Every axis is a NO-OP at its default value, so apps that set
6
+ * nothing — or override tokens in their own theme.css — are unaffected.
7
+ *
8
+ * `:root[data-…]` (specificity 0,2,0) intentionally outranks an app theme.css
9
+ * `:root` (0,1,0): when an axis IS set it wins; when it is NOT set the app token
10
+ * override wins. brand is therefore fully opt-in (no default rule). */
11
+
12
+ /* ── font-size axis — base-driven: a preset sets ONLY --font-size-base and the
13
+ * whole golden modular scale (foundation.css) rescales from it. The `default`
14
+ * value emits no rule → foundation 14px stands. */
15
+ :root[data-font-size="sm"] {
16
+ --font-size-base: 0.8125rem; /* 13px */
17
+ }
18
+ :root[data-font-size="lg"] {
19
+ --font-size-base: 1rem; /* 16px */
20
+ }
21
+
22
+ /* ── brand axis — primary-palette presets (canonical values; src/test/theme-globals.ts
23
+ * mirrors these for the Storybook toolbar). OPT-IN: no `default` rule, so an app
24
+ * that pins --primary in its own theme.css keeps it unless it sets data-brand. */
25
+ :root[data-brand="brand"] {
26
+ --primary: 211 73% 15%;
27
+ --primary-foreground: 0 0% 100%;
28
+ --ring: 24 99% 46%;
29
+ --accent: 24 99% 95%;
30
+ --accent-foreground: 24 99% 28%;
31
+ }
32
+ :root[data-brand="crm"] {
33
+ --primary: 262 83% 58%;
34
+ --primary-foreground: 0 0% 100%;
35
+ --ring: 262 83% 58%;
36
+ --accent: 262 83% 96%;
37
+ --accent-foreground: 262 83% 28%;
38
+ }
39
+ :root[data-brand="logistics"] {
40
+ --primary: 173 80% 36%;
41
+ --primary-foreground: 0 0% 100%;
42
+ --ring: 173 80% 36%;
43
+ --accent: 173 80% 94%;
44
+ --accent-foreground: 173 80% 22%;
45
+ }
46
+ :root[data-brand="partner"] {
47
+ --primary: 24 95% 53%;
48
+ --primary-foreground: 0 0% 100%;
49
+ --ring: 24 95% 53%;
50
+ --accent: 24 95% 95%;
51
+ --accent-foreground: 24 95% 28%;
52
+ }
53
+ :root[data-brand="slate"] {
54
+ --primary: 215 25% 27%;
55
+ --primary-foreground: 0 0% 100%;
56
+ --ring: 215 25% 27%;
57
+ --accent: 215 25% 94%;
58
+ --accent-foreground: 215 25% 20%;
59
+ }
@@ -5,6 +5,7 @@
5
5
 
6
6
  @import "./foundation.css";
7
7
  @import "./semantic/layout.css";
8
+ @import "./axes.css";
8
9
  @import "./components/control.css";
9
10
  @import "./components/form.css";
10
11
  @import "./components/card.css";
@@ -62,6 +62,14 @@
62
62
  --search-input-end-padding: calc(
63
63
  var(--search-input-edge-inset) + var(--control-icon-size) + var(--control-gap)
64
64
  );
65
+
66
+ --choice-description-font-size: var(--font-size-xs);
67
+ --color-picker-hex-font-size: var(--font-size-xs);
68
+ --command-group-heading-font-size: var(--font-size-xs);
69
+ --search-input-label-font-size: var(--font-size-xs);
70
+ --tag-input-chip-font-size: var(--font-size-xs);
71
+ --toggle-sm-font-size: var(--font-size-xs);
72
+ --button-sm-font-size: var(--font-size-xs);
65
73
  }
66
74
 
67
75
  /* Rule #24 — on touch devices (coarse pointer) interactive controls keep a ≥44px tap target
@@ -72,11 +80,4 @@
72
80
  --control-height-compact: 2.75rem;
73
81
  --control-height-default: 2.75rem;
74
82
  }
75
- --choice-description-font-size: var(--font-size-xs);
76
- --color-picker-hex-font-size: var(--font-size-xs);
77
- --command-group-heading-font-size: var(--font-size-xs);
78
- --search-input-label-font-size: var(--font-size-xs);
79
- --tag-input-chip-font-size: var(--font-size-xs);
80
- --toggle-sm-font-size: var(--font-size-xs);
81
- --button-sm-font-size: var(--font-size-xs);
82
83
  }
@@ -162,8 +162,11 @@
162
162
  --phi-p2: calc(var(--phi-p1) * var(--ratio-phi));
163
163
  }
164
164
 
165
- .dark {
166
- /* Warm-tinted dark spine — keeps the hue-60 character at low lightness. */
165
+ .dark,
166
+ :root[data-theme="dark"] {
167
+ /* Warm-tinted dark spine — keeps the hue-60 character at low lightness.
168
+ * `data-theme="dark"` is the theme axis (AppProvider theme prop → <html>); the
169
+ * legacy `.dark` class stays an equal alias so existing toggles keep working. */
167
170
  --background: 48 9% 9%;
168
171
  --foreground: 60 20% 96%;
169
172
  --card: 48 8% 12%;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@godxjp/ui",
3
- "version": "13.13.0",
3
+ "version": "13.15.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.29.1",
6
6
  "sideEffects": false,