@godxjp/ui 13.14.0 → 13.15.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.
@@ -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
+ };
@@ -38,7 +38,7 @@ function PageContainerRoot({
38
38
  footer,
39
39
  breadcrumb,
40
40
  linkComponent: LinkComponent = "a",
41
- density = "default",
41
+ density,
42
42
  variant = "default",
43
43
  stickyFooter = false,
44
44
  footerReveal = "always",
@@ -54,7 +54,9 @@ function PageContainerRoot({
54
54
  "data-revealed": revealed ? "true" : void 0,
55
55
  className: cn(
56
56
  "ui-page-container",
57
- 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],
58
60
  pageContainerVariantClass[variant],
59
61
  stickyFooter && "ui-page-container--sticky-footer",
60
62
  reveal && "ui-page-container--reveal-footer",
@@ -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
  };
@@ -1,47 +1,61 @@
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 to a descendant,
4
+ * wins for its subtree by proximity).
5
+ * • the density AXIS → `:root[data-density="*"]` on <html> (AppProvider density prop),
6
+ * so density cascades app-wide incl. portalled overlays. PageContainer inherits it
7
+ * when its `density` prop is unset (emits no class).
8
+ *
9
+ * INTENTIONALLY UNLAYERED — do NOT wrap in `@layer components`. The active
10
+ * tokens (--control-height, --table-row-height, --phi-unit…) get their defaults
11
+ * from UNLAYERED `:root` rules in tokens/components/*.css. A layered block always
12
+ * loses to an unlayered one regardless of specificity, so a layered
13
+ * `:root[data-density]` would silently never override those defaults (the axis
14
+ * would be a no-op on <html>). Unlayered, `:root[data-density="x"]` (0,2,0) beats
15
+ * the `:root` default (0,1,0) by specificity. Apps use the prop / axis, never
16
+ * these classes directly.
4
17
  */
5
18
 
6
- @layer components {
7
- .ui-density-compact {
8
- --control-height: var(--control-height-compact);
9
- --control-padding-x: var(--control-padding-x-compact);
10
- --table-row-height: var(--table-row-height-compact);
11
- --phi-unit: var(--space-3);
12
- --checkbox-size: var(--checkbox-size-compact);
13
- --switch-width: var(--switch-width-compact);
14
- --switch-height: var(--switch-height-compact);
15
- --switch-thumb-size: var(--switch-thumb-size-compact);
16
- --switch-thumb-translate: var(--switch-thumb-translate-compact);
17
- --table-cell-padding-y: var(--space-1);
18
- /* Tighter overlay/card chrome at compact density (12px vertical). */
19
- --space-chrome-y: var(--space-3);
20
- }
19
+ .ui-density-compact,
20
+ :root[data-density="compact"] {
21
+ --control-height: var(--control-height-compact);
22
+ --control-padding-x: var(--control-padding-x-compact);
23
+ --table-row-height: var(--table-row-height-compact);
24
+ --phi-unit: var(--space-3);
25
+ --checkbox-size: var(--checkbox-size-compact);
26
+ --switch-width: var(--switch-width-compact);
27
+ --switch-height: var(--switch-height-compact);
28
+ --switch-thumb-size: var(--switch-thumb-size-compact);
29
+ --switch-thumb-translate: var(--switch-thumb-translate-compact);
30
+ --table-cell-padding-y: var(--space-1);
31
+ /* Tighter overlay/card chrome at compact density (12px vertical). */
32
+ --space-chrome-y: var(--space-3);
33
+ }
21
34
 
22
- .ui-density-default {
23
- --control-height: var(--control-height-default);
24
- --control-padding-x: var(--control-padding-x-default);
25
- --table-row-height: var(--table-row-height-default);
26
- --table-cell-padding-y: var(--space-2);
27
- }
35
+ .ui-density-default,
36
+ :root[data-density="default"] {
37
+ --control-height: var(--control-height-default);
38
+ --control-padding-x: var(--control-padding-x-default);
39
+ --table-row-height: var(--table-row-height-default);
40
+ --table-cell-padding-y: var(--space-2);
41
+ }
28
42
 
29
- .ui-density-comfortable {
30
- --control-height: var(--control-height-comfortable);
31
- /* Comfortable steps 44 → 36 → 32 (design spacing-density), steeper than the
32
- * default 4px step so sm/xs aren't left 4px too tall. */
33
- --control-height-sm: calc(var(--control-height) - 0.5rem);
34
- --control-height-xs: calc(var(--control-height) - 0.75rem);
35
- --control-padding-x: var(--control-padding-x-comfortable);
36
- --table-row-height: var(--table-row-height-comfortable);
37
- --phi-unit: var(--space-6);
38
- --checkbox-size: var(--checkbox-size-comfortable);
39
- --switch-width: var(--switch-width-comfortable);
40
- --switch-height: var(--switch-height-comfortable);
41
- --switch-thumb-size: var(--switch-thumb-size-comfortable);
42
- --switch-thumb-translate: var(--switch-thumb-translate-comfortable);
43
- --table-cell-padding-y: var(--space-2);
44
- /* Roomier overlay/card chrome at comfortable density (24px vertical). */
45
- --space-chrome-y: var(--space-6);
46
- }
43
+ .ui-density-comfortable,
44
+ :root[data-density="comfortable"] {
45
+ --control-height: var(--control-height-comfortable);
46
+ /* Comfortable steps 44 36 32 (design spacing-density), steeper than the
47
+ * default 4px step so sm/xs aren't left 4px too tall. */
48
+ --control-height-sm: calc(var(--control-height) - 0.5rem);
49
+ --control-height-xs: calc(var(--control-height) - 0.75rem);
50
+ --control-padding-x: var(--control-padding-x-comfortable);
51
+ --table-row-height: var(--table-row-height-comfortable);
52
+ --phi-unit: var(--space-6);
53
+ --checkbox-size: var(--checkbox-size-comfortable);
54
+ --switch-width: var(--switch-width-comfortable);
55
+ --switch-height: var(--switch-height-comfortable);
56
+ --switch-thumb-size: var(--switch-thumb-size-comfortable);
57
+ --switch-thumb-translate: var(--switch-thumb-translate-comfortable);
58
+ --table-cell-padding-y: var(--space-2);
59
+ /* Roomier overlay/card chrome at comfortable density (24px vertical). */
60
+ --space-chrome-y: var(--space-6);
47
61
  }
@@ -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";
@@ -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.14.0",
3
+ "version": "13.15.1",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@10.29.1",
6
6
  "sideEffects": false,