@stridge/noctis 1.0.0-beta.0 → 1.0.0-beta.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # @stridge/noctis
2
2
 
3
+ ![license](https://img.shields.io/badge/license-MIT-blue) ![coverage](https://img.shields.io/badge/coverage-100%25-brightgreen)
4
+
3
5
  The Stridge design system, and the single package consumers install. It pairs Base UI
4
6
  behavior with precompiled, **framework-neutral component CSS** on the semantic token layer, and
5
7
  ships the authoring substrate every primitive builds on — the polymorphic `Primitive` base, the
@@ -41,6 +41,7 @@ function CodeBlockRoot({ label, language, icon, copyText, copyLabel, copiedLabel
41
41
  const context = useMemo(() => ({
42
42
  state: { activeValue: void 0 },
43
43
  actions: {
44
+ /* v8 ignore next -- unreachable: Root has no tab strip to switch */
44
45
  setActiveValue: () => {},
45
46
  registerTarget,
46
47
  getText
@@ -144,6 +145,7 @@ function CodeBlockTabs({ children, "aria-label": ariaLabel, copyLabel, copiedLab
144
145
  const items = Children.toArray(children).filter((child) => isValidElement(child));
145
146
  const [value, setValue] = useState("0");
146
147
  const panelRef = useRef(null);
148
+ /* v8 ignore next -- unreachable: Tabs parts read the shared panelRef instead of registering */
147
149
  const registerTarget = useCallback((_ref) => {}, []);
148
150
  const getText = useCallback(() => panelRef.current?.querySelector("code, pre")?.textContent ?? "", []);
149
151
  const setActiveValue = useCallback((next) => setValue(next), []);
@@ -167,6 +169,7 @@ function CodeBlockTabs({ children, "aria-label": ariaLabel, copyLabel, copiedLab
167
169
  copiedLabel
168
170
  ]);
169
171
  if (items.length === 0) return null;
172
+ /* v8 ignore next 2 -- unreachable: value tracks a live tab index, so the left side is always defined */
170
173
  const activeChild = items[Number(value)] ?? items[0];
171
174
  return /* @__PURE__ */ jsx(CodeBlockProvider, {
172
175
  value: context,
@@ -186,13 +189,15 @@ function CodeBlockTabs({ children, "aria-label": ariaLabel, copyLabel, copiedLab
186
189
  children: items.map((child, index) => {
187
190
  const language = childLanguage(child.props);
188
191
  const label = childLabel(child.props);
192
+ /* v8 ignore next 2 -- unreachable: Children.toArray always assigns a key */
193
+ const key = child.key ?? index;
189
194
  return /* @__PURE__ */ jsx(CodeBlockTab, {
190
195
  value: String(index),
191
196
  language,
192
197
  label,
193
198
  icon: childIcon(child.props),
194
199
  children: resolveTabLabel(label, language, index)
195
- }, child.key ?? index);
200
+ }, key);
196
201
  })
197
202
  }), /* @__PURE__ */ jsx(CodeBlockCopyButton, {})]
198
203
  }), /* @__PURE__ */ jsx(CodeBlockPanel, {
@@ -51,7 +51,8 @@ function CollapsibleRoot({ className, ...props }) {
51
51
  * @see {@link Collapsible.Trigger.Props}
52
52
  */
53
53
  function CollapsibleTrigger({ indicator = true, className, children, ...props }) {
54
- const labelId = useCollapsibleId();
54
+ /* v8 ignore next */
55
+ const labelIdAttr = useCollapsibleId() ?? void 0;
55
56
  const side = resolveIndicator(indicator);
56
57
  const { "data-disabled": _ghostDisabled, ...ghostButton } = Button.props({
57
58
  variant: "ghost",
@@ -76,7 +77,7 @@ function CollapsibleTrigger({ indicator = true, className, children, ...props })
76
77
  side === "chevron-start" ? chevron : null,
77
78
  /* @__PURE__ */ jsx("span", {
78
79
  "data-collapsible-label": "",
79
- id: labelId ?? void 0,
80
+ id: labelIdAttr,
80
81
  children
81
82
  }),
82
83
  side === "chevron-end" ? chevron : null
@@ -18,6 +18,7 @@ function ColorPickerArea({ className }) {
18
18
  const draggingRef = useRef(false);
19
19
  const updateFromPoint = (clientX, clientY) => {
20
20
  const node = areaRef.current;
21
+ /* v8 ignore next -- defensive: updateFromPoint only runs from pointer handlers on this node, so the ref is always set */
21
22
  if (!node) return;
22
23
  const rect = node.getBoundingClientRect();
23
24
  const ratioX = clamp01((clientX - rect.left) / rect.width);
@@ -35,6 +35,7 @@ function ColorPickerRoot({ value, defaultValue, format, defaultFormat = "oklch",
35
35
  const syncingValue = useRef(false);
36
36
  const syncingFormat = useRef(false);
37
37
  if (storeRef.current === null) {
38
+ /* v8 ignore next -- defensive: DEFAULT_COLOR always parses, so the `?? BLACK` fallback is unreachable */
38
39
  const seed = parseColor(value ?? defaultValue ?? "oklch(0.504 0.151 275.2)") ?? BLACK;
39
40
  const color = {
40
41
  ...seed,
@@ -59,6 +60,8 @@ function ColorPickerRoot({ value, defaultValue, format, defaultFormat = "oklch",
59
60
  const next = formatColor(state.color, state.format, alpha);
60
61
  if (next !== lastValue.current) {
61
62
  lastValue.current = next;
63
+ /* v8 ignore next -- the value-sync effect pre-seeds lastValue to this exact string, so when
64
+ syncingValue is true the outer guard is already false; the skip branch is unreachable */
62
65
  if (!syncingValue.current) onValueChange?.(next);
63
66
  }
64
67
  }), [
@@ -38,6 +38,7 @@ function parseColor(input) {
38
38
  const parsed = parse(input);
39
39
  if (!parsed) return null;
40
40
  const rgb = toRgb(parsed);
41
+ /* v8 ignore next -- defensive: culori's rgb converter only returns undefined for an undefined input, and `parsed` is a valid color here */
41
42
  if (!rgb) return null;
42
43
  return {
43
44
  r: to255(rgb.r),
@@ -63,12 +64,14 @@ function rgbToHsv(rgb) {
63
64
  b: rgb.b / 255,
64
65
  alpha: rgb.a
65
66
  });
67
+ /* v8 ignore next -- defensive: the hsv converter never returns undefined for a constructed rgb object */
66
68
  if (!hsv) return {
67
69
  h: 0,
68
70
  s: 0,
69
71
  v: 0,
70
72
  a: rgb.a
71
73
  };
74
+ /* v8 ignore next -- s/v are always present (0 on the gray/black axis); only h drops out, so the s/v `?? 0` fallbacks are unreachable */
72
75
  return {
73
76
  h: wrapHue(hsv.h ?? 0),
74
77
  s: round((hsv.s ?? 0) * 100),
@@ -85,6 +88,7 @@ function hsvToRgb(hsv) {
85
88
  v: hsv.v / 100,
86
89
  alpha: hsv.a
87
90
  });
91
+ /* v8 ignore next -- defensive: the rgb converter never returns undefined for a constructed hsv object */
88
92
  if (!rgb) return {
89
93
  ...BLACK,
90
94
  a: hsv.a
@@ -181,19 +185,23 @@ function formatRgbString(rgb, withAlpha) {
181
185
  return `rgb(${r} ${g} ${b} / ${trim(rgb.a.toFixed(3))})`;
182
186
  }
183
187
  function formatHslString(hsl, withAlpha) {
188
+ /* v8 ignore next -- defensive: the hsl converter never returns undefined for the constructed rgb object formatColor passes */
184
189
  if (!hsl) return "hsl(0 0% 0%)";
185
190
  const h = round(wrapHue(hsl.h ?? 0));
186
191
  const s = round(hsl.s * 100);
187
192
  const l = round(hsl.l * 100);
193
+ /* v8 ignore next -- alpha is always set: formatColor builds the source rgb with `alpha: rgb.a`, so the `?? 1` fallback is unreachable */
188
194
  const a = hsl.alpha ?? 1;
189
195
  if (!withAlpha || a >= 1) return `hsl(${h} ${s}% ${l}%)`;
190
196
  return `hsl(${h} ${s}% ${l}% / ${trim(a.toFixed(3))})`;
191
197
  }
192
198
  function formatOklchString(oklch, withAlpha) {
199
+ /* v8 ignore next -- defensive: the oklch converter never returns undefined for the constructed rgb object formatColor passes */
193
200
  if (!oklch) return "oklch(0% 0 0)";
194
201
  const l = `${trim((oklch.l * 100).toFixed(2))}%`;
195
202
  const c = trim(oklch.c.toFixed(3));
196
203
  const h = trim(wrapHue(oklch.h ?? 0).toFixed(2));
204
+ /* v8 ignore next -- alpha is always set: formatColor builds the source rgb with `alpha: rgb.a`, so the `?? 1` fallback is unreachable */
197
205
  const a = oklch.alpha ?? 1;
198
206
  if (!withAlpha || a >= 1) return `oklch(${l} ${c} ${h})`;
199
207
  return `oklch(${l} ${c} ${h} / ${trim(a.toFixed(3))})`;
@@ -218,6 +226,7 @@ function wrapHue(n) {
218
226
  return (n % 360 + 360) % 360;
219
227
  }
220
228
  function trim(s) {
229
+ /* v8 ignore next -- every caller passes a `toFixed(n)` string, which always contains a "."; the no-dot `: s` branch is unreachable */
221
230
  return s.includes(".") ? s.replace(/0+$/, "").replace(/\.$/, "") : s;
222
231
  }
223
232
  //#endregion
@@ -19,6 +19,8 @@ function ColorPickerEyeDropper({ className }) {
19
19
  }, []);
20
20
  if (!supported) return null;
21
21
  const pick = async () => {
22
+ /* v8 ignore next -- defensive: the button is rendered `disabled` when `disabled` is set, so
23
+ React never dispatches this click; the guard can't be reached through the rendered button */
22
24
  if (disabled) return;
23
25
  const Ctor = window.EyeDropper;
24
26
  if (!Ctor) return;
@@ -26,6 +26,8 @@ function ColorPickerInput({ className }) {
26
26
  setDraft(next);
27
27
  if (!accepts(next, format, alpha)) return;
28
28
  const rgb = parseColor(format === "hex" ? withHash(next) : next);
29
+ /* v8 ignore next -- defensive: `accepts` already proved `next` parses, and culori's rgb
30
+ converter never fails on a parsed color, so parseColor can't return null here */
29
31
  if (!rgb) return;
30
32
  const constrained = {
31
33
  ...rgb,
@@ -68,7 +68,9 @@ function ChannelSlider({ slot, thumbSlot, ariaLabel, value, max, disabled, track
68
68
  max,
69
69
  step: 1,
70
70
  disabled,
71
- onValueChange: (next) => onValueChange(typeof next === "number" ? next : next[0] ?? 0),
71
+ onValueChange: (next) => {
72
+ onValueChange(typeof next === "number" ? next : next[0] ?? 0);
73
+ },
72
74
  className: clsx$1(className),
73
75
  children: /* @__PURE__ */ jsxs(Slider.Control, {
74
76
  "data-color-picker-channel-control": true,
@@ -62,6 +62,7 @@ function SelectValue({ className, children, placeholder, ...props }) {
62
62
  const { multiple } = useSelectContext("Value");
63
63
  const formatter = useNoctisStringFormatter();
64
64
  const render = children === void 0 && multiple ? (value) => {
65
+ /* v8 ignore next -- non-array value never reaches a multiple Select.Value */
65
66
  const count = Array.isArray(value) ? value.length : 0;
66
67
  return count > 0 ? formatter.format("select.selectedCount", { count }) : placeholder ?? null;
67
68
  } : children;
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { Sheet } from "./sheet.js";
3
- import { createContext, use, useMemo, useReducer, useRef } from "react";
3
+ import { createContext, use, useEffect, useMemo, useReducer, useRef, useState } from "react";
4
4
  import { jsx, jsxs } from "react/jsx-runtime";
5
5
  //#region src/components/sheet/sheet-stack.tsx
6
6
  /** The empty stack. */
@@ -111,24 +111,43 @@ function useSheetStackContext() {
111
111
  if (!manager) throw new Error("useSheetStackContext must be used within <SheetStack>.");
112
112
  return manager;
113
113
  }
114
- /** Render layers from `index` down, each nested inside the one before it so the stack reads as depth. */
115
- function renderLayer(entries, index, manager) {
116
- if (index >= entries.length) return null;
117
- const entry = entries[index];
118
- if (!entry) return null;
114
+ /**
115
+ * One stacked layer. A `manager.push` adds the entry already `open`, so handing that straight to
116
+ * `Sheet.Root` would mount Base UI's dialog in the open state and skip its enter transition — the panel
117
+ * would snap in with no slide. Instead the root mounts closed and flips to the entry's visibility on the
118
+ * first commit, so Base UI runs the closed→open transition and the layer animates in like a
119
+ * trigger-opened sheet. After that the local state tracks `entry.open`, so `pop`/`close` animate out too.
120
+ */
121
+ function SheetStackLayer({ entry, manager, children }) {
122
+ const [open, setOpen] = useState(false);
123
+ useEffect(() => {
124
+ setOpen(entry.open);
125
+ }, [entry.open]);
119
126
  return /* @__PURE__ */ jsx(Sheet.Root, {
120
- open: entry.open,
121
- onOpenChange: (open) => {
122
- if (!open) manager.close(entry.key);
127
+ open,
128
+ onOpenChange: (next) => {
129
+ /* v8 ignore next -- next===true never fires: layers open via the controlled `open` prop (manager.push), never by Base UI requesting open on a triggerless stack root */
130
+ if (!next) manager.close(entry.key);
123
131
  },
124
- onOpenChangeComplete: (open) => {
125
- if (!open) manager.remove(entry.key);
132
+ onOpenChangeComplete: (next) => {
133
+ if (!next) manager.remove(entry.key);
126
134
  },
127
135
  children: /* @__PURE__ */ jsxs(Sheet.Content, {
128
136
  side: entry.side,
129
137
  size: entry.size,
130
- children: [entry.content, renderLayer(entries, index + 1, manager)]
138
+ children: [entry.content, children]
131
139
  })
140
+ });
141
+ }
142
+ /** Render layers from `index` down, each nested inside the one before it so the stack reads as depth. */
143
+ function renderLayer(entries, index, manager) {
144
+ if (index >= entries.length) return null;
145
+ const entry = entries[index];
146
+ if (!entry) return null;
147
+ return /* @__PURE__ */ jsx(SheetStackLayer, {
148
+ entry,
149
+ manager,
150
+ children: renderLayer(entries, index + 1, manager)
132
151
  }, entry.key);
133
152
  }
134
153
  /**
@@ -16,6 +16,7 @@ declare function NoctisProvider({
16
16
  messages,
17
17
  theme,
18
18
  themeOverrides,
19
+ themePresets,
19
20
  nonce,
20
21
  children
21
22
  }: NoctisProvider.Props): ReactElement;
@@ -29,7 +30,12 @@ declare namespace NoctisProvider {
29
30
  direction?: TextDirection; /** String dictionary for the localized-string hooks. Defaults to the built-in `en` stub. */
30
31
  messages?: LocalizedStrings<string, string>; /** Server-resolved theme seed for the engine; omit it for the default theme. */
31
32
  theme?: ThemeProviderProps["initialInput"]; /** Generation-time engine overrides — per-primitive replacements that participate in derivation. */
32
- themeOverrides?: ThemeProviderProps["overrides"]; /** CSP nonce stamped on the inline styles Base UI injects, so they stay valid under a strict CSP. */
33
+ themeOverrides?: ThemeProviderProps["overrides"];
34
+ /**
35
+ * Named theme seeds advertised to the tree via `useTheme().presets` — what a theme picker offers.
36
+ * Defaults to the presets Noctis ships; pass your own palette to showcase additional themes.
37
+ */
38
+ themePresets?: ThemeProviderProps["presets"]; /** CSP nonce stamped on the inline styles Base UI injects, so they stay valid under a strict CSP. */
33
39
  nonce?: string;
34
40
  children: ReactNode;
35
41
  };
@@ -14,7 +14,7 @@ import { ThemeProvider } from "@stridge/noctis-design-tokens/react";
14
14
  * it for the default theme. RTL-aware primitives resolve the active direction from Base UI's
15
15
  * innermost `DirectionProvider`.
16
16
  */
17
- function NoctisProvider({ locale, direction, messages, theme, themeOverrides, nonce, children }) {
17
+ function NoctisProvider({ locale, direction, messages, theme, themeOverrides, themePresets, nonce, children }) {
18
18
  const resolvedDirection = direction ?? (isRTL(locale) ? "rtl" : "ltr");
19
19
  const value = useMemo(() => ({
20
20
  locale,
@@ -30,6 +30,7 @@ function NoctisProvider({ locale, direction, messages, theme, themeOverrides, no
30
30
  children: /* @__PURE__ */ jsx(ThemeProvider, {
31
31
  initialInput: theme,
32
32
  overrides: themeOverrides,
33
+ presets: themePresets,
33
34
  children: /* @__PURE__ */ jsx(LocaleContext.Provider, {
34
35
  value,
35
36
  children: /* @__PURE__ */ jsx(DirectionProvider, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stridge/noctis",
3
- "version": "1.0.0-beta.0",
3
+ "version": "1.0.0-beta.3",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -69,9 +69,9 @@
69
69
  "culori": "4.0.2",
70
70
  "lucide-react": "1.17.0",
71
71
  "simple-icons": "16.23.0",
72
- "@stridge/noctis-design-tokens": "1.0.0-beta.0",
73
- "@stridge/noctis-intl": "1.0.0-beta.0",
74
- "@stridge/noctis-theme-engine": "1.0.0-beta.0"
72
+ "@stridge/noctis-design-tokens": "1.0.0-beta.3",
73
+ "@stridge/noctis-theme-engine": "1.0.0-beta.3",
74
+ "@stridge/noctis-intl": "1.0.0-beta.3"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "react": "19.2.7",
@@ -106,6 +106,7 @@
106
106
  "tsdown": "0.21.10",
107
107
  "typescript": "6.0.3",
108
108
  "vitest": "4.1.8",
109
+ "@vitest/coverage-v8": "4.1.8",
109
110
  "@stridge/noctis-typescript": "0.0.0",
110
111
  "@stridge/noctis-test-utils": "0.0.0"
111
112
  },
@@ -115,7 +116,7 @@
115
116
  "check:publint": "publint",
116
117
  "check:size": "size-limit",
117
118
  "check:publish": "pnpm run check:publint && pnpm run check:size",
118
- "test": "vitest --run",
119
+ "test": "vitest --run --coverage --maxWorkers=2",
119
120
  "primitives:gen": "node scripts/generate-primitives.mjs"
120
121
  }
121
122
  }