@umodeapporg/ui 0.19.0 → 0.21.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.
@@ -0,0 +1,13 @@
1
+ interface IPumpkinGlyphProps {
2
+ /** Same unit as `MaterialSymbol`: pixels at the default root size, rendered in rem. */
3
+ size: number;
4
+ /** Mirrors the FILL axis of the icon font: solid while its option is the active one. */
5
+ filled?: boolean;
6
+ }
7
+ /**
8
+ * The icon font has no pumpkin, so the Halloween option draws its own. It is a single-ink
9
+ * glyph on the 24-unit grid of the font and takes `currentColor`, which keeps it in step with
10
+ * the other positions of the knob: outlined at rest, solid with a carved face while active.
11
+ */
12
+ declare const PumpkinGlyph: React.FC<IPumpkinGlyphProps>;
13
+ export default PumpkinGlyph;
@@ -0,0 +1,29 @@
1
+ import { jsxs, jsx, Fragment } from "react/jsx-runtime";
2
+ const STEM_PATH = "M11 6.8c0-1.9.7-3.3 2.2-4.3l1.1 1.3c-.9.7-1.3 1.7-1.3 3z";
3
+ const BODY_PATH = "M3 14a9 7.5 0 1 0 18 0a9 7.5 0 1 0-18 0z";
4
+ const FACE_PATH = "M7.2 13.2h3l-1.5-2.8zM13.8 13.2h3l-1.5-2.8zM7 15.6l2 1 1.5-1 1.5 1 1.5-1 1.5 1 2-1c-.8 2.6-2.7 4-5 4s-4.2-1.4-5-4z";
5
+ const PumpkinGlyph = ({ size, filled = false }) => {
6
+ const sizeInRem = `${size / 16}rem`;
7
+ return /* @__PURE__ */ jsxs(
8
+ "svg",
9
+ {
10
+ viewBox: "0 0 24 24",
11
+ "aria-hidden": true,
12
+ focusable: "false",
13
+ fill: "currentColor",
14
+ className: "shrink-0",
15
+ style: { width: sizeInRem, height: sizeInRem },
16
+ children: [
17
+ /* @__PURE__ */ jsx("path", { d: STEM_PATH }),
18
+ filled ? /* @__PURE__ */ jsx("path", { d: `${BODY_PATH}${FACE_PATH}`, fillRule: "evenodd" }) : /* @__PURE__ */ jsxs(Fragment, { children: [
19
+ /* @__PURE__ */ jsx("path", { d: BODY_PATH, fill: "none", stroke: "currentColor", strokeWidth: 1.5 }),
20
+ /* @__PURE__ */ jsx("path", { d: FACE_PATH })
21
+ ] })
22
+ ]
23
+ }
24
+ );
25
+ };
26
+ export {
27
+ PumpkinGlyph as default
28
+ };
29
+ //# sourceMappingURL=PumpkinGlyph.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"PumpkinGlyph.js","sources":["../../../src/components/Shell/PumpkinGlyph.tsx"],"sourcesContent":["interface IPumpkinGlyphProps {\n /** Same unit as `MaterialSymbol`: pixels at the default root size, rendered in rem. */\n size: number;\n /** Mirrors the FILL axis of the icon font: solid while its option is the active one. */\n filled?: boolean;\n}\n\nconst STEM_PATH = 'M11 6.8c0-1.9.7-3.3 2.2-4.3l1.1 1.3c-.9.7-1.3 1.7-1.3 3z';\nconst BODY_PATH = 'M3 14a9 7.5 0 1 0 18 0a9 7.5 0 1 0-18 0z';\nconst FACE_PATH =\n 'M7.2 13.2h3l-1.5-2.8zM13.8 13.2h3l-1.5-2.8zM7 15.6l2 1 1.5-1 1.5 1 1.5-1 1.5 1 2-1c-.8 2.6-2.7 4-5 4s-4.2-1.4-5-4z';\n\n/**\n * The icon font has no pumpkin, so the Halloween option draws its own. It is a single-ink\n * glyph on the 24-unit grid of the font and takes `currentColor`, which keeps it in step with\n * the other positions of the knob: outlined at rest, solid with a carved face while active.\n */\nconst PumpkinGlyph: React.FC<IPumpkinGlyphProps> = ({ size, filled = false }) => {\n const sizeInRem = `${size / 16}rem`;\n\n return (\n <svg\n viewBox=\"0 0 24 24\"\n aria-hidden\n focusable=\"false\"\n fill=\"currentColor\"\n className=\"shrink-0\"\n style={{ width: sizeInRem, height: sizeInRem }}\n >\n <path d={STEM_PATH} />\n {filled ? (\n <path d={`${BODY_PATH}${FACE_PATH}`} fillRule=\"evenodd\" />\n ) : (\n <>\n <path d={BODY_PATH} fill=\"none\" stroke=\"currentColor\" strokeWidth={1.5} />\n <path d={FACE_PATH} />\n </>\n )}\n </svg>\n );\n};\n\nexport default PumpkinGlyph;\n"],"names":[],"mappings":";AAOA,MAAM,YAAY;AAClB,MAAM,YAAY;AAClB,MAAM,YACJ;AAOF,MAAM,eAA6C,CAAC,EAAE,MAAM,SAAS,YAAY;AAC/E,QAAM,YAAY,GAAG,OAAO,EAAE;AAE9B,SACE;AAAA,IAAC;AAAA,IAAA;AAAA,MACC,SAAQ;AAAA,MACR,eAAW;AAAA,MACX,WAAU;AAAA,MACV,MAAK;AAAA,MACL,WAAU;AAAA,MACV,OAAO,EAAE,OAAO,WAAW,QAAQ,UAAA;AAAA,MAEnC,UAAA;AAAA,QAAA,oBAAC,QAAA,EAAK,GAAG,UAAA,CAAW;AAAA,QACnB,SACC,oBAAC,QAAA,EAAK,GAAG,GAAG,SAAS,GAAG,SAAS,IAAI,UAAS,UAAA,CAAU,IAExD,qBAAA,UAAA,EACE,UAAA;AAAA,UAAA,oBAAC,QAAA,EAAK,GAAG,WAAW,MAAK,QAAO,QAAO,gBAAe,aAAa,IAAA,CAAK;AAAA,UACxE,oBAAC,QAAA,EAAK,GAAG,UAAA,CAAW;AAAA,QAAA,EAAA,CACtB;AAAA,MAAA;AAAA,IAAA;AAAA,EAAA;AAIR;"}
@@ -1,11 +1,18 @@
1
+ import { TUiThemeSkin } from '../../helpers/themeSkins';
1
2
  export type TUiThemePreference = 'light' | 'system' | 'dark';
2
- interface IThemeToggleProps {
3
- preference: TUiThemePreference;
4
- onChange: (preference: TUiThemePreference) => void;
3
+ /** A skin is one more position of the knob: choosing it is choosing its scheme, re-coloured. */
4
+ export type TUiThemeToggleOption = TUiThemePreference | TUiThemeSkin;
5
+ interface IThemeToggleProps<TOption extends TUiThemeToggleOption = TUiThemePreference> {
6
+ preference: TOption;
7
+ onChange: (preference: TOption) => void;
8
+ /** Positions of the knob, in order. Defaults to light, system and dark; the host adds a
9
+ skin for as long as it is on offer and drops it again when the season ends. */
10
+ options?: ReadonlyArray<TOption>;
5
11
  }
6
12
  /**
7
- * Three-state control: an explicit light/dark choice, or `system` to follow the OS. The host
8
- * owns the preference (persistence + writing `data-theme` on <html>); this renders the knob.
13
+ * The theme control: an explicit light/dark choice, `system` to follow the OS, and any skin
14
+ * the host is offering. The host owns the preference (persistence + writing `data-theme` and
15
+ * `data-theme-skin` on <html>); this renders the knob.
9
16
  */
10
- declare const ThemeToggle: React.FC<IThemeToggleProps>;
17
+ declare const ThemeToggle: <TOption extends TUiThemeToggleOption = TUiThemePreference>({ preference, onChange, options, }: IThemeToggleProps<TOption>) => import("react").JSX.Element;
11
18
  export default ThemeToggle;
@@ -5,20 +5,31 @@ import ControlledButton from "../Form/ControlledButton.js";
5
5
  import ControlledTooltip from "../UI/ControlledTooltip.js";
6
6
  import { MaterialSymbol } from "../UI/MaterialSymbol/index.js";
7
7
  import { HEADER_CONTROL_SHELL_CLASS, HEADER_CONTROL_SIZE_REM } from "./headerControlStyles.js";
8
+ import PumpkinGlyph from "./PumpkinGlyph.js";
8
9
  const OPTION_SIZE_REM = HEADER_CONTROL_SIZE_REM;
9
- const OPTIONS = [
10
- { value: "light", icon: "light_mode", labelKey: "theme.light" },
11
- { value: "system", icon: "computer", labelKey: "theme.system" },
12
- { value: "dark", icon: "dark_mode", labelKey: "theme.dark" }
13
- ];
14
- const ThemeToggle = ({ preference, onChange }) => {
10
+ const OPTION_ICON_SIZE = 15;
11
+ const renderSymbol = (icon) => (isActive) => /* @__PURE__ */ jsx(MaterialSymbol, { icon, size: OPTION_ICON_SIZE, fill: isActive });
12
+ const OPTION_PRESENTATION = {
13
+ light: { labelKey: "theme.light", renderIcon: renderSymbol("light_mode") },
14
+ system: { labelKey: "theme.system", renderIcon: renderSymbol("computer") },
15
+ dark: { labelKey: "theme.dark", renderIcon: renderSymbol("dark_mode") },
16
+ halloween: {
17
+ labelKey: "theme.halloween",
18
+ renderIcon: (isActive) => /* @__PURE__ */ jsx(PumpkinGlyph, { size: OPTION_ICON_SIZE, filled: isActive })
19
+ }
20
+ };
21
+ const DEFAULT_OPTIONS = ["light", "system", "dark"];
22
+ const ThemeToggle = ({
23
+ preference,
24
+ onChange,
25
+ options
26
+ }) => {
15
27
  const { t } = useTranslation("ui");
16
- const activeIndex = Math.max(
17
- 0,
18
- OPTIONS.findIndex((option) => option.value === preference)
19
- );
28
+ const requestedOptions = Array.isArray(options) && options.length > 0 ? options : DEFAULT_OPTIONS;
29
+ const visibleOptions = requestedOptions.filter((option) => Boolean(OPTION_PRESENTATION[option]));
30
+ const activeIndex = visibleOptions.indexOf(preference);
20
31
  return /* @__PURE__ */ jsx("div", { role: "group", "aria-label": t("theme.toggle"), className: HEADER_CONTROL_SHELL_CLASS, children: /* @__PURE__ */ jsxs("div", { className: "relative flex items-center", children: [
21
- /* @__PURE__ */ jsx(
32
+ activeIndex >= 0 && /* @__PURE__ */ jsx(
22
33
  "div",
23
34
  {
24
35
  "aria-hidden": true,
@@ -30,22 +41,23 @@ const ThemeToggle = ({ preference, onChange }) => {
30
41
  }
31
42
  }
32
43
  ),
33
- OPTIONS.map((option) => {
34
- const isActive = option.value === preference;
35
- return /* @__PURE__ */ jsx(ControlledTooltip, { content: t(option.labelKey), children: /* @__PURE__ */ jsx(
44
+ visibleOptions.map((option) => {
45
+ const isActive = option === preference;
46
+ const presentation = OPTION_PRESENTATION[option];
47
+ return /* @__PURE__ */ jsx(ControlledTooltip, { content: t(presentation.labelKey), children: /* @__PURE__ */ jsx(
36
48
  ControlledButton,
37
49
  {
38
50
  defaultHtmlButton: true,
39
51
  "aria-pressed": isActive,
40
- onClick: () => onChange(option.value),
52
+ onClick: () => onChange(option),
41
53
  className: classNames(
42
54
  "relative z-10 flex shrink-0 items-center justify-center rounded-full leading-none transition-colors duration-base",
43
55
  isActive ? "text-brand-emphasis" : "text-foreground-faint hover:text-foreground-muted"
44
56
  ),
45
57
  style: { width: `${OPTION_SIZE_REM}rem`, height: `${OPTION_SIZE_REM}rem` },
46
- children: /* @__PURE__ */ jsx(MaterialSymbol, { icon: option.icon, size: 15, fill: isActive })
58
+ children: presentation.renderIcon(isActive)
47
59
  }
48
- ) }, option.value);
60
+ ) }, option);
49
61
  })
50
62
  ] }) });
51
63
  };
@@ -1 +1 @@
1
- {"version":3,"file":"ThemeToggle.js","sources":["../../../src/components/Shell/ThemeToggle.tsx"],"sourcesContent":["import classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\n\nimport { HEADER_CONTROL_SHELL_CLASS, HEADER_CONTROL_SIZE_REM } from './headerControlStyles';\n\nconst OPTION_SIZE_REM = HEADER_CONTROL_SIZE_REM;\n\nexport type TUiThemePreference = 'light' | 'system' | 'dark';\n\nconst OPTIONS: Array<{ value: TUiThemePreference; icon: SymbolCodepoints; labelKey: string }> = [\n { value: 'light', icon: 'light_mode', labelKey: 'theme.light' },\n { value: 'system', icon: 'computer', labelKey: 'theme.system' },\n { value: 'dark', icon: 'dark_mode', labelKey: 'theme.dark' },\n];\n\ninterface IThemeToggleProps {\n preference: TUiThemePreference;\n onChange: (preference: TUiThemePreference) => void;\n}\n\n/**\n * Three-state control: an explicit light/dark choice, or `system` to follow the OS. The host\n * owns the preference (persistence + writing `data-theme` on <html>); this renders the knob.\n */\nconst ThemeToggle: React.FC<IThemeToggleProps> = ({ preference, onChange }) => {\n const { t } = useTranslation('ui');\n\n const activeIndex = Math.max(\n 0,\n OPTIONS.findIndex((option) => option.value === preference),\n );\n\n return (\n <div role=\"group\" aria-label={t('theme.toggle')} className={HEADER_CONTROL_SHELL_CLASS}>\n <div className=\"relative flex items-center\">\n <div\n aria-hidden\n className=\"pointer-events-none absolute left-0 top-0 rounded-full bg-surface shadow-elevation1 ring-1 ring-border transition-transform duration-base ease-enter\"\n style={{\n width: `${OPTION_SIZE_REM}rem`,\n height: `${OPTION_SIZE_REM}rem`,\n transform: `translateX(${activeIndex * OPTION_SIZE_REM}rem)`,\n }}\n />\n\n {OPTIONS.map((option) => {\n const isActive = option.value === preference;\n\n return (\n <ControlledTooltip key={option.value} content={t(option.labelKey)}>\n <ControlledButton\n defaultHtmlButton\n aria-pressed={isActive}\n onClick={() => onChange(option.value)}\n className={classNames(\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full leading-none transition-colors duration-base',\n isActive ? 'text-brand-emphasis' : 'text-foreground-faint hover:text-foreground-muted',\n )}\n style={{ width: `${OPTION_SIZE_REM}rem`, height: `${OPTION_SIZE_REM}rem` }}\n >\n <MaterialSymbol icon={option.icon} size={15} fill={isActive} />\n </ControlledButton>\n </ControlledTooltip>\n );\n })}\n </div>\n </div>\n );\n};\n\nexport default ThemeToggle;\n"],"names":[],"mappings":";;;;;;;AASA,MAAM,kBAAkB;AAIxB,MAAM,UAA0F;AAAA,EAC9F,EAAE,OAAO,SAAS,MAAM,cAAc,UAAU,cAAA;AAAA,EAChD,EAAE,OAAO,UAAU,MAAM,YAAY,UAAU,eAAA;AAAA,EAC/C,EAAE,OAAO,QAAQ,MAAM,aAAa,UAAU,aAAA;AAChD;AAWA,MAAM,cAA2C,CAAC,EAAE,YAAY,eAAe;AAC7E,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AAEjC,QAAM,cAAc,KAAK;AAAA,IACvB;AAAA,IACA,QAAQ,UAAU,CAAC,WAAW,OAAO,UAAU,UAAU;AAAA,EAAA;AAG3D,SACE,oBAAC,OAAA,EAAI,MAAK,SAAQ,cAAY,EAAE,cAAc,GAAG,WAAW,4BAC1D,UAAA,qBAAC,OAAA,EAAI,WAAU,8BACb,UAAA;AAAA,IAAA;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAU;AAAA,QACV,OAAO;AAAA,UACL,OAAO,GAAG,eAAe;AAAA,UACzB,QAAQ,GAAG,eAAe;AAAA,UAC1B,WAAW,cAAc,cAAc,eAAe;AAAA,QAAA;AAAA,MACxD;AAAA,IAAA;AAAA,IAGD,QAAQ,IAAI,CAAC,WAAW;AACvB,YAAM,WAAW,OAAO,UAAU;AAElC,iCACG,mBAAA,EAAqC,SAAS,EAAE,OAAO,QAAQ,GAC9D,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,mBAAiB;AAAA,UACjB,gBAAc;AAAA,UACd,SAAS,MAAM,SAAS,OAAO,KAAK;AAAA,UACpC,WAAW;AAAA,YACT;AAAA,YACA,WAAW,wBAAwB;AAAA,UAAA;AAAA,UAErC,OAAO,EAAE,OAAO,GAAG,eAAe,OAAO,QAAQ,GAAG,eAAe,MAAA;AAAA,UAEnE,UAAA,oBAAC,kBAAe,MAAM,OAAO,MAAM,MAAM,IAAI,MAAM,SAAA,CAAU;AAAA,QAAA;AAAA,MAAA,EAC/D,GAZsB,OAAO,KAa/B;AAAA,IAEJ,CAAC;AAAA,EAAA,EAAA,CACH,EAAA,CACF;AAEJ;"}
1
+ {"version":3,"file":"ThemeToggle.js","sources":["../../../src/components/Shell/ThemeToggle.tsx"],"sourcesContent":["import { ReactNode } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledTooltip from '@/components/UI/ControlledTooltip';\nimport { MaterialSymbol, SymbolCodepoints } from '@/components/UI/MaterialSymbol';\nimport { TUiThemeSkin } from '@/helpers/themeSkins';\n\nimport { HEADER_CONTROL_SHELL_CLASS, HEADER_CONTROL_SIZE_REM } from './headerControlStyles';\nimport PumpkinGlyph from './PumpkinGlyph';\n\nconst OPTION_SIZE_REM = HEADER_CONTROL_SIZE_REM;\nconst OPTION_ICON_SIZE = 15;\n\nexport type TUiThemePreference = 'light' | 'system' | 'dark';\n\n/** A skin is one more position of the knob: choosing it is choosing its scheme, re-coloured. */\nexport type TUiThemeToggleOption = TUiThemePreference | TUiThemeSkin;\n\ninterface IOptionPresentation {\n labelKey: string;\n renderIcon: (isActive: boolean) => ReactNode;\n}\n\nconst renderSymbol = (icon: SymbolCodepoints) => (isActive: boolean) => (\n <MaterialSymbol icon={icon} size={OPTION_ICON_SIZE} fill={isActive} />\n);\n\nconst OPTION_PRESENTATION: Record<TUiThemeToggleOption, IOptionPresentation> = {\n light: { labelKey: 'theme.light', renderIcon: renderSymbol('light_mode') },\n system: { labelKey: 'theme.system', renderIcon: renderSymbol('computer') },\n dark: { labelKey: 'theme.dark', renderIcon: renderSymbol('dark_mode') },\n halloween: {\n labelKey: 'theme.halloween',\n renderIcon: (isActive) => <PumpkinGlyph size={OPTION_ICON_SIZE} filled={isActive} />,\n },\n};\n\nconst DEFAULT_OPTIONS: ReadonlyArray<TUiThemePreference> = ['light', 'system', 'dark'];\n\ninterface IThemeToggleProps<TOption extends TUiThemeToggleOption = TUiThemePreference> {\n preference: TOption;\n onChange: (preference: TOption) => void;\n /** Positions of the knob, in order. Defaults to light, system and dark; the host adds a\n skin for as long as it is on offer and drops it again when the season ends. */\n options?: ReadonlyArray<TOption>;\n}\n\n/**\n * The theme control: an explicit light/dark choice, `system` to follow the OS, and any skin\n * the host is offering. The host owns the preference (persistence + writing `data-theme` and\n * `data-theme-skin` on <html>); this renders the knob.\n */\nconst ThemeToggle = <TOption extends TUiThemeToggleOption = TUiThemePreference>({\n preference,\n onChange,\n options,\n}: IThemeToggleProps<TOption>) => {\n const { t } = useTranslation('ui');\n\n const requestedOptions = (\n Array.isArray(options) && options.length > 0 ? options : DEFAULT_OPTIONS\n ) as ReadonlyArray<TOption>;\n const visibleOptions = requestedOptions.filter((option) => Boolean(OPTION_PRESENTATION[option]));\n const activeIndex = visibleOptions.indexOf(preference);\n\n return (\n <div role=\"group\" aria-label={t('theme.toggle')} className={HEADER_CONTROL_SHELL_CLASS}>\n <div className=\"relative flex items-center\">\n {activeIndex >= 0 && (\n <div\n aria-hidden\n className=\"pointer-events-none absolute left-0 top-0 rounded-full bg-surface shadow-elevation1 ring-1 ring-border transition-transform duration-base ease-enter\"\n style={{\n width: `${OPTION_SIZE_REM}rem`,\n height: `${OPTION_SIZE_REM}rem`,\n transform: `translateX(${activeIndex * OPTION_SIZE_REM}rem)`,\n }}\n />\n )}\n\n {visibleOptions.map((option) => {\n const isActive = option === preference;\n const presentation = OPTION_PRESENTATION[option];\n\n return (\n <ControlledTooltip key={option} content={t(presentation.labelKey)}>\n <ControlledButton\n defaultHtmlButton\n aria-pressed={isActive}\n onClick={() => onChange(option)}\n className={classNames(\n 'relative z-10 flex shrink-0 items-center justify-center rounded-full leading-none transition-colors duration-base',\n isActive ? 'text-brand-emphasis' : 'text-foreground-faint hover:text-foreground-muted',\n )}\n style={{ width: `${OPTION_SIZE_REM}rem`, height: `${OPTION_SIZE_REM}rem` }}\n >\n {presentation.renderIcon(isActive)}\n </ControlledButton>\n </ControlledTooltip>\n );\n })}\n </div>\n </div>\n );\n};\n\nexport default ThemeToggle;\n"],"names":[],"mappings":";;;;;;;;AAYA,MAAM,kBAAkB;AACxB,MAAM,mBAAmB;AAYzB,MAAM,eAAe,CAAC,SAA2B,CAAC,aAChD,oBAAC,gBAAA,EAAe,MAAY,MAAM,kBAAkB,MAAM,SAAA,CAAU;AAGtE,MAAM,sBAAyE;AAAA,EAC7E,OAAO,EAAE,UAAU,eAAe,YAAY,aAAa,YAAY,EAAA;AAAA,EACvE,QAAQ,EAAE,UAAU,gBAAgB,YAAY,aAAa,UAAU,EAAA;AAAA,EACvE,MAAM,EAAE,UAAU,cAAc,YAAY,aAAa,WAAW,EAAA;AAAA,EACpE,WAAW;AAAA,IACT,UAAU;AAAA,IACV,YAAY,CAAC,aAAa,oBAAC,gBAAa,MAAM,kBAAkB,QAAQ,SAAA,CAAU;AAAA,EAAA;AAEtF;AAEA,MAAM,kBAAqD,CAAC,SAAS,UAAU,MAAM;AAerF,MAAM,cAAc,CAA4D;AAAA,EAC9E;AAAA,EACA;AAAA,EACA;AACF,MAAkC;AAChC,QAAM,EAAE,EAAA,IAAM,eAAe,IAAI;AAEjC,QAAM,mBACJ,MAAM,QAAQ,OAAO,KAAK,QAAQ,SAAS,IAAI,UAAU;AAE3D,QAAM,iBAAiB,iBAAiB,OAAO,CAAC,WAAW,QAAQ,oBAAoB,MAAM,CAAC,CAAC;AAC/F,QAAM,cAAc,eAAe,QAAQ,UAAU;AAErD,SACE,oBAAC,OAAA,EAAI,MAAK,SAAQ,cAAY,EAAE,cAAc,GAAG,WAAW,4BAC1D,UAAA,qBAAC,OAAA,EAAI,WAAU,8BACZ,UAAA;AAAA,IAAA,eAAe,KACd;AAAA,MAAC;AAAA,MAAA;AAAA,QACC,eAAW;AAAA,QACX,WAAU;AAAA,QACV,OAAO;AAAA,UACL,OAAO,GAAG,eAAe;AAAA,UACzB,QAAQ,GAAG,eAAe;AAAA,UAC1B,WAAW,cAAc,cAAc,eAAe;AAAA,QAAA;AAAA,MACxD;AAAA,IAAA;AAAA,IAIH,eAAe,IAAI,CAAC,WAAW;AAC9B,YAAM,WAAW,WAAW;AAC5B,YAAM,eAAe,oBAAoB,MAAM;AAE/C,iCACG,mBAAA,EAA+B,SAAS,EAAE,aAAa,QAAQ,GAC9D,UAAA;AAAA,QAAC;AAAA,QAAA;AAAA,UACC,mBAAiB;AAAA,UACjB,gBAAc;AAAA,UACd,SAAS,MAAM,SAAS,MAAM;AAAA,UAC9B,WAAW;AAAA,YACT;AAAA,YACA,WAAW,wBAAwB;AAAA,UAAA;AAAA,UAErC,OAAO,EAAE,OAAO,GAAG,eAAe,OAAO,QAAQ,GAAG,eAAe,MAAA;AAAA,UAElE,UAAA,aAAa,WAAW,QAAQ;AAAA,QAAA;AAAA,MAAA,KAXb,MAaxB;AAAA,IAEJ,CAAC;AAAA,EAAA,EAAA,CACH,EAAA,CACF;AAEJ;"}
@@ -3,6 +3,7 @@ import { useRef, useMemo, useEffect } from "react";
3
3
  import { Transformer, Circle } from "react-konva";
4
4
  import { readThemeToken } from "../../../../helpers/themeTokens.js";
5
5
  import { useResolvedTheme } from "../../../../hooks/useResolvedTheme.js";
6
+ import { useThemeSkin } from "../../../../hooks/useThemeSkin.js";
6
7
  import { SELECTION_BORDER_PX, SELECTION_ANCHOR_PX, SEGMENT_HANDLE_PX } from "../helpers/annotationCanvas.js";
7
8
  import { isSegmentElement } from "../interfaces/annotationDocument.js";
8
9
  const MIN_TRANSFORM_SIZE = 8;
@@ -15,13 +16,14 @@ const AnnotationSelection = ({
15
16
  onChange
16
17
  }) => {
17
18
  const resolvedTheme = useResolvedTheme();
19
+ const themeSkin = useThemeSkin();
18
20
  const transformerRef = useRef(null);
19
21
  const tokens = useMemo(
20
22
  () => ({
21
23
  accent: readThemeToken("brand", "#8B5CF6"),
22
24
  surface: readThemeToken("surface", "#FFFFFF")
23
25
  }),
24
- [resolvedTheme]
26
+ [resolvedTheme, themeSkin]
25
27
  );
26
28
  const segment = selectedElement && isSegmentElement(selectedElement) ? selectedElement : null;
27
29
  const transformableId = selectedElement && !segment ? selectedElement.id : null;
@@ -1 +1 @@
1
- {"version":3,"file":"AnnotationSelection.js","sources":["../../../../../src/components/UI/ImageAnnotator/canvas/AnnotationSelection.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef } from 'react';\nimport Konva from 'konva';\nimport { Circle, Transformer } from 'react-konva';\n\nimport { readThemeToken } from '@/helpers/themeTokens';\nimport { useResolvedTheme } from '@/hooks/useResolvedTheme';\n\nimport { SEGMENT_HANDLE_PX, SELECTION_ANCHOR_PX, SELECTION_BORDER_PX } from '../helpers/annotationCanvas';\nimport { isSegmentElement, TAnnotationElement } from '../interfaces/annotationDocument';\n\nconst MIN_TRANSFORM_SIZE = 8;\nconst ROTATION_SNAPS = [0, 45, 90, 135, 180, 225, 270, 315];\n\nconst TEXT_ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'middle-left', 'middle-right'];\n\ninterface IAnnotationSelectionProps {\n selectedElement: TAnnotationElement | null;\n viewScale: number;\n getNode: (id: string) => Konva.Node | null;\n onChange: (id: string, updater: (element: TAnnotationElement) => TAnnotationElement) => void;\n}\n\nconst AnnotationSelection: React.FC<IAnnotationSelectionProps> = ({\n selectedElement,\n viewScale,\n getNode,\n onChange,\n}) => {\n const resolvedTheme = useResolvedTheme();\n const transformerRef = useRef<Konva.Transformer | null>(null);\n\n const tokens = useMemo(\n () => ({\n accent: readThemeToken('brand', '#8B5CF6'),\n surface: readThemeToken('surface', '#FFFFFF'),\n }),\n [resolvedTheme],\n );\n\n const segment = selectedElement && isSegmentElement(selectedElement) ? selectedElement : null;\n const transformableId = selectedElement && !segment ? selectedElement.id : null;\n\n useEffect(() => {\n const transformer = transformerRef.current;\n if (!transformer) return;\n\n const node = transformableId ? getNode(transformableId) : null;\n transformer.nodes(node ? [node] : []);\n transformer.forceUpdate();\n transformer.getLayer()?.batchDraw();\n }, [transformableId, selectedElement, getNode]);\n\n const anchorSize = SELECTION_ANCHOR_PX / viewScale;\n const borderWidth = SELECTION_BORDER_PX / viewScale;\n\n /**\n * Endpoint drags move the Konva node directly and only commit on release: routing every\n * pointer frame through React state would push one history entry per frame.\n */\n const applyLivePoints = (handle: Konva.Node, isStart: boolean) => {\n if (!segment) return;\n const node = getNode(segment.id);\n if (!node) return;\n\n const start = isStart ? handle.position() : segment.start;\n const end = isStart ? segment.end : handle.position();\n node.position({ x: start.x, y: start.y });\n (node as Konva.Line).points([0, 0, end.x - start.x, end.y - start.y]);\n node.getLayer()?.batchDraw();\n };\n\n const commitEndpoint = (handle: Konva.Node, isStart: boolean) => {\n if (!segment) return;\n const { x, y } = handle.position();\n\n onChange(segment.id, (current) =>\n isSegmentElement(current) ? { ...current, [isStart ? 'start' : 'end']: { x, y } } : current,\n );\n };\n\n const handleProps = {\n radius: SEGMENT_HANDLE_PX / viewScale,\n fill: tokens.surface,\n stroke: tokens.accent,\n strokeWidth: borderWidth * 1.5,\n strokeScaleEnabled: false,\n draggable: true,\n hitStrokeWidth: (SEGMENT_HANDLE_PX * 2) / viewScale,\n onMouseEnter: (event: Konva.KonvaEventObject<MouseEvent>) => {\n const container = event.target.getStage()?.container();\n if (container) container.style.cursor = 'move';\n },\n onMouseLeave: (event: Konva.KonvaEventObject<MouseEvent>) => {\n const container = event.target.getStage()?.container();\n if (container) container.style.cursor = 'default';\n },\n };\n\n return (\n <>\n <Transformer\n ref={transformerRef}\n visible={Boolean(transformableId)}\n rotateEnabled={selectedElement?.type !== 'freehand'}\n enabledAnchors={selectedElement?.type === 'text' ? TEXT_ANCHORS : undefined}\n keepRatio\n shouldOverdrawWholeArea\n ignoreStroke\n rotationSnaps={ROTATION_SNAPS}\n rotationSnapTolerance={4}\n anchorSize={anchorSize}\n anchorCornerRadius={anchorSize / 4}\n anchorFill={tokens.surface}\n anchorStroke={tokens.accent}\n anchorStrokeWidth={borderWidth}\n borderStroke={tokens.accent}\n borderStrokeWidth={borderWidth}\n borderDash={[6 / viewScale, 4 / viewScale]}\n padding={4 / viewScale}\n boundBoxFunc={(oldBox, newBox) =>\n newBox.width < MIN_TRANSFORM_SIZE || newBox.height < MIN_TRANSFORM_SIZE ? oldBox : newBox\n }\n />\n\n {segment && (\n <>\n <Circle\n {...handleProps}\n x={segment.start.x}\n y={segment.start.y}\n onDragMove={(event) => applyLivePoints(event.target, true)}\n onDragEnd={(event) => commitEndpoint(event.target, true)}\n />\n <Circle\n {...handleProps}\n x={segment.end.x}\n y={segment.end.y}\n onDragMove={(event) => applyLivePoints(event.target, false)}\n onDragEnd={(event) => commitEndpoint(event.target, false)}\n />\n </>\n )}\n </>\n );\n};\n\nexport default AnnotationSelection;\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"AnnotationSelection.js","sources":["../../../../../src/components/UI/ImageAnnotator/canvas/AnnotationSelection.tsx"],"sourcesContent":["import { useEffect, useMemo, useRef } from 'react';\nimport Konva from 'konva';\nimport { Circle, Transformer } from 'react-konva';\n\nimport { readThemeToken } from '@/helpers/themeTokens';\nimport { useResolvedTheme } from '@/hooks/useResolvedTheme';\nimport { useThemeSkin } from '@/hooks/useThemeSkin';\n\nimport { SEGMENT_HANDLE_PX, SELECTION_ANCHOR_PX, SELECTION_BORDER_PX } from '../helpers/annotationCanvas';\nimport { isSegmentElement, TAnnotationElement } from '../interfaces/annotationDocument';\n\nconst MIN_TRANSFORM_SIZE = 8;\nconst ROTATION_SNAPS = [0, 45, 90, 135, 180, 225, 270, 315];\n\nconst TEXT_ANCHORS = ['top-left', 'top-right', 'bottom-left', 'bottom-right', 'middle-left', 'middle-right'];\n\ninterface IAnnotationSelectionProps {\n selectedElement: TAnnotationElement | null;\n viewScale: number;\n getNode: (id: string) => Konva.Node | null;\n onChange: (id: string, updater: (element: TAnnotationElement) => TAnnotationElement) => void;\n}\n\nconst AnnotationSelection: React.FC<IAnnotationSelectionProps> = ({\n selectedElement,\n viewScale,\n getNode,\n onChange,\n}) => {\n const resolvedTheme = useResolvedTheme();\n const themeSkin = useThemeSkin();\n const transformerRef = useRef<Konva.Transformer | null>(null);\n\n const tokens = useMemo(\n () => ({\n accent: readThemeToken('brand', '#8B5CF6'),\n surface: readThemeToken('surface', '#FFFFFF'),\n }),\n [resolvedTheme, themeSkin],\n );\n\n const segment = selectedElement && isSegmentElement(selectedElement) ? selectedElement : null;\n const transformableId = selectedElement && !segment ? selectedElement.id : null;\n\n useEffect(() => {\n const transformer = transformerRef.current;\n if (!transformer) return;\n\n const node = transformableId ? getNode(transformableId) : null;\n transformer.nodes(node ? [node] : []);\n transformer.forceUpdate();\n transformer.getLayer()?.batchDraw();\n }, [transformableId, selectedElement, getNode]);\n\n const anchorSize = SELECTION_ANCHOR_PX / viewScale;\n const borderWidth = SELECTION_BORDER_PX / viewScale;\n\n /**\n * Endpoint drags move the Konva node directly and only commit on release: routing every\n * pointer frame through React state would push one history entry per frame.\n */\n const applyLivePoints = (handle: Konva.Node, isStart: boolean) => {\n if (!segment) return;\n const node = getNode(segment.id);\n if (!node) return;\n\n const start = isStart ? handle.position() : segment.start;\n const end = isStart ? segment.end : handle.position();\n node.position({ x: start.x, y: start.y });\n (node as Konva.Line).points([0, 0, end.x - start.x, end.y - start.y]);\n node.getLayer()?.batchDraw();\n };\n\n const commitEndpoint = (handle: Konva.Node, isStart: boolean) => {\n if (!segment) return;\n const { x, y } = handle.position();\n\n onChange(segment.id, (current) =>\n isSegmentElement(current) ? { ...current, [isStart ? 'start' : 'end']: { x, y } } : current,\n );\n };\n\n const handleProps = {\n radius: SEGMENT_HANDLE_PX / viewScale,\n fill: tokens.surface,\n stroke: tokens.accent,\n strokeWidth: borderWidth * 1.5,\n strokeScaleEnabled: false,\n draggable: true,\n hitStrokeWidth: (SEGMENT_HANDLE_PX * 2) / viewScale,\n onMouseEnter: (event: Konva.KonvaEventObject<MouseEvent>) => {\n const container = event.target.getStage()?.container();\n if (container) container.style.cursor = 'move';\n },\n onMouseLeave: (event: Konva.KonvaEventObject<MouseEvent>) => {\n const container = event.target.getStage()?.container();\n if (container) container.style.cursor = 'default';\n },\n };\n\n return (\n <>\n <Transformer\n ref={transformerRef}\n visible={Boolean(transformableId)}\n rotateEnabled={selectedElement?.type !== 'freehand'}\n enabledAnchors={selectedElement?.type === 'text' ? TEXT_ANCHORS : undefined}\n keepRatio\n shouldOverdrawWholeArea\n ignoreStroke\n rotationSnaps={ROTATION_SNAPS}\n rotationSnapTolerance={4}\n anchorSize={anchorSize}\n anchorCornerRadius={anchorSize / 4}\n anchorFill={tokens.surface}\n anchorStroke={tokens.accent}\n anchorStrokeWidth={borderWidth}\n borderStroke={tokens.accent}\n borderStrokeWidth={borderWidth}\n borderDash={[6 / viewScale, 4 / viewScale]}\n padding={4 / viewScale}\n boundBoxFunc={(oldBox, newBox) =>\n newBox.width < MIN_TRANSFORM_SIZE || newBox.height < MIN_TRANSFORM_SIZE ? oldBox : newBox\n }\n />\n\n {segment && (\n <>\n <Circle\n {...handleProps}\n x={segment.start.x}\n y={segment.start.y}\n onDragMove={(event) => applyLivePoints(event.target, true)}\n onDragEnd={(event) => commitEndpoint(event.target, true)}\n />\n <Circle\n {...handleProps}\n x={segment.end.x}\n y={segment.end.y}\n onDragMove={(event) => applyLivePoints(event.target, false)}\n onDragEnd={(event) => commitEndpoint(event.target, false)}\n />\n </>\n )}\n </>\n );\n};\n\nexport default AnnotationSelection;\n"],"names":[],"mappings":"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"}
@@ -8,6 +8,7 @@ import { MaterialSymbol } from "../MaterialSymbol/index.js";
8
8
  import { readThemeToken } from "../../../helpers/themeTokens.js";
9
9
  import { uiLogger } from "../../../helpers/uiLogger.js";
10
10
  import { useResolvedTheme } from "../../../hooks/useResolvedTheme.js";
11
+ import { useThemeSkin } from "../../../hooks/useThemeSkin.js";
11
12
  import { redrawStrokes, overlayHasPaint, exportMaskFile, drawStrokeTail } from "./maskStrokes.js";
12
13
  const IMAGE_MASK_BRUSH_MIN = 8;
13
14
  const IMAGE_MASK_BRUSH_MAX = 96;
@@ -24,6 +25,7 @@ const ImageMaskPainter = ({
24
25
  }) => {
25
26
  const { t } = useTranslation("ui");
26
27
  const resolvedTheme = useResolvedTheme();
28
+ const themeSkin = useThemeSkin();
27
29
  const canvasRef = useRef(null);
28
30
  const cursorRef = useRef(null);
29
31
  const strokesRef = useRef([]);
@@ -46,7 +48,7 @@ const ImageMaskPainter = ({
46
48
  }, [getContext, overlayColor]);
47
49
  useEffect(() => {
48
50
  if (ready) repaint();
49
- }, [ready, resolvedTheme, repaint]);
51
+ }, [ready, resolvedTheme, themeSkin, repaint]);
50
52
  const emitMask = useCallback(async () => {
51
53
  const sequence = ++emitSequenceRef.current;
52
54
  const canvas = canvasRef.current;
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":["../../../../src/components/UI/ImageMaskPainter/index.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledSlider from '@/components/UI/ControlledSlider';\nimport { MaterialSymbol } from '@/components/UI/MaterialSymbol';\nimport { readThemeToken } from '@/helpers/themeTokens';\nimport { uiLogger } from '@/helpers/uiLogger';\nimport { useResolvedTheme } from '@/hooks/useResolvedTheme';\n\nimport {\n drawStrokeTail,\n exportMaskFile,\n IMaskPoint,\n IMaskStroke,\n overlayHasPaint,\n redrawStrokes,\n TMaskBrushMode,\n} from './maskStrokes';\n\nexport type { TMaskBrushMode } from './maskStrokes';\n\n/** Brush diameter in on-screen pixels; converted to natural pixels per stroke. */\nexport const IMAGE_MASK_BRUSH_MIN = 8;\nexport const IMAGE_MASK_BRUSH_MAX = 96;\nconst DEFAULT_BRUSH_SIZE = 32;\nconst DEFAULT_MAX_HEIGHT = '70vh';\n\ninterface ILoadedImage {\n url: string;\n width: number;\n height: number;\n}\n\nexport interface IImageMaskPainterProps {\n imageUrl: string;\n /**\n * Called after every stroke, undo and clear with a PNG at the image's natural resolution\n * (white = painted, black = untouched), or null when nothing is painted.\n */\n onMaskChange: (mask: File | null) => void;\n disabled?: boolean;\n maskFileName?: string;\n /**\n * The tallest the image may get, as any CSS length. The image fills the available width up to\n * this height, scaled up when its natural size is smaller.\n */\n maxHeight?: string;\n /** Sizes the image with classes instead, at its natural size at most. Turns the fill off. */\n imageClassName?: string;\n className?: string;\n}\n\nconst ImageMaskPainter: React.FC<IImageMaskPainterProps> = ({\n imageUrl,\n onMaskChange,\n disabled = false,\n maskFileName = 'mask.png',\n maxHeight = DEFAULT_MAX_HEIGHT,\n imageClassName,\n className,\n}) => {\n const { t } = useTranslation('ui');\n const resolvedTheme = useResolvedTheme();\n\n const canvasRef = useRef<HTMLCanvasElement | null>(null);\n const cursorRef = useRef<HTMLSpanElement | null>(null);\n const strokesRef = useRef<Array<IMaskStroke>>([]);\n const activeStrokeRef = useRef<{ pointerId: number; stroke: IMaskStroke } | null>(null);\n const onMaskChangeRef = useRef(onMaskChange);\n const emitSequenceRef = useRef(0);\n\n const [loaded, setLoaded] = useState<ILoadedImage | null>(null);\n const ready = loaded?.url === imageUrl;\n const [mode, setMode] = useState<TMaskBrushMode>('paint');\n const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE);\n const [strokeCount, setStrokeCount] = useState(0);\n\n useEffect(() => {\n onMaskChangeRef.current = onMaskChange;\n }, [onMaskChange]);\n\n const overlayColor = useCallback(() => readThemeToken('brand'), []);\n\n const getContext = useCallback(() => canvasRef.current?.getContext('2d') ?? null, []);\n\n const repaint = useCallback(() => {\n const context = getContext();\n if (context) redrawStrokes(context, strokesRef.current, overlayColor());\n }, [getContext, overlayColor]);\n\n // The brand token differs per theme; strokes already on screen follow it.\n useEffect(() => {\n if (ready) repaint();\n }, [ready, resolvedTheme, repaint]);\n\n const emitMask = useCallback(async () => {\n const sequence = ++emitSequenceRef.current;\n const canvas = canvasRef.current;\n try {\n const mask = canvas && overlayHasPaint(canvas) ? await exportMaskFile(canvas, maskFileName) : null;\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(mask);\n } catch (error) {\n uiLogger.error('Failed to export the image mask', error);\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(null);\n }\n }, [maskFileName]);\n\n const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {\n const canvas = canvasRef.current;\n const { naturalWidth, naturalHeight } = event.currentTarget;\n if (!canvas || !naturalWidth || !naturalHeight) return;\n canvas.width = naturalWidth;\n canvas.height = naturalHeight;\n strokesRef.current = [];\n activeStrokeRef.current = null;\n setStrokeCount(0);\n setLoaded({ url: imageUrl, width: naturalWidth, height: naturalHeight });\n };\n\n const toNaturalPoint = (event: React.PointerEvent<HTMLCanvasElement>): { point: IMaskPoint; scale: number } => {\n const canvas = event.currentTarget;\n const rect = canvas.getBoundingClientRect();\n const scale = rect.width > 0 ? canvas.width / rect.width : 1;\n return {\n point: { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale },\n scale,\n };\n };\n\n const moveCursor = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const cursor = cursorRef.current;\n if (!cursor) return;\n if (event.pointerType !== 'mouse') {\n cursor.style.opacity = '0';\n return;\n }\n const rect = event.currentTarget.getBoundingClientRect();\n cursor.style.opacity = '1';\n cursor.style.transform = `translate(${event.clientX - rect.left - brushSize / 2}px, ${event.clientY - rect.top - brushSize / 2}px)`;\n };\n\n const hideCursor = () => {\n if (cursorRef.current) cursorRef.current.style.opacity = '0';\n };\n\n const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {\n if (disabled || !ready || activeStrokeRef.current) return;\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n const context = getContext();\n if (!context) return;\n\n event.preventDefault();\n try {\n event.currentTarget.setPointerCapture(event.pointerId);\n } catch {\n // Synthetic events carry no active pointer; drawing still works without capture.\n }\n const { point, scale } = toNaturalPoint(event);\n const stroke: IMaskStroke = { mode, size: Math.max(1, brushSize * scale), points: [point] };\n activeStrokeRef.current = { pointerId: event.pointerId, stroke };\n drawStrokeTail(context, stroke, overlayColor());\n };\n\n const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {\n moveCursor(event);\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n const context = getContext();\n if (!context) return;\n\n active.stroke.points.push(toNaturalPoint(event).point);\n drawStrokeTail(context, active.stroke, overlayColor());\n };\n\n const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n activeStrokeRef.current = null;\n strokesRef.current = [...strokesRef.current, active.stroke];\n setStrokeCount(strokesRef.current.length);\n void emitMask();\n };\n\n const handleUndo = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = strokesRef.current.slice(0, -1);\n setStrokeCount(strokesRef.current.length);\n repaint();\n void emitMask();\n };\n\n const handleClear = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = [];\n setStrokeCount(0);\n repaint();\n void emitMask();\n };\n\n const controlsDisabled = disabled || !ready;\n\n // The frame takes the image's proportions, so the canvas over it always matches the image.\n const fillsFrame = !imageClassName && ready && loaded !== null;\n const frameStyle = fillsFrame\n ? {\n aspectRatio: `${loaded.width} / ${loaded.height}`,\n width: `min(100%, calc(${maxHeight} * ${loaded.width / loaded.height}))`,\n }\n : undefined;\n\n return (\n <div className={classNames('flex min-w-0 flex-col gap-y-2', className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <ControlledButton\n size=\"sm\"\n variant={mode === 'paint' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'paint'}\n disabled={controlsDisabled}\n onClick={() => setMode('paint')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"brush\" size={14} />\n {t('imageMaskPainter.paint')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant={mode === 'erase' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'erase'}\n disabled={controlsDisabled}\n onClick={() => setMode('erase')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"ink_eraser\" size={14} />\n {t('imageMaskPainter.erase')}\n </ControlledButton>\n\n <label className=\"flex min-w-32 flex-1 items-center gap-x-2 text-xs text-foreground-muted\">\n <span className=\"shrink-0\">{t('imageMaskPainter.brushSize')}</span>\n <ControlledSlider\n value={brushSize}\n min={IMAGE_MASK_BRUSH_MIN}\n max={IMAGE_MASK_BRUSH_MAX}\n step={2}\n disabled={controlsDisabled}\n label={t('imageMaskPainter.brushSize')}\n onChange={setBrushSize}\n />\n </label>\n\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleUndo}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"undo\" size={14} />\n {t('imageMaskPainter.undo')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleClear}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"layers_clear\" size={14} />\n {t('imageMaskPainter.clear')}\n </ControlledButton>\n </div>\n\n <div className=\"flex items-center justify-center rounded-xl bg-ink/95 p-2\">\n <div\n style={frameStyle}\n className={classNames('relative max-w-full overflow-hidden', fillsFrame ? 'min-w-0' : 'inline-flex')}\n >\n <img\n key={imageUrl}\n src={imageUrl}\n alt=\"\"\n draggable={false}\n onLoad={handleImageLoad}\n style={imageClassName || fillsFrame ? undefined : { maxHeight }}\n className={classNames(\n 'block select-none rounded-lg object-contain',\n imageClassName ?? (fillsFrame ? 'size-full' : 'w-auto max-w-full'),\n )}\n />\n <canvas\n ref={canvasRef}\n role=\"img\"\n aria-label={t('imageMaskPainter.surfaceLabel')}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={finishStroke}\n onPointerCancel={finishStroke}\n onPointerLeave={hideCursor}\n className={classNames(\n 'absolute inset-0 size-full touch-none rounded-lg opacity-50',\n controlsDisabled ? 'cursor-not-allowed' : 'cursor-crosshair',\n )}\n />\n <span\n ref={cursorRef}\n aria-hidden\n style={{ width: brushSize, height: brushSize }}\n className=\"pointer-events-none absolute left-0 top-0 rounded-full border-2 border-brand opacity-0 shadow-sm\"\n />\n </div>\n </div>\n </div>\n );\n};\n\nexport default ImageMaskPainter;\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.js","sources":["../../../../src/components/UI/ImageMaskPainter/index.tsx"],"sourcesContent":["import { useCallback, useEffect, useRef, useState } from 'react';\nimport classNames from 'classnames';\nimport { useTranslation } from 'react-i18next';\n\nimport ControlledButton from '@/components/Form/ControlledButton';\nimport ControlledSlider from '@/components/UI/ControlledSlider';\nimport { MaterialSymbol } from '@/components/UI/MaterialSymbol';\nimport { readThemeToken } from '@/helpers/themeTokens';\nimport { uiLogger } from '@/helpers/uiLogger';\nimport { useResolvedTheme } from '@/hooks/useResolvedTheme';\nimport { useThemeSkin } from '@/hooks/useThemeSkin';\n\nimport {\n drawStrokeTail,\n exportMaskFile,\n IMaskPoint,\n IMaskStroke,\n overlayHasPaint,\n redrawStrokes,\n TMaskBrushMode,\n} from './maskStrokes';\n\nexport type { TMaskBrushMode } from './maskStrokes';\n\n/** Brush diameter in on-screen pixels; converted to natural pixels per stroke. */\nexport const IMAGE_MASK_BRUSH_MIN = 8;\nexport const IMAGE_MASK_BRUSH_MAX = 96;\nconst DEFAULT_BRUSH_SIZE = 32;\nconst DEFAULT_MAX_HEIGHT = '70vh';\n\ninterface ILoadedImage {\n url: string;\n width: number;\n height: number;\n}\n\nexport interface IImageMaskPainterProps {\n imageUrl: string;\n /**\n * Called after every stroke, undo and clear with a PNG at the image's natural resolution\n * (white = painted, black = untouched), or null when nothing is painted.\n */\n onMaskChange: (mask: File | null) => void;\n disabled?: boolean;\n maskFileName?: string;\n /**\n * The tallest the image may get, as any CSS length. The image fills the available width up to\n * this height, scaled up when its natural size is smaller.\n */\n maxHeight?: string;\n /** Sizes the image with classes instead, at its natural size at most. Turns the fill off. */\n imageClassName?: string;\n className?: string;\n}\n\nconst ImageMaskPainter: React.FC<IImageMaskPainterProps> = ({\n imageUrl,\n onMaskChange,\n disabled = false,\n maskFileName = 'mask.png',\n maxHeight = DEFAULT_MAX_HEIGHT,\n imageClassName,\n className,\n}) => {\n const { t } = useTranslation('ui');\n const resolvedTheme = useResolvedTheme();\n const themeSkin = useThemeSkin();\n\n const canvasRef = useRef<HTMLCanvasElement | null>(null);\n const cursorRef = useRef<HTMLSpanElement | null>(null);\n const strokesRef = useRef<Array<IMaskStroke>>([]);\n const activeStrokeRef = useRef<{ pointerId: number; stroke: IMaskStroke } | null>(null);\n const onMaskChangeRef = useRef(onMaskChange);\n const emitSequenceRef = useRef(0);\n\n const [loaded, setLoaded] = useState<ILoadedImage | null>(null);\n const ready = loaded?.url === imageUrl;\n const [mode, setMode] = useState<TMaskBrushMode>('paint');\n const [brushSize, setBrushSize] = useState(DEFAULT_BRUSH_SIZE);\n const [strokeCount, setStrokeCount] = useState(0);\n\n useEffect(() => {\n onMaskChangeRef.current = onMaskChange;\n }, [onMaskChange]);\n\n const overlayColor = useCallback(() => readThemeToken('brand'), []);\n\n const getContext = useCallback(() => canvasRef.current?.getContext('2d') ?? null, []);\n\n const repaint = useCallback(() => {\n const context = getContext();\n if (context) redrawStrokes(context, strokesRef.current, overlayColor());\n }, [getContext, overlayColor]);\n\n // The brand token differs per theme and per skin; strokes already on screen follow it.\n useEffect(() => {\n if (ready) repaint();\n }, [ready, resolvedTheme, themeSkin, repaint]);\n\n const emitMask = useCallback(async () => {\n const sequence = ++emitSequenceRef.current;\n const canvas = canvasRef.current;\n try {\n const mask = canvas && overlayHasPaint(canvas) ? await exportMaskFile(canvas, maskFileName) : null;\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(mask);\n } catch (error) {\n uiLogger.error('Failed to export the image mask', error);\n if (sequence === emitSequenceRef.current) onMaskChangeRef.current(null);\n }\n }, [maskFileName]);\n\n const handleImageLoad = (event: React.SyntheticEvent<HTMLImageElement>) => {\n const canvas = canvasRef.current;\n const { naturalWidth, naturalHeight } = event.currentTarget;\n if (!canvas || !naturalWidth || !naturalHeight) return;\n canvas.width = naturalWidth;\n canvas.height = naturalHeight;\n strokesRef.current = [];\n activeStrokeRef.current = null;\n setStrokeCount(0);\n setLoaded({ url: imageUrl, width: naturalWidth, height: naturalHeight });\n };\n\n const toNaturalPoint = (event: React.PointerEvent<HTMLCanvasElement>): { point: IMaskPoint; scale: number } => {\n const canvas = event.currentTarget;\n const rect = canvas.getBoundingClientRect();\n const scale = rect.width > 0 ? canvas.width / rect.width : 1;\n return {\n point: { x: (event.clientX - rect.left) * scale, y: (event.clientY - rect.top) * scale },\n scale,\n };\n };\n\n const moveCursor = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const cursor = cursorRef.current;\n if (!cursor) return;\n if (event.pointerType !== 'mouse') {\n cursor.style.opacity = '0';\n return;\n }\n const rect = event.currentTarget.getBoundingClientRect();\n cursor.style.opacity = '1';\n cursor.style.transform = `translate(${event.clientX - rect.left - brushSize / 2}px, ${event.clientY - rect.top - brushSize / 2}px)`;\n };\n\n const hideCursor = () => {\n if (cursorRef.current) cursorRef.current.style.opacity = '0';\n };\n\n const handlePointerDown = (event: React.PointerEvent<HTMLCanvasElement>) => {\n if (disabled || !ready || activeStrokeRef.current) return;\n if (event.pointerType === 'mouse' && event.button !== 0) return;\n const context = getContext();\n if (!context) return;\n\n event.preventDefault();\n try {\n event.currentTarget.setPointerCapture(event.pointerId);\n } catch {\n // Synthetic events carry no active pointer; drawing still works without capture.\n }\n const { point, scale } = toNaturalPoint(event);\n const stroke: IMaskStroke = { mode, size: Math.max(1, brushSize * scale), points: [point] };\n activeStrokeRef.current = { pointerId: event.pointerId, stroke };\n drawStrokeTail(context, stroke, overlayColor());\n };\n\n const handlePointerMove = (event: React.PointerEvent<HTMLCanvasElement>) => {\n moveCursor(event);\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n const context = getContext();\n if (!context) return;\n\n active.stroke.points.push(toNaturalPoint(event).point);\n drawStrokeTail(context, active.stroke, overlayColor());\n };\n\n const finishStroke = (event: React.PointerEvent<HTMLCanvasElement>) => {\n const active = activeStrokeRef.current;\n if (!active || active.pointerId !== event.pointerId) return;\n activeStrokeRef.current = null;\n strokesRef.current = [...strokesRef.current, active.stroke];\n setStrokeCount(strokesRef.current.length);\n void emitMask();\n };\n\n const handleUndo = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = strokesRef.current.slice(0, -1);\n setStrokeCount(strokesRef.current.length);\n repaint();\n void emitMask();\n };\n\n const handleClear = () => {\n if (strokesRef.current.length === 0) return;\n strokesRef.current = [];\n setStrokeCount(0);\n repaint();\n void emitMask();\n };\n\n const controlsDisabled = disabled || !ready;\n\n // The frame takes the image's proportions, so the canvas over it always matches the image.\n const fillsFrame = !imageClassName && ready && loaded !== null;\n const frameStyle = fillsFrame\n ? {\n aspectRatio: `${loaded.width} / ${loaded.height}`,\n width: `min(100%, calc(${maxHeight} * ${loaded.width / loaded.height}))`,\n }\n : undefined;\n\n return (\n <div className={classNames('flex min-w-0 flex-col gap-y-2', className)}>\n <div className=\"flex flex-wrap items-center gap-2\">\n <ControlledButton\n size=\"sm\"\n variant={mode === 'paint' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'paint'}\n disabled={controlsDisabled}\n onClick={() => setMode('paint')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"brush\" size={14} />\n {t('imageMaskPainter.paint')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant={mode === 'erase' ? 'filled' : 'outlined'}\n aria-pressed={mode === 'erase'}\n disabled={controlsDisabled}\n onClick={() => setMode('erase')}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"ink_eraser\" size={14} />\n {t('imageMaskPainter.erase')}\n </ControlledButton>\n\n <label className=\"flex min-w-32 flex-1 items-center gap-x-2 text-xs text-foreground-muted\">\n <span className=\"shrink-0\">{t('imageMaskPainter.brushSize')}</span>\n <ControlledSlider\n value={brushSize}\n min={IMAGE_MASK_BRUSH_MIN}\n max={IMAGE_MASK_BRUSH_MAX}\n step={2}\n disabled={controlsDisabled}\n label={t('imageMaskPainter.brushSize')}\n onChange={setBrushSize}\n />\n </label>\n\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleUndo}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"undo\" size={14} />\n {t('imageMaskPainter.undo')}\n </ControlledButton>\n <ControlledButton\n size=\"sm\"\n variant=\"text\"\n tone=\"neutral\"\n disabled={controlsDisabled || strokeCount === 0}\n onClick={handleClear}\n className=\"flex items-center gap-x-1.5 normal-case\"\n >\n <MaterialSymbol aria-hidden icon=\"layers_clear\" size={14} />\n {t('imageMaskPainter.clear')}\n </ControlledButton>\n </div>\n\n <div className=\"flex items-center justify-center rounded-xl bg-ink/95 p-2\">\n <div\n style={frameStyle}\n className={classNames('relative max-w-full overflow-hidden', fillsFrame ? 'min-w-0' : 'inline-flex')}\n >\n <img\n key={imageUrl}\n src={imageUrl}\n alt=\"\"\n draggable={false}\n onLoad={handleImageLoad}\n style={imageClassName || fillsFrame ? undefined : { maxHeight }}\n className={classNames(\n 'block select-none rounded-lg object-contain',\n imageClassName ?? (fillsFrame ? 'size-full' : 'w-auto max-w-full'),\n )}\n />\n <canvas\n ref={canvasRef}\n role=\"img\"\n aria-label={t('imageMaskPainter.surfaceLabel')}\n onPointerDown={handlePointerDown}\n onPointerMove={handlePointerMove}\n onPointerUp={finishStroke}\n onPointerCancel={finishStroke}\n onPointerLeave={hideCursor}\n className={classNames(\n 'absolute inset-0 size-full touch-none rounded-lg opacity-50',\n controlsDisabled ? 'cursor-not-allowed' : 'cursor-crosshair',\n )}\n />\n <span\n ref={cursorRef}\n aria-hidden\n style={{ width: brushSize, height: brushSize }}\n className=\"pointer-events-none absolute left-0 top-0 rounded-full border-2 border-brand opacity-0 shadow-sm\"\n />\n </div>\n </div>\n </div>\n );\n};\n\nexport default ImageMaskPainter;\n"],"names":[],"mappings":"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"}
@@ -0,0 +1,12 @@
1
+ export declare const UI_THEME_SKIN_ATTRIBUTE = "data-theme-skin";
2
+ /**
3
+ * A skin re-colours exactly ONE scheme (see the Skins block in theme.css). The host paints it
4
+ * by writing that scheme to `data-theme` and the skin name to `data-theme-skin`, so everything
5
+ * that only understands light and dark keeps working untouched.
6
+ */
7
+ export declare const UI_THEME_SKIN_SCHEMES: {
8
+ readonly halloween: "dark";
9
+ };
10
+ export type TUiThemeSkin = keyof typeof UI_THEME_SKIN_SCHEMES;
11
+ export declare const UI_THEME_SKINS: Array<TUiThemeSkin>;
12
+ export declare const isUiThemeSkin: (value: unknown) => value is TUiThemeSkin;
@@ -0,0 +1,13 @@
1
+ const UI_THEME_SKIN_ATTRIBUTE = "data-theme-skin";
2
+ const UI_THEME_SKIN_SCHEMES = {
3
+ halloween: "dark"
4
+ };
5
+ const UI_THEME_SKINS = Object.keys(UI_THEME_SKIN_SCHEMES);
6
+ const isUiThemeSkin = (value) => typeof value === "string" && UI_THEME_SKINS.includes(value);
7
+ export {
8
+ UI_THEME_SKINS,
9
+ UI_THEME_SKIN_ATTRIBUTE,
10
+ UI_THEME_SKIN_SCHEMES,
11
+ isUiThemeSkin
12
+ };
13
+ //# sourceMappingURL=themeSkins.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"themeSkins.js","sources":["../../src/helpers/themeSkins.ts"],"sourcesContent":["import type { TUiResolvedTheme } from '../hooks/useResolvedTheme';\n\nexport const UI_THEME_SKIN_ATTRIBUTE = 'data-theme-skin';\n\n/**\n * A skin re-colours exactly ONE scheme (see the Skins block in theme.css). The host paints it\n * by writing that scheme to `data-theme` and the skin name to `data-theme-skin`, so everything\n * that only understands light and dark keeps working untouched.\n */\nexport const UI_THEME_SKIN_SCHEMES = {\n halloween: 'dark',\n} as const satisfies Record<string, TUiResolvedTheme>;\n\nexport type TUiThemeSkin = keyof typeof UI_THEME_SKIN_SCHEMES;\n\nexport const UI_THEME_SKINS = Object.keys(UI_THEME_SKIN_SCHEMES) as Array<TUiThemeSkin>;\n\nexport const isUiThemeSkin = (value: unknown): value is TUiThemeSkin =>\n typeof value === 'string' && (UI_THEME_SKINS as Array<string>).includes(value);\n"],"names":[],"mappings":"AAEO,MAAM,0BAA0B;AAOhC,MAAM,wBAAwB;AAAA,EACnC,WAAW;AACb;AAIO,MAAM,iBAAiB,OAAO,KAAK,qBAAqB;AAExD,MAAM,gBAAgB,CAAC,UAC5B,OAAO,UAAU,YAAa,eAAiC,SAAS,KAAK;"}
@@ -8,7 +8,7 @@
8
8
  */
9
9
  export declare const readThemeToken: (name: string, fallback?: string) => string;
10
10
  export interface IThemeChartTokens {
11
- /** Qualitative series palette, hue- and luminance-separated, validated for both themes. */
11
+ /** Qualitative series palette, hue- and luminance-separated, validated for every theme. */
12
12
  series: Array<string>;
13
13
  grid: string;
14
14
  axis: string;
@@ -22,7 +22,7 @@ export interface IThemeChartTokens {
22
22
  mode: 'light' | 'dark';
23
23
  }
24
24
  /**
25
- * Recomputed whenever the resolved theme changes: the CSS variables have already been
26
- * swapped by then, so `getComputedStyle` returns the new values.
25
+ * Recomputed whenever the resolved theme or its skin changes: the CSS variables have already
26
+ * been swapped by then, so `getComputedStyle` returns the new values.
27
27
  */
28
28
  export declare const useThemeChartTokens: () => IThemeChartTokens;
@@ -1,5 +1,6 @@
1
1
  import { useMemo } from "react";
2
2
  import { useResolvedTheme } from "../hooks/useResolvedTheme.js";
3
+ import { useThemeSkin } from "../hooks/useThemeSkin.js";
3
4
  const readThemeToken = (name, fallback = "#000000") => {
4
5
  if (typeof window === "undefined") return fallback;
5
6
  const raw = getComputedStyle(document.documentElement).getPropertyValue(`--color-${name}`).trim();
@@ -11,6 +12,7 @@ const readThemeToken = (name, fallback = "#000000") => {
11
12
  const SERIES_TOKENS = ["chart-1", "chart-2", "chart-3", "chart-4", "chart-5", "chart-6", "chart-7", "chart-8"];
12
13
  const useThemeChartTokens = () => {
13
14
  const resolvedTheme = useResolvedTheme();
15
+ const themeSkin = useThemeSkin();
14
16
  return useMemo(
15
17
  () => ({
16
18
  series: SERIES_TOKENS.map((token) => readThemeToken(token)),
@@ -23,7 +25,7 @@ const useThemeChartTokens = () => {
23
25
  border: readThemeToken("border"),
24
26
  mode: resolvedTheme
25
27
  }),
26
- [resolvedTheme]
28
+ [resolvedTheme, themeSkin]
27
29
  );
28
30
  };
29
31
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"themeTokens.js","sources":["../../src/helpers/themeTokens.ts"],"sourcesContent":["import { useMemo } from 'react';\n\nimport { useResolvedTheme } from '../hooks/useResolvedTheme';\n\n/**\n * Reads a design token as a concrete colour, for the few consumers that cannot use a CSS\n * class: canvas rendering, and third-party libraries that take colours as JS strings\n * (ApexCharts, Excalidraw exports).\n *\n * Reading from the stylesheet instead of duplicating the palette in TypeScript is what\n * keeps a single source of truth: change theme.css and every chart follows.\n */\nexport const readThemeToken = (name: string, fallback = '#000000'): string => {\n if (typeof window === 'undefined') return fallback;\n const raw = getComputedStyle(document.documentElement).getPropertyValue(`--color-${name}`).trim();\n if (!raw) return fallback;\n if (raw.startsWith('#') || raw.startsWith('rgb')) return raw;\n const [r, g, b] = raw.split(/\\s+/);\n return `rgb(${r}, ${g}, ${b})`;\n};\n\nexport interface IThemeChartTokens {\n /** Qualitative series palette, hue- and luminance-separated, validated for both themes. */\n series: Array<string>;\n grid: string;\n axis: string;\n /** Text drawn INSIDE a bar or slice, i.e. on top of a series colour. */\n label: string;\n foreground: string;\n foregroundMuted: string;\n surface: string;\n border: string;\n /** Feeds ApexCharts' own `theme.mode`, which drives its tooltip and legend defaults. */\n mode: 'light' | 'dark';\n}\n\nconst SERIES_TOKENS = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'chart-6', 'chart-7', 'chart-8'];\n\n/**\n * Recomputed whenever the resolved theme changes: the CSS variables have already been\n * swapped by then, so `getComputedStyle` returns the new values.\n */\nexport const useThemeChartTokens = (): IThemeChartTokens => {\n const resolvedTheme = useResolvedTheme();\n\n return useMemo(\n () => ({\n series: SERIES_TOKENS.map((token) => readThemeToken(token)),\n grid: readThemeToken('chart-grid'),\n axis: readThemeToken('chart-axis'),\n label: readThemeToken('chart-label'),\n foreground: readThemeToken('foreground'),\n foregroundMuted: readThemeToken('foreground-muted'),\n surface: readThemeToken('surface'),\n border: readThemeToken('border'),\n mode: resolvedTheme,\n }),\n [resolvedTheme],\n );\n};\n"],"names":[],"mappings":";;AAYO,MAAM,iBAAiB,CAAC,MAAc,WAAW,cAAsB;AAC5E,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,MAAM,iBAAiB,SAAS,eAAe,EAAE,iBAAiB,WAAW,IAAI,EAAE,EAAE,KAAA;AAC3F,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI,IAAI,WAAW,GAAG,KAAK,IAAI,WAAW,KAAK,EAAG,QAAO;AACzD,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI,IAAI,MAAM,KAAK;AACjC,SAAO,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC;AAC7B;AAiBA,MAAM,gBAAgB,CAAC,WAAW,WAAW,WAAW,WAAW,WAAW,WAAW,WAAW,SAAS;AAMtG,MAAM,sBAAsB,MAAyB;AAC1D,QAAM,gBAAgB,iBAAA;AAEtB,SAAO;AAAA,IACL,OAAO;AAAA,MACL,QAAQ,cAAc,IAAI,CAAC,UAAU,eAAe,KAAK,CAAC;AAAA,MAC1D,MAAM,eAAe,YAAY;AAAA,MACjC,MAAM,eAAe,YAAY;AAAA,MACjC,OAAO,eAAe,aAAa;AAAA,MACnC,YAAY,eAAe,YAAY;AAAA,MACvC,iBAAiB,eAAe,kBAAkB;AAAA,MAClD,SAAS,eAAe,SAAS;AAAA,MACjC,QAAQ,eAAe,QAAQ;AAAA,MAC/B,MAAM;AAAA,IAAA;AAAA,IAER,CAAC,aAAa;AAAA,EAAA;AAElB;"}
1
+ {"version":3,"file":"themeTokens.js","sources":["../../src/helpers/themeTokens.ts"],"sourcesContent":["import { useMemo } from 'react';\n\nimport { useResolvedTheme } from '../hooks/useResolvedTheme';\nimport { useThemeSkin } from '../hooks/useThemeSkin';\n\n/**\n * Reads a design token as a concrete colour, for the few consumers that cannot use a CSS\n * class: canvas rendering, and third-party libraries that take colours as JS strings\n * (ApexCharts, Excalidraw exports).\n *\n * Reading from the stylesheet instead of duplicating the palette in TypeScript is what\n * keeps a single source of truth: change theme.css and every chart follows.\n */\nexport const readThemeToken = (name: string, fallback = '#000000'): string => {\n if (typeof window === 'undefined') return fallback;\n const raw = getComputedStyle(document.documentElement).getPropertyValue(`--color-${name}`).trim();\n if (!raw) return fallback;\n if (raw.startsWith('#') || raw.startsWith('rgb')) return raw;\n const [r, g, b] = raw.split(/\\s+/);\n return `rgb(${r}, ${g}, ${b})`;\n};\n\nexport interface IThemeChartTokens {\n /** Qualitative series palette, hue- and luminance-separated, validated for every theme. */\n series: Array<string>;\n grid: string;\n axis: string;\n /** Text drawn INSIDE a bar or slice, i.e. on top of a series colour. */\n label: string;\n foreground: string;\n foregroundMuted: string;\n surface: string;\n border: string;\n /** Feeds ApexCharts' own `theme.mode`, which drives its tooltip and legend defaults. */\n mode: 'light' | 'dark';\n}\n\nconst SERIES_TOKENS = ['chart-1', 'chart-2', 'chart-3', 'chart-4', 'chart-5', 'chart-6', 'chart-7', 'chart-8'];\n\n/**\n * Recomputed whenever the resolved theme or its skin changes: the CSS variables have already\n * been swapped by then, so `getComputedStyle` returns the new values.\n */\nexport const useThemeChartTokens = (): IThemeChartTokens => {\n const resolvedTheme = useResolvedTheme();\n const themeSkin = useThemeSkin();\n\n return useMemo(\n () => ({\n series: SERIES_TOKENS.map((token) => readThemeToken(token)),\n grid: readThemeToken('chart-grid'),\n axis: readThemeToken('chart-axis'),\n label: readThemeToken('chart-label'),\n foreground: readThemeToken('foreground'),\n foregroundMuted: readThemeToken('foreground-muted'),\n surface: readThemeToken('surface'),\n border: readThemeToken('border'),\n mode: resolvedTheme,\n }),\n [resolvedTheme, themeSkin],\n );\n};\n"],"names":[],"mappings":";;;AAaO,MAAM,iBAAiB,CAAC,MAAc,WAAW,cAAsB;AAC5E,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,MAAM,iBAAiB,SAAS,eAAe,EAAE,iBAAiB,WAAW,IAAI,EAAE,EAAE,KAAA;AAC3F,MAAI,CAAC,IAAK,QAAO;AACjB,MAAI,IAAI,WAAW,GAAG,KAAK,IAAI,WAAW,KAAK,EAAG,QAAO;AACzD,QAAM,CAAC,GAAG,GAAG,CAAC,IAAI,IAAI,MAAM,KAAK;AACjC,SAAO,OAAO,CAAC,KAAK,CAAC,KAAK,CAAC;AAC7B;AAiBA,MAAM,gBAAgB,CAAC,WAAW,WAAW,WAAW,WAAW,WAAW,WAAW,WAAW,SAAS;AAMtG,MAAM,sBAAsB,MAAyB;AAC1D,QAAM,gBAAgB,iBAAA;AACtB,QAAM,YAAY,aAAA;AAElB,SAAO;AAAA,IACL,OAAO;AAAA,MACL,QAAQ,cAAc,IAAI,CAAC,UAAU,eAAe,KAAK,CAAC;AAAA,MAC1D,MAAM,eAAe,YAAY;AAAA,MACjC,MAAM,eAAe,YAAY;AAAA,MACjC,OAAO,eAAe,aAAa;AAAA,MACnC,YAAY,eAAe,YAAY;AAAA,MACvC,iBAAiB,eAAe,kBAAkB;AAAA,MAClD,SAAS,eAAe,SAAS;AAAA,MACjC,QAAQ,eAAe,QAAQ;AAAA,MAC/B,MAAM;AAAA,IAAA;AAAA,IAER,CAAC,eAAe,SAAS;AAAA,EAAA;AAE7B;"}
@@ -0,0 +1,7 @@
1
+ import { TUiThemeSkin } from '../helpers/themeSkins';
2
+ /**
3
+ * The skin painted on `<html data-theme-skin>`, or `null` when the scheme is plain. A skin
4
+ * changes token VALUES without changing the scheme, so anything that caches a colour read
5
+ * from the stylesheet has to depend on this next to `useResolvedTheme`.
6
+ */
7
+ export declare const useThemeSkin: () => TUiThemeSkin | null;
@@ -0,0 +1,19 @@
1
+ import { useSyncExternalStore } from "react";
2
+ import { UI_THEME_SKIN_ATTRIBUTE, isUiThemeSkin } from "../helpers/themeSkins.js";
3
+ const readThemeSkin = () => {
4
+ if (typeof document === "undefined") return null;
5
+ const skin = document.documentElement.getAttribute(UI_THEME_SKIN_ATTRIBUTE);
6
+ return isUiThemeSkin(skin) ? skin : null;
7
+ };
8
+ const subscribeToSkinAttribute = (onChange) => {
9
+ if (typeof document === "undefined") return () => void 0;
10
+ const observer = new MutationObserver(onChange);
11
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: [UI_THEME_SKIN_ATTRIBUTE] });
12
+ return () => observer.disconnect();
13
+ };
14
+ const readServerSkin = () => null;
15
+ const useThemeSkin = () => useSyncExternalStore(subscribeToSkinAttribute, readThemeSkin, readServerSkin);
16
+ export {
17
+ useThemeSkin
18
+ };
19
+ //# sourceMappingURL=useThemeSkin.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useThemeSkin.js","sources":["../../src/hooks/useThemeSkin.ts"],"sourcesContent":["import { useSyncExternalStore } from 'react';\n\nimport { isUiThemeSkin, TUiThemeSkin, UI_THEME_SKIN_ATTRIBUTE } from '../helpers/themeSkins';\n\nconst readThemeSkin = (): TUiThemeSkin | null => {\n if (typeof document === 'undefined') return null;\n\n const skin = document.documentElement.getAttribute(UI_THEME_SKIN_ATTRIBUTE);\n return isUiThemeSkin(skin) ? skin : null;\n};\n\nconst subscribeToSkinAttribute = (onChange: () => void): (() => void) => {\n if (typeof document === 'undefined') return () => undefined;\n\n const observer = new MutationObserver(onChange);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [UI_THEME_SKIN_ATTRIBUTE] });\n return () => observer.disconnect();\n};\n\nconst readServerSkin = (): TUiThemeSkin | null => null;\n\n/**\n * The skin painted on `<html data-theme-skin>`, or `null` when the scheme is plain. A skin\n * changes token VALUES without changing the scheme, so anything that caches a colour read\n * from the stylesheet has to depend on this next to `useResolvedTheme`.\n */\nexport const useThemeSkin = (): TUiThemeSkin | null =>\n useSyncExternalStore(subscribeToSkinAttribute, readThemeSkin, readServerSkin);\n"],"names":[],"mappings":";;AAIA,MAAM,gBAAgB,MAA2B;AAC/C,MAAI,OAAO,aAAa,YAAa,QAAO;AAE5C,QAAM,OAAO,SAAS,gBAAgB,aAAa,uBAAuB;AAC1E,SAAO,cAAc,IAAI,IAAI,OAAO;AACtC;AAEA,MAAM,2BAA2B,CAAC,aAAuC;AACvE,MAAI,OAAO,aAAa,YAAa,QAAO,MAAM;AAElD,QAAM,WAAW,IAAI,iBAAiB,QAAQ;AAC9C,WAAS,QAAQ,SAAS,iBAAiB,EAAE,YAAY,MAAM,iBAAiB,CAAC,uBAAuB,GAAG;AAC3G,SAAO,MAAM,SAAS,WAAA;AACxB;AAEA,MAAM,iBAAiB,MAA2B;AAO3C,MAAM,eAAe,MAC1B,qBAAqB,0BAA0B,eAAe,cAAc;"}
package/dist/index.d.ts CHANGED
@@ -45,6 +45,7 @@ export { default as SkeletonLoading } from './components/UI/SkeletonLoading';
45
45
  export { default as UserMenu } from './components/UI/UserMenu';
46
46
  export * from './helpers/composeRefs';
47
47
  export * from './helpers/layeredSurfaces';
48
+ export * from './helpers/themeSkins';
48
49
  export * from './helpers/themeTokens';
49
50
  export * from './helpers/uiLogger';
50
51
  export * from './helpers/uiPreferences';
@@ -55,5 +56,6 @@ export * from './hooks/useHoverCapable';
55
56
  export * from './hooks/useMobile';
56
57
  export * from './hooks/useNotification';
57
58
  export * from './hooks/useResolvedTheme';
59
+ export * from './hooks/useThemeSkin';
58
60
  export * from './locales';
59
61
  export * from './motion/index';
package/dist/index.js CHANGED
@@ -44,6 +44,7 @@ import { default as default38 } from "./components/UI/SkeletonLoading.js";
44
44
  import { default as default39 } from "./components/UI/UserMenu.js";
45
45
  import { composeRefs } from "./helpers/composeRefs.js";
46
46
  import { LAYERED_SURFACE_ATTRIBUTE, LAYERED_SURFACE_DATA_ATTRIBUTE, LAYERED_SURFACE_SELECTOR, isPressOnLayeredSurface } from "./helpers/layeredSurfaces.js";
47
+ import { UI_THEME_SKINS, UI_THEME_SKIN_ATTRIBUTE, UI_THEME_SKIN_SCHEMES, isUiThemeSkin } from "./helpers/themeSkins.js";
47
48
  import { readThemeToken, useThemeChartTokens } from "./helpers/themeTokens.js";
48
49
  import { configureUiLogger, uiLogger } from "./helpers/uiLogger.js";
49
50
  import { configureUiPreferences, readUiPreference, resolveUiPreferenceScopeId, writeUiPreference } from "./helpers/uiPreferences.js";
@@ -54,6 +55,7 @@ import { useHoverCapable } from "./hooks/useHoverCapable.js";
54
55
  import { useMobile } from "./hooks/useMobile.js";
55
56
  import { useNotification, useNotificationStore } from "./hooks/useNotification.js";
56
57
  import { useResolvedTheme } from "./hooks/useResolvedTheme.js";
58
+ import { useThemeSkin } from "./hooks/useThemeSkin.js";
57
59
  import { uiLocales } from "./locales/index.js";
58
60
  import { collapseVariants, createDrawerVariants, dialogVariants, fadeVariants, floatingVariants, scrimVariants, tooltipVariants } from "./motion/motionPresets.js";
59
61
  import { MOTION_OFFSET_PX, enterTransition, exitTransition, motionDuration, motionEase } from "./motion/motionTokens.js";
@@ -102,6 +104,9 @@ export {
102
104
  default19 as ShellLanguageToggle,
103
105
  default20 as ShellThemeToggle,
104
106
  default38 as SkeletonLoading,
107
+ UI_THEME_SKINS,
108
+ UI_THEME_SKIN_ATTRIBUTE,
109
+ UI_THEME_SKIN_SCHEMES,
105
110
  UiAccessProvider,
106
111
  UiNavigationProvider,
107
112
  default17 as UnifiedSearchBar,
@@ -119,6 +124,7 @@ export {
119
124
  floatingVariants,
120
125
  getButtonClassName,
121
126
  isPressOnLayeredSurface,
127
+ isUiThemeSkin,
122
128
  motionDuration,
123
129
  motionEase,
124
130
  readThemeToken,
@@ -139,6 +145,7 @@ export {
139
145
  usePageTitleNavigation,
140
146
  useResolvedTheme,
141
147
  useThemeChartTokens,
148
+ useThemeSkin,
142
149
  useUiAccess,
143
150
  useUiNavigation,
144
151
  writeUiPreference
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -509,7 +509,8 @@ declare const _default: {
509
509
  "toggle": "Theme",
510
510
  "light": "Light theme",
511
511
  "dark": "Dark theme",
512
- "system": "Follow system"
512
+ "system": "Follow system",
513
+ "halloween": "Halloween theme"
513
514
  },
514
515
  "toast": {
515
516
  "learnMore": "Learn more"
@@ -28,7 +28,7 @@ const pagination = { "resultsPerPage": "Results per page", "pageOf": "Page {{cur
28
28
  const permissions = { "deniedAction": "This action is not open to your profile. An account administrator can grant access.", "deniedRequest": "This action is not open to your user profile.", "checkingAccess": "Checking permissions...", "deniedPage": { "resource": "Page: {{resource}}", "backToHome": "Back to start", "nextStepsTitle": "What to do now", "service": { "title": "This module is not part of your account", "message": "The module behind this page is not among the services your account has, so no user in it can open the page.", "nextStep": "Talk to whoever owns the account at uMode to add this module. Everything else you already use keeps working." }, "capability": { "title": "This page is not open to you yet", "message": "Your account has this page, but your user profile does not include permission to open it yet.", "nextStep": "An account administrator can grant it in Settings, Access Control, under your user profile. It takes seconds and applies from your next visit." }, "request": { "title": "This page needs a permission you do not have yet", "message": "You got this far, but while loading this screen we found that your user profile does not have access to its information.", "nextStep": "An account administrator can grant it in Settings, Access Control. If access was just granted, use Try again. None of your work was affected." } } };
29
29
  const requestErrors = { "denied": { "title": "This area is not open to you yet", "description": "Your user profile does not include access to this content. An account administrator can grant it in Settings, Access Control." }, "notFound": { "title": "We could not find this item", "description": "It may have been deleted by someone on the team, or the link that brought you here is out of date." }, "offline": { "title": "The connection seems to have dropped", "description": "Check your connection and try again in a few seconds. Nothing you had already saved was lost." }, "server": { "title": "Something is unstable on our side", "description": "The problem is on our end, not something you did. Try again in a moment; if it keeps happening, contact support." }, "unknown": { "title": "We could not load this part", "description": "This is usually temporary. Try again and, if it stays like this, contact support and tell them what you were doing." } };
30
30
  const sidebar = { "home": "Home", "logout": "Sign out", "openSidebar": "Open sidebar", "collapseSidebar": "Collapse sidebar", "favorites": { "add": "Add to favorites", "remove": "Remove from favorites" }, "sections": { "favorites": "Favorites", "management": "Management", "registration": "Records" } };
31
- const theme = { "toggle": "Theme", "light": "Light theme", "dark": "Dark theme", "system": "Follow system" };
31
+ const theme = { "toggle": "Theme", "light": "Light theme", "dark": "Dark theme", "system": "Follow system", "halloween": "Halloween theme" };
32
32
  const toast = { "learnMore": "Learn more" };
33
33
  const unifiedSearchBar = { "clearFilter": "Clear filter" };
34
34
  const uploader = { "dragOr": "Drag or", "selectFile": "select a file", "orPaste": "or paste (Ctrl+V)", "allowedFormats": "Allowed formats:", "upTo": "up to", "megabytesSuffix": "MB", "sendFiles": "Send file(s)", "filesIgnoredMaxSize": "Some files were ignored because they exceeded the maximum size of {{size}}MB", "invalidFormat": "Please add files in the allowed formats", "allowedFormatsTooltip": "Allowed formats: {{formats}} up to {{size}}MB" };
@@ -512,6 +512,7 @@ export declare const uiLocales: {
512
512
  light: string;
513
513
  dark: string;
514
514
  system: string;
515
+ halloween: string;
515
516
  };
516
517
  toast: {
517
518
  learnMore: string;
@@ -1065,6 +1066,7 @@ export declare const uiLocales: {
1065
1066
  light: string;
1066
1067
  dark: string;
1067
1068
  system: string;
1069
+ halloween: string;
1068
1070
  };
1069
1071
  toast: {
1070
1072
  learnMore: string;
@@ -509,7 +509,8 @@ declare const _default: {
509
509
  "toggle": "Tema",
510
510
  "light": "Tema claro",
511
511
  "dark": "Tema escuro",
512
- "system": "Seguir o sistema"
512
+ "system": "Seguir o sistema",
513
+ "halloween": "Tema de Halloween"
513
514
  },
514
515
  "toast": {
515
516
  "learnMore": "Saiba Mais"
@@ -28,7 +28,7 @@ const pagination = { "resultsPerPage": "Resultados por página", "pageOf": "Pág
28
28
  const permissions = { "deniedAction": "Esta ação não está liberada para o seu perfil. Um administrador da conta pode liberar o acesso.", "deniedRequest": "Esta ação não está liberada para o seu perfil de usuário.", "checkingAccess": "Verificando permissões...", "deniedPage": { "resource": "Página: {{resource}}", "backToHome": "Voltar para o início", "nextStepsTitle": "O que fazer agora", "service": { "title": "Este módulo não faz parte da sua conta", "message": "O módulo que abre esta página ainda não está entre os serviços contratados pela sua conta, então ele não pode ser aberto por nenhum usuário dela.", "nextStep": "Fale com a pessoa responsável pela conta na uMode para incluir este módulo. Enquanto isso, o restante do que você já usa segue funcionando normalmente." }, "capability": { "title": "Esta página ainda não está liberada para você", "message": "A sua conta tem esta página, mas o seu perfil de usuário ainda não inclui a permissão para abri-la.", "nextStep": "Um administrador da conta pode liberar em Configurações, Controle de Acesso, escolhendo o seu perfil de usuário. Leva alguns segundos e vale a partir do próximo acesso." }, "request": { "title": "Esta página precisa de uma permissão que você ainda não tem", "message": "Você chegou até aqui, mas ao buscar as informações desta tela percebemos que o seu perfil de usuário não tem acesso a elas.", "nextStep": "Um administrador da conta pode liberar em Configurações, Controle de Acesso. Se o acesso acabou de ser concedido, use Tentar novamente. Nada do seu trabalho foi afetado." } } };
29
29
  const requestErrors = { "denied": { "title": "Esta área ainda não está liberada para você", "description": "Seu perfil de usuário não inclui o acesso a este conteúdo. Um administrador da conta pode liberar em Configurações, Controle de Acesso." }, "notFound": { "title": "Não encontramos este item", "description": "Ele pode ter sido excluído por alguém da equipe, ou o link usado para chegar até aqui está desatualizado." }, "offline": { "title": "Parece que a conexão caiu", "description": "Confira sua conexão e tente de novo em alguns segundos. Nada do que já foi salvo se perdeu." }, "server": { "title": "Estamos com uma instabilidade", "description": "O problema é aqui do nosso lado, não em algo que você fez. Tente de novo em alguns instantes; se continuar, fale com o suporte." }, "unknown": { "title": "Não conseguimos carregar esta parte", "description": "Isso costuma ser passageiro. Tente de novo e, se continuar assim, fale com o suporte contando o que você estava fazendo." } };
30
30
  const sidebar = { "home": "Início", "logout": "Sair", "openSidebar": "Abrir barra lateral", "collapseSidebar": "Recolher barra lateral", "favorites": { "add": "Adicionar aos favoritos", "remove": "Remover dos favoritos" }, "sections": { "favorites": "Favoritos", "management": "Gestão", "registration": "Cadastros" } };
31
- const theme = { "toggle": "Tema", "light": "Tema claro", "dark": "Tema escuro", "system": "Seguir o sistema" };
31
+ const theme = { "toggle": "Tema", "light": "Tema claro", "dark": "Tema escuro", "system": "Seguir o sistema", "halloween": "Tema de Halloween" };
32
32
  const toast = { "learnMore": "Saiba Mais" };
33
33
  const unifiedSearchBar = { "clearFilter": "Limpar Filtro" };
34
34
  const uploader = { "dragOr": "Arraste ou", "selectFile": "selecione um arquivo", "orPaste": "ou cole (Ctrl+V)", "allowedFormats": "Formatos permitidos:", "upTo": "até", "megabytesSuffix": "MB", "sendFiles": "Enviar arquivo(s)", "filesIgnoredMaxSize": "Alguns arquivos foram ignorados porque excederam o tamanho máximo de {{size}}MB", "invalidFormat": "Por favor, insira arquivos nos formatos permitidos", "allowedFormatsTooltip": "Formatos permitidos: {{formats}} até {{size}}MB" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umodeapporg/ui",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "license": "UNLICENSED",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -168,7 +168,7 @@ body {
168
168
 
169
169
  `data-theme` re-declares the whole token set, so any subtree can pin itself to a
170
170
  theme. This is how print previews stay truthful: what the user sees on screen is
171
- what comes out of the printer, regardless of the app theme.
171
+ what comes out of the printer, regardless of the app theme or of the skin on it.
172
172
  --------------------------------------------------------------------------- */
173
173
  [data-theme='light'],
174
174
  [data-theme='dark'] {
@@ -12,10 +12,14 @@
12
12
  Every pairing below is verified against WCAG 2.1 AA (and AAA for body text on
13
13
  every surface). Do not hand-edit a value without re-checking the pair it is
14
14
  used in; see .claude/THEMING.md.
15
+
16
+ The scheme blocks match the ATTRIBUTE, not only the root: that is what lets a
17
+ subtree pin itself to a scheme (`<div data-theme="light">`, the print preview)
18
+ and get the whole token set re-declared, instead of inheriting the app's.
15
19
  ============================================================================= */
16
20
 
17
21
  :root,
18
- :root[data-theme='light'] {
22
+ [data-theme='light'] {
19
23
  color-scheme: light;
20
24
 
21
25
  --color-canvas: 246 246 246;
@@ -142,7 +146,8 @@
142
146
  --glass-highlight-alpha: 0.75;
143
147
  }
144
148
 
145
- :root[data-theme='dark'] {
149
+ :root[data-theme='dark'],
150
+ [data-theme='dark'] {
146
151
  color-scheme: dark;
147
152
 
148
153
  --color-canvas: 10 10 10;
@@ -257,6 +262,52 @@
257
262
  --glass-highlight-alpha: 0.07;
258
263
  }
259
264
 
265
+ /* =============================================================================
266
+ Skins.
267
+
268
+ A skin is a seasonal re-colouring of ONE scheme. It lives on a second attribute
269
+ (`data-theme-skin`), so `data-theme` keeps meaning light or dark for everything
270
+ that reads it: the `dark:` variant, chart libraries, canvas tools, pinned islands.
271
+
272
+ A skin overrides CHROMATIC tokens only. Surfaces, text and borders stay the
273
+ scheme's own, which is what keeps every audited contrast pair standing and the
274
+ neutrals neutral. The selector names the scheme on purpose: these values are
275
+ tuned against the dark surfaces and would fail on the light ones.
276
+ ============================================================================= */
277
+
278
+ /* Halloween: pumpkin orange takes the brand role and the brand's violet moves to accent.
279
+ - `primary` is a BURNT orange, not the bright one, because its ink stays white: plenty of
280
+ brand buttons carry a white icon, and white only clears 4.5:1 below this luminance.
281
+ The bright pumpkin orange lives in `ring`, which is never a text background.
282
+ - `warning` leaves amber for yellow. With an orange brand, an amber warning chip and a
283
+ brand chip would read as the same thing, and a status must never look like decoration. */
284
+ :root[data-theme='dark'][data-theme-skin='halloween'] {
285
+ --color-ring: 255 138 51;
286
+ --color-selection: 138 62 12;
287
+ --color-selection-fg: 237 237 237;
288
+ --color-primary: 190 74 0;
289
+ --color-primary-hover: 198 80 4;
290
+ --color-primary-active: 200 80 0;
291
+ --color-primary-soft: 58 27 6;
292
+ --color-primary-soft-hover: 74 34 8;
293
+ --color-primary-emphasis: 255 170 102;
294
+ --color-primary-border: 104 49 12;
295
+ --color-accent: 180 124 245;
296
+ --color-accent-hover: 196 150 248;
297
+ --color-accent-fg: 26 10 51;
298
+ --color-accent-soft: 43 26 70;
299
+ --color-accent-emphasis: 199 155 250;
300
+ --color-accent-border: 74 45 115;
301
+ --color-warning: 245 200 40;
302
+ --color-warning-hover: 250 214 80;
303
+ --color-warning-fg: 38 28 0;
304
+ --color-warning-soft: 46 38 6;
305
+ --color-warning-emphasis: 250 220 110;
306
+ --color-warning-border: 84 68 14;
307
+ --color-chart-1: 251 138 60;
308
+ --color-chart-3: 180 124 245;
309
+ }
310
+
260
311
  /* Zero-flash safety net. The inline boot script in index.html always resolves the
261
312
  preference to a concrete data-theme before first paint, so this block only
262
313
  matters in the window before that script runs (or if it ever fails to). */