@crisp-ui-kit/crisp 0.37.0 → 0.38.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.
Files changed (40) hide show
  1. package/dist/badge.css +5 -5
  2. package/dist/badge.layered.css +5 -5
  3. package/dist/cjs/components/badge/badge.recipe.js +11 -5
  4. package/dist/cjs/components/fileupload/fileupload.js +2 -2
  5. package/dist/cjs/components/multiselect/multiselect.js +3 -3
  6. package/dist/cjs/components/multiselect/multiselect.recipe.js +13 -2
  7. package/dist/cjs/components/select/select.js +3 -3
  8. package/dist/cjs/components/select/select.recipe.js +14 -1
  9. package/dist/cjs/tokens/palette.js +23 -0
  10. package/dist/cjs/tokens/semantic.js +7 -0
  11. package/dist/esm/components/badge/badge.recipe.js +11 -5
  12. package/dist/esm/components/fileupload/fileupload.js +2 -2
  13. package/dist/esm/components/multiselect/multiselect.js +3 -3
  14. package/dist/esm/components/multiselect/multiselect.recipe.js +13 -2
  15. package/dist/esm/components/select/select.js +3 -3
  16. package/dist/esm/components/select/select.recipe.js +14 -1
  17. package/dist/esm/tokens/palette.js +23 -0
  18. package/dist/esm/tokens/semantic.js +7 -0
  19. package/dist/multiselect.css +6 -0
  20. package/dist/multiselect.d.ts +2 -2
  21. package/dist/multiselect.layered.css +6 -0
  22. package/dist/native/tokens.d.ts +1 -1
  23. package/dist/native/tokens.js +24 -4
  24. package/dist/select.css +6 -0
  25. package/dist/select.d.ts +2 -1
  26. package/dist/select.layered.css +6 -0
  27. package/dist/styles.css +27 -5
  28. package/dist/styles.layered.css +27 -5
  29. package/dist/tokens.css +10 -0
  30. package/dist/tokens.json +24 -4
  31. package/dist/types/components/fileupload/fileupload.d.ts +1 -1
  32. package/dist/types/components/multiselect/multiselect.d.ts +3 -1
  33. package/dist/types/components/multiselect/multiselect.recipe.d.ts +18 -3
  34. package/dist/types/components/select/select.d.ts +4 -1
  35. package/dist/types/components/select/select.recipe.d.ts +12 -0
  36. package/dist/types/product/sidebar/index.d.ts +1 -1
  37. package/dist/types/tokens/contract.d.ts +5 -0
  38. package/dist/types/tokens/palette.d.ts +20 -0
  39. package/dist/types/tokens/semantic.d.ts +7 -0
  40. package/package.json +1 -1
package/dist/badge.css CHANGED
@@ -3,19 +3,19 @@
3
3
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
4
4
  }
5
5
  .crisp-badge--tone_neutral {
6
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
6
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
7
7
  }
8
8
  .crisp-badge--tone_brand {
9
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
9
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
10
10
  }
11
11
  .crisp-badge--tone_success {
12
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
12
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
13
13
  }
14
14
  .crisp-badge--tone_warning {
15
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
15
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
16
16
  }
17
17
  .crisp-badge--tone_danger {
18
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
18
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
19
19
  }
20
20
  .crisp-badge--size_small {
21
21
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -4,19 +4,19 @@
4
4
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
5
5
  }
6
6
  .crisp-badge--tone_neutral {
7
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
7
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
8
8
  }
9
9
  .crisp-badge--tone_brand {
10
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
10
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
11
11
  }
12
12
  .crisp-badge--tone_success {
13
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
13
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
14
14
  }
15
15
  .crisp-badge--tone_warning {
16
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
16
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
17
17
  }
18
18
  .crisp-badge--tone_danger {
19
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
19
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
20
20
  }
21
21
  .crisp-badge--size_small {
22
22
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -32,29 +32,35 @@ const spec = (0, core_1.defineSpec)({
32
32
  ],
33
33
  variants: {
34
34
  tone: {
35
+ // #323: the soft text colour is a dedicated fg*Soft token, not the
36
+ // tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
37
+ // — reusing the accent as soft text is exactly what measured below AA
38
+ // (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
39
+ // ring are unchanged: they were never the problem, and neither is a
40
+ // measured Attio value that moving would help — only the text is.
35
41
  neutral: {
36
42
  background: tokens_1.contract.bgRaised,
37
- color: tokens_1.contract.fgMuted,
43
+ color: tokens_1.contract.fgNeutralSoft,
38
44
  boxShadow: `inset 0 0 0 1px ${tokens_1.contract.borderDefault}`,
39
45
  },
40
46
  brand: {
41
47
  background: soft(tokens_1.contract.bgBrandSolid, 14),
42
- color: tokens_1.contract.bgBrandSolid,
48
+ color: tokens_1.contract.fgBrandSoft,
43
49
  boxShadow: ring(tokens_1.contract.bgBrandSolid, 30),
44
50
  },
45
51
  success: {
46
52
  background: soft(tokens_1.contract.bgSuccess, 14),
47
- color: tokens_1.contract.bgSuccess,
53
+ color: tokens_1.contract.fgSuccessSoft,
48
54
  boxShadow: ring(tokens_1.contract.bgSuccess, 30),
49
55
  },
50
56
  warning: {
51
57
  background: soft(tokens_1.contract.bgWarning, 16),
52
- color: tokens_1.contract.bgWarning,
58
+ color: tokens_1.contract.fgWarningSoft,
53
59
  boxShadow: ring(tokens_1.contract.bgWarning, 32),
54
60
  },
55
61
  danger: {
56
62
  background: soft(tokens_1.contract.bgDanger, 14),
57
- color: tokens_1.contract.bgDanger,
63
+ color: tokens_1.contract.fgDangerSoft,
58
64
  boxShadow: ring(tokens_1.contract.bgDanger, 30),
59
65
  },
60
66
  },
@@ -73,7 +73,7 @@ function formatSize(bytes) {
73
73
  * is theirs by construction. Controlled or uncontrolled, the same way every
74
74
  * other crisp component is.
75
75
  */
76
- function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
76
+ function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
77
77
  const props = {
78
78
  accept,
79
79
  disabled: disabledProp,
@@ -124,7 +124,7 @@ function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabl
124
124
  // focus should land next.
125
125
  const rootRef = React.useRef(null);
126
126
  const focusAfterRemoval = (0, core_1.useFocusAfterRemoval)(rootRef, ".crisp-fileupload-remove", dropzoneRef);
127
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
127
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, ...rest, className: (0, fileupload_recipe_1.fileuploadRecipe)({ className }), children: [(0, jsx_runtime_1.jsxs)("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-title", children: t.title }), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-hint", children: t.hint })] }), (0, jsx_runtime_1.jsx)("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && ((0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => ((0, jsx_runtime_1.jsxs)("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), (0, jsx_runtime_1.jsx)("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), (0, jsx_runtime_1.jsx)("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
128
128
  focusAfterRemoval(i);
129
129
  removeFileEntry(entry.id);
130
130
  }, children: (0, jsx_runtime_1.jsx)(lucide_react_1.X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), (0, jsx_runtime_1.jsx)("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => ((0, jsx_runtime_1.jsx)("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
@@ -69,7 +69,7 @@ function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, n
69
69
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
70
70
  // LABELABLE element, so an id left on the root div meant a FormField label
71
71
  // above this control named nothing (#247). Select already did this.
72
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
72
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
73
73
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
74
74
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
75
75
  // styling hook only -- a screen reader cannot see it.
@@ -182,8 +182,8 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
182
182
  document.addEventListener("pointerdown", onDown);
183
183
  return () => document.removeEventListener("pointerdown", onDown);
184
184
  }, [open]);
185
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ className }), ...rest, children: [name &&
186
- (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
185
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, multiselect_recipe_1.multiselectRecipe)({ invalid, className }), ...rest, children: [name &&
186
+ (value.length > 0 ? value : required ? [""] : []).map((v) => ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
187
187
  if (open && !searchable)
188
188
  onNavKeys(e);
189
189
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -62,8 +62,19 @@ const config = (0, core_1.defineRecipe)({
62
62
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${tokens_1.contract.bgCyan} 18%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgCyan} 82%, ${tokens_1.contract.fgPrimary}); }
63
63
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${tokens_1.contract.bgOrange} 16%, transparent); color: color-mix(in srgb, ${tokens_1.contract.bgOrange} 82%, ${tokens_1.contract.fgPrimary}); }
64
64
  `,
65
- variants: {},
66
- defaultVariants: {},
65
+ variants: {
66
+ // Same ring Select's `invalid` draws — see that recipe's comment.
67
+ invalid: {
68
+ true: `
69
+ & .crisp-multiselect-trigger,
70
+ & .crisp-multiselect-trigger:hover,
71
+ & .crisp-multiselect-trigger[data-open],
72
+ & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
73
+ `,
74
+ false: ``,
75
+ },
76
+ },
77
+ defaultVariants: { invalid: false },
67
78
  compoundVariants: [],
68
79
  });
69
80
  exports.multiselectRecipe = Object.assign((0, core_1.resolveRecipe)(config), {
@@ -52,7 +52,7 @@ function optionText(o) {
52
52
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
53
53
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
54
54
  */
55
- function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
55
+ function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
56
56
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
57
57
  // active option is named by `aria-activedescendant`. That is the only thing a
58
58
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -177,10 +177,10 @@ function Select({ options, value, defaultValue, onValueChange, name, required, p
177
177
  document.addEventListener("pointerdown", onDown);
178
178
  return () => document.removeEventListener("pointerdown", onDown);
179
179
  }, [open]);
180
- return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
180
+ return ((0, jsx_runtime_1.jsxs)("div", { ref: rootRef, className: (0, select_recipe_1.selectRecipe)({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && ((0, jsx_runtime_1.jsx)("input", { className: "crisp-formvalue", style: core_1.formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
181
181
  // controlled by the listbox above; `readOnly` would bar it from
182
182
  // validation, which is the only reason this input is not hidden
183
- onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
183
+ onChange: () => { } })), (0, jsx_runtime_1.jsxs)("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [(0, jsx_runtime_1.jsx)("span", { className: "crisp-select-value", children: selectedOption ? ((0, jsx_runtime_1.jsxs)(jsx_runtime_1.Fragment, { children: [selectedOption.color && ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : ((0, jsx_runtime_1.jsx)("span", { className: "crisp-select-placeholder", children: placeholder })) }), (0, jsx_runtime_1.jsx)(lucide_react_1.ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && ((0, jsx_runtime_1.jsx)(core_1.FloatingPortal, { children: (0, jsx_runtime_1.jsxs)("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && ((0, jsx_runtime_1.jsxs)("div", { className: "crisp-select-search", children: [(0, jsx_runtime_1.jsx)(lucide_react_1.Search, { size: 15, strokeWidth: 1.75 }), (0, jsx_runtime_1.jsx)("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
184
184
  setQuery(e.target.value);
185
185
  setHighlight(0);
186
186
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? ((0, jsx_runtime_1.jsx)("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -73,8 +73,21 @@ const config = (0, core_1.defineRecipe)({
73
73
  full: `width: 100%;`,
74
74
  auto: `width: auto;`,
75
75
  },
76
+ // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
77
+ // trigger (a real <button>, so it needs no ::after overlay the way
78
+ // Input/Textarea do). Outranks hover/open/focus, same reasoning as
79
+ // Input: a field being corrected stays visibly wrong while it is.
80
+ invalid: {
81
+ true: `
82
+ & .crisp-select-trigger,
83
+ & .crisp-select-trigger:hover,
84
+ & .crisp-select-trigger[data-open],
85
+ & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${tokens_1.contract.bgDanger}; }
86
+ `,
87
+ false: ``,
88
+ },
76
89
  },
77
- defaultVariants: { variant: "default", width: "full" },
90
+ defaultVariants: { variant: "default", width: "full", invalid: false },
78
91
  compoundVariants: [],
79
92
  });
80
93
  exports.selectRecipe = Object.assign((0, core_1.resolveRecipe)(config), { config });
@@ -60,4 +60,27 @@ exports.palette = {
60
60
  cyan600: { light: "#00b9eb", dark: "#00b9eb" },
61
61
  purple600: { light: "#9b69ff", dark: "#9b69ff" },
62
62
  graySidebar: { light: "#fbfbfb", dark: "#0e0f10" },
63
+ // Soft Badge's text colour (#323): the *600 tones above, used directly as
64
+ // soft-badge text, fail AA on their own soft tint in light mode (2.97-3.87)
65
+ // and brand fails in dark too (3.83) -- everything else already brightens
66
+ // enough in dark to pass (measured: 5.60-6.77). Attio's own soft tag pills
67
+ // (a company's Categories field, app.attio.com, 2026-09-23) don't reuse the
68
+ // accent colour as text either: they shift it toward black (light) or white
69
+ // (dark) at roughly the same relative luminance regardless of hue --
70
+ // measured 5 different category colours in dark mode (L 0.61-0.75) and two
71
+ // in light (L 0.08-0.10), a tight enough cluster to generalise the SHIFT,
72
+ // not the exact colour, to crisp's own four accent hues. Computed to clear
73
+ // AA with the same margin Attio's pills measured at (5.5-6.5:1) against
74
+ // crisp's EXISTING soft-tint background -- the tint itself is unchanged.
75
+ // Dark values that already passed keep their *600 dark value unchanged.
76
+ softBrandText: { light: "#2057bd", dark: "#568df3" },
77
+ softSuccessText: { light: "#186b3d", dark: "#2ea968" },
78
+ softWarningText: { light: "#80520d", dark: "#e0982a" },
79
+ softDangerText: { light: "#a33539", dark: "#ff5a5f" },
80
+ // Same reasoning, for Badge's neutral tone: fg-muted on bg-raised measures
81
+ // 4.40 in light (just under AA) and 6.77 in dark (already fine). Unlike
82
+ // fg-muted/bg-raised themselves (system-wide, measured from Attio, kept
83
+ // as-is per build/spec-check.mjs's own note), this token exists only for
84
+ // Badge's soft-neutral text, so darkening it doesn't move any other surface.
85
+ softNeutralText: { light: "#686870", dark: "#9a9aa2" },
63
86
  };
@@ -54,4 +54,11 @@ exports.semantic = {
54
54
  // #fbfbfb — the subtle raised fill; the active tab pill uses the same
55
55
  // value as the sidebar, so it shares the palette entry under a neutral name.
56
56
  bgSubtle: "graySidebar",
57
+ /** Soft Badge's text colour (#323) — see palette.ts's comment on the
58
+ * soft*Text entries for the Attio measurement this is derived from. */
59
+ fgBrandSoft: "softBrandText",
60
+ fgSuccessSoft: "softSuccessText",
61
+ fgWarningSoft: "softWarningText",
62
+ fgDangerSoft: "softDangerText",
63
+ fgNeutralSoft: "softNeutralText",
57
64
  };
@@ -29,29 +29,35 @@ const spec = defineSpec({
29
29
  ],
30
30
  variants: {
31
31
  tone: {
32
+ // #323: the soft text colour is a dedicated fg*Soft token, not the
33
+ // tone's own accent (fgMuted/bgBrandSolid/bgSuccess/bgWarning/bgDanger)
34
+ // — reusing the accent as soft text is exactly what measured below AA
35
+ // (2.97-4.40 in light; brand also failed in dark, 3.83). Background and
36
+ // ring are unchanged: they were never the problem, and neither is a
37
+ // measured Attio value that moving would help — only the text is.
32
38
  neutral: {
33
39
  background: c.bgRaised,
34
- color: c.fgMuted,
40
+ color: c.fgNeutralSoft,
35
41
  boxShadow: `inset 0 0 0 1px ${c.borderDefault}`,
36
42
  },
37
43
  brand: {
38
44
  background: soft(c.bgBrandSolid, 14),
39
- color: c.bgBrandSolid,
45
+ color: c.fgBrandSoft,
40
46
  boxShadow: ring(c.bgBrandSolid, 30),
41
47
  },
42
48
  success: {
43
49
  background: soft(c.bgSuccess, 14),
44
- color: c.bgSuccess,
50
+ color: c.fgSuccessSoft,
45
51
  boxShadow: ring(c.bgSuccess, 30),
46
52
  },
47
53
  warning: {
48
54
  background: soft(c.bgWarning, 16),
49
- color: c.bgWarning,
55
+ color: c.fgWarningSoft,
50
56
  boxShadow: ring(c.bgWarning, 32),
51
57
  },
52
58
  danger: {
53
59
  background: soft(c.bgDanger, 14),
54
- color: c.bgDanger,
60
+ color: c.fgDangerSoft,
55
61
  boxShadow: ring(c.bgDanger, 30),
56
62
  },
57
63
  },
@@ -36,7 +36,7 @@ function formatSize(bytes) {
36
36
  * is theirs by construction. Controlled or uncontrolled, the same way every
37
37
  * other crisp component is.
38
38
  */
39
- export function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
39
+ export function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }) {
40
40
  const props = {
41
41
  accept,
42
42
  disabled: disabledProp,
@@ -87,7 +87,7 @@ export function FileUpload({ className, labels, "aria-label": ariaLabel, accept,
87
87
  // focus should land next.
88
88
  const rootRef = React.useRef(null);
89
89
  const focusAfterRemoval = useFocusAfterRemoval(rootRef, ".crisp-fileupload-remove", dropzoneRef);
90
- return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
90
+ return (_jsxs("div", { ref: rootRef, ...rest, className: fileuploadRecipe({ className }), children: [_jsxs("button", { ...dropzoneProps, ref: dropzoneRef, type: "button", className: "crisp-fileupload-zone", tabIndex: disabled ? -1 : 0, "aria-label": ariaLabel ?? t.hint, disabled: disabled, "data-dragging": dragging ? "" : undefined, "data-disabled": disabled ? "" : undefined, "data-invalid": invalid ? "" : undefined, onClick: () => openFilePicker(), children: [_jsx("span", { className: "crisp-fileupload-title", children: t.title }), _jsx("span", { className: "crisp-fileupload-hint", children: t.hint })] }), _jsx("input", { ...hiddenInputProps, id: id ?? hiddenInputProps.id, ref: inputRef, className: "crisp-fileupload-input" }), acceptedFileEntries.length > 0 && (_jsx("ul", { className: "crisp-fileupload-list", children: acceptedFileEntries.map((entry, i) => (_jsxs("li", { className: "crisp-fileupload-item", "data-status": entry.status, children: [_jsx("span", { className: "crisp-fileupload-item-name", children: entry.file.name }), entry.status === "uploading" && entry.progress != null && (_jsx("span", { className: "crisp-fileupload-progress", role: "progressbar", "aria-valuenow": Math.round(entry.progress), "aria-valuemin": 0, "aria-valuemax": 100, "aria-label": entry.file.name, children: _jsx("span", { className: "crisp-fileupload-progress-fill", style: { width: `${entry.progress}%` } }) })), _jsx("span", { className: "crisp-fileupload-item-size", children: formatSize(entry.file.size) }), _jsx("button", { type: "button", className: "crisp-fileupload-remove", "aria-label": `${t.remove}: ${entry.file.name}`, onClick: () => {
91
91
  focusAfterRemoval(i);
92
92
  removeFileEntry(entry.id);
93
93
  }, children: _jsx(X, { size: 13, strokeWidth: 2 }) })] }, entry.id))) })), _jsx("ul", { className: "crisp-fileupload-errors", "aria-live": "polite", children: rejections.map((r) => (_jsx("li", { className: "crisp-fileupload-error", children: `${r.file.name}: ${r.errors.map((e) => t.errors[e] ?? e).join(" ")}` }, r.file.name))) })] }));
@@ -33,7 +33,7 @@ export function MultiSelect({ options, value: valueProp, defaultValue, onValueCh
33
33
  // The id belongs on the trigger, not the wrapper: `<label for>` only names a
34
34
  // LABELABLE element, so an id left on the root div meant a FormField label
35
35
  // above this control named nothing (#247). Select already did this.
36
- id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, ...rest }) {
36
+ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, ...rest }) {
37
37
  // Virtual focus: DOM focus stays on the trigger (or the search field) and the
38
38
  // active option is named by `aria-activedescendant`. `data-highlighted` is a
39
39
  // styling hook only -- a screen reader cannot see it.
@@ -146,8 +146,8 @@ id, placeholder = "Select…", searchable = true, searchPlaceholder = "Search…
146
146
  document.addEventListener("pointerdown", onDown);
147
147
  return () => document.removeEventListener("pointerdown", onDown);
148
148
  }, [open]);
149
- return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ className }), ...rest, children: [name &&
150
- (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
149
+ return (_jsxs("div", { ref: rootRef, className: multiselectRecipe({ invalid, className }), ...rest, children: [name &&
150
+ (value.length > 0 ? value : required ? [""] : []).map((v) => (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: v, required: required, tabIndex: -1, "aria-hidden": "true", onChange: () => { } }, v))), _jsxs("button", { ref: triggerRef, id: id, type: "button", role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-multiselect-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: (e) => {
151
151
  if (open && !searchable)
152
152
  onNavKeys(e);
153
153
  else if (!open && (e.key === "ArrowDown" || e.key === "Enter")) {
@@ -59,8 +59,19 @@ const config = defineRecipe({
59
59
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, ${c.bgCyan} 18%, transparent); color: color-mix(in srgb, ${c.bgCyan} 82%, ${c.fgPrimary}); }
60
60
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, ${c.bgOrange} 16%, transparent); color: color-mix(in srgb, ${c.bgOrange} 82%, ${c.fgPrimary}); }
61
61
  `,
62
- variants: {},
63
- defaultVariants: {},
62
+ variants: {
63
+ // Same ring Select's `invalid` draws — see that recipe's comment.
64
+ invalid: {
65
+ true: `
66
+ & .crisp-multiselect-trigger,
67
+ & .crisp-multiselect-trigger:hover,
68
+ & .crisp-multiselect-trigger[data-open],
69
+ & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
70
+ `,
71
+ false: ``,
72
+ },
73
+ },
74
+ defaultVariants: { invalid: false },
64
75
  compoundVariants: [],
65
76
  });
66
77
  export const multiselectRecipe = Object.assign(resolveRecipe(config), {
@@ -16,7 +16,7 @@ function optionText(o) {
16
16
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
17
17
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
18
18
  */
19
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, className, "aria-label": ariaLabel, id, ...rest }) {
19
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder = "Select…", variant = "default", width = "full", searchable, searchPlaceholder = "Search…", emptyLabel = "No results", disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
20
20
  // Virtual focus: DOM focus stays on the trigger (or the search field), and the
21
21
  // active option is named by `aria-activedescendant`. That is the only thing a
22
22
  // screen reader has to go on -- `data-highlighted` is invisible to it -- so the
@@ -141,10 +141,10 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
141
141
  document.addEventListener("pointerdown", onDown);
142
142
  return () => document.removeEventListener("pointerdown", onDown);
143
143
  }, [open]);
144
- return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
144
+ return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, invalid, className }), ...rest, children: [name && (selected || required) && (_jsx("input", { className: "crisp-formvalue", style: formValueStyle, name: name, value: selected ?? "", required: required, tabIndex: -1, "aria-hidden": "true",
145
145
  // controlled by the listbox above; `readOnly` would bar it from
146
146
  // validation, which is the only reason this input is not hidden
147
- onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
147
+ onChange: () => { } })), _jsxs("button", { ref: triggerRef, type: "button", id: id, role: "combobox", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listboxId : undefined, "aria-activedescendant": searchable ? undefined : activeId, "aria-label": ariaLabel, "aria-invalid": invalid === true || undefined, disabled: disabled, "data-open": open ? "" : undefined, className: "crisp-select-trigger", onClick: () => (open ? setOpen(false) : openMenu()), onKeyDown: onTriggerKeyDown, children: [_jsx("span", { className: "crisp-select-value", children: selectedOption ? (_jsxs(_Fragment, { children: [selectedOption.color && (_jsx("span", { className: "crisp-select-option-dot", style: { background: selectedOption.color } })), selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { children: _jsxs("div", { ref: listboxRef, id: listboxId, className: "crisp-select-listbox", role: "listbox", "aria-label": ariaLabel, "data-up": up ? "" : undefined, style: pos, children: [searchable && (_jsxs("div", { className: "crisp-select-search", children: [_jsx(Search, { size: 15, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
148
148
  setQuery(e.target.value);
149
149
  setHighlight(0);
150
150
  }, onKeyDown: onNavKeys })] })), filtered.length === 0 ? (_jsx("div", { className: "crisp-select-empty", children: emptyLabel })) : (filtered.map((opt, i) => {
@@ -70,8 +70,21 @@ const config = defineRecipe({
70
70
  full: `width: 100%;`,
71
71
  auto: `width: auto;`,
72
72
  },
73
+ // Same ring Input's `invalid` draws, adapted to Select's box-shadow-ring
74
+ // trigger (a real <button>, so it needs no ::after overlay the way
75
+ // Input/Textarea do). Outranks hover/open/focus, same reasoning as
76
+ // Input: a field being corrected stays visibly wrong while it is.
77
+ invalid: {
78
+ true: `
79
+ & .crisp-select-trigger,
80
+ & .crisp-select-trigger:hover,
81
+ & .crisp-select-trigger[data-open],
82
+ & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px ${c.bgDanger}; }
83
+ `,
84
+ false: ``,
85
+ },
73
86
  },
74
- defaultVariants: { variant: "default", width: "full" },
87
+ defaultVariants: { variant: "default", width: "full", invalid: false },
75
88
  compoundVariants: [],
76
89
  });
77
90
  export const selectRecipe = Object.assign(resolveRecipe(config), { config });
@@ -57,4 +57,27 @@ export const palette = {
57
57
  cyan600: { light: "#00b9eb", dark: "#00b9eb" },
58
58
  purple600: { light: "#9b69ff", dark: "#9b69ff" },
59
59
  graySidebar: { light: "#fbfbfb", dark: "#0e0f10" },
60
+ // Soft Badge's text colour (#323): the *600 tones above, used directly as
61
+ // soft-badge text, fail AA on their own soft tint in light mode (2.97-3.87)
62
+ // and brand fails in dark too (3.83) -- everything else already brightens
63
+ // enough in dark to pass (measured: 5.60-6.77). Attio's own soft tag pills
64
+ // (a company's Categories field, app.attio.com, 2026-09-23) don't reuse the
65
+ // accent colour as text either: they shift it toward black (light) or white
66
+ // (dark) at roughly the same relative luminance regardless of hue --
67
+ // measured 5 different category colours in dark mode (L 0.61-0.75) and two
68
+ // in light (L 0.08-0.10), a tight enough cluster to generalise the SHIFT,
69
+ // not the exact colour, to crisp's own four accent hues. Computed to clear
70
+ // AA with the same margin Attio's pills measured at (5.5-6.5:1) against
71
+ // crisp's EXISTING soft-tint background -- the tint itself is unchanged.
72
+ // Dark values that already passed keep their *600 dark value unchanged.
73
+ softBrandText: { light: "#2057bd", dark: "#568df3" },
74
+ softSuccessText: { light: "#186b3d", dark: "#2ea968" },
75
+ softWarningText: { light: "#80520d", dark: "#e0982a" },
76
+ softDangerText: { light: "#a33539", dark: "#ff5a5f" },
77
+ // Same reasoning, for Badge's neutral tone: fg-muted on bg-raised measures
78
+ // 4.40 in light (just under AA) and 6.77 in dark (already fine). Unlike
79
+ // fg-muted/bg-raised themselves (system-wide, measured from Attio, kept
80
+ // as-is per build/spec-check.mjs's own note), this token exists only for
81
+ // Badge's soft-neutral text, so darkening it doesn't move any other surface.
82
+ softNeutralText: { light: "#686870", dark: "#9a9aa2" },
60
83
  };
@@ -51,4 +51,11 @@ export const semantic = {
51
51
  // #fbfbfb — the subtle raised fill; the active tab pill uses the same
52
52
  // value as the sidebar, so it shares the palette entry under a neutral name.
53
53
  bgSubtle: "graySidebar",
54
+ /** Soft Badge's text colour (#323) — see palette.ts's comment on the
55
+ * soft*Text entries for the Attio measurement this is derived from. */
56
+ fgBrandSoft: "softBrandText",
57
+ fgSuccessSoft: "softSuccessText",
58
+ fgWarningSoft: "softWarningText",
59
+ fgDangerSoft: "softDangerText",
60
+ fgNeutralSoft: "softNeutralText",
54
61
  };
@@ -30,6 +30,12 @@
30
30
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, var(--crisp-bg-cyan) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-cyan) 82%, var(--crisp-fg-primary)); }
31
31
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
32
32
  }
33
+ .crisp-multiselect--invalid_true {
34
+ & .crisp-multiselect-trigger, & .crisp-multiselect-trigger:hover, & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
35
+ }
36
+ .crisp-multiselect--invalid_false {
37
+
38
+ }
33
39
 
34
40
  @media (prefers-reduced-motion: reduce) {
35
41
  .crisp-multiselect, .crisp-multiselect * {
@@ -1,7 +1,7 @@
1
1
  import type { RecipeFn } from "../core/types";
2
2
 
3
3
  export interface MultiselectVariants {
4
-
4
+ invalid?: boolean;
5
5
  }
6
6
 
7
- export declare const multiselectRecipe: RecipeFn<{ }>;
7
+ export declare const multiselectRecipe: RecipeFn<{ invalid: { true: string; false: string } }>;
@@ -31,6 +31,12 @@
31
31
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, var(--crisp-bg-cyan) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-cyan) 82%, var(--crisp-fg-primary)); }
32
32
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
33
33
  }
34
+ .crisp-multiselect--invalid_true {
35
+ & .crisp-multiselect-trigger, & .crisp-multiselect-trigger:hover, & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
36
+ }
37
+ .crisp-multiselect--invalid_false {
38
+
39
+ }
34
40
 
35
41
  @media (prefers-reduced-motion: reduce) {
36
42
  .crisp-multiselect, .crisp-multiselect * {
@@ -2,7 +2,7 @@
2
2
  // The same tokens the CSS custom properties carry, resolved per theme, with
3
3
  // dimensions as numbers for React Native and Lynx style objects.
4
4
  export type Theme = "light" | "dark";
5
- export type ColorTokens = Record<"bgBrandSolid" | "bgBrandSolidPressed" | "fgOnBrand" | "fgOnTone" | "bgSurface" | "bgRaised" | "borderDefault" | "borderSubtle" | "ringNeutral" | "bgTileActive" | "shadowTileActive" | "bgOverlayFrost" | "shadowOverlay1" | "shadowOverlay2" | "shadowOverlay3" | "bgPopover" | "ringPopover" | "shadowPopover1" | "shadowPopover2" | "shadowPopover3" | "fgPrimary" | "fgMuted" | "bgDanger" | "bgDangerSolid" | "strokeFieldFocus" | "fgDisabled" | "bgNeutralWeak" | "bgNeutralInverted" | "fgOnNeutralInverted" | "fgNeutralSubtle" | "fgNeutralMuted" | "strokeFocusRing" | "bgSuccess" | "bgWarning" | "bgOrange" | "bgCyan" | "bgPurple" | "bgSidebar" | "bgSubtle", string>;
5
+ export type ColorTokens = Record<"bgBrandSolid" | "bgBrandSolidPressed" | "fgOnBrand" | "fgOnTone" | "bgSurface" | "bgRaised" | "borderDefault" | "borderSubtle" | "ringNeutral" | "bgTileActive" | "shadowTileActive" | "bgOverlayFrost" | "shadowOverlay1" | "shadowOverlay2" | "shadowOverlay3" | "bgPopover" | "ringPopover" | "shadowPopover1" | "shadowPopover2" | "shadowPopover3" | "fgPrimary" | "fgMuted" | "bgDanger" | "bgDangerSolid" | "strokeFieldFocus" | "fgDisabled" | "bgNeutralWeak" | "bgNeutralInverted" | "fgOnNeutralInverted" | "fgNeutralSubtle" | "fgNeutralMuted" | "strokeFocusRing" | "bgSuccess" | "bgWarning" | "bgOrange" | "bgCyan" | "bgPurple" | "bgSidebar" | "bgSubtle" | "fgBrandSoft" | "fgSuccessSoft" | "fgWarningSoft" | "fgDangerSoft" | "fgNeutralSoft", string>;
6
6
  export declare const tokens: {
7
7
  color: Record<Theme, ColorTokens>;
8
8
  palette: Record<Theme, Record<string, string>>;
@@ -42,7 +42,12 @@ export const tokens = {
42
42
  "bgCyan": "#00b9eb",
43
43
  "bgPurple": "#9b69ff",
44
44
  "bgSidebar": "#fbfbfb",
45
- "bgSubtle": "#fbfbfb"
45
+ "bgSubtle": "#fbfbfb",
46
+ "fgBrandSoft": "#2057bd",
47
+ "fgSuccessSoft": "#186b3d",
48
+ "fgWarningSoft": "#80520d",
49
+ "fgDangerSoft": "#a33539",
50
+ "fgNeutralSoft": "#686870"
46
51
  },
47
52
  "dark": {
48
53
  "bgBrandSolid": "#266df0",
@@ -83,7 +88,12 @@ export const tokens = {
83
88
  "bgCyan": "#00b9eb",
84
89
  "bgPurple": "#9b69ff",
85
90
  "bgSidebar": "#0e0f10",
86
- "bgSubtle": "#0e0f10"
91
+ "bgSubtle": "#0e0f10",
92
+ "fgBrandSoft": "#568df3",
93
+ "fgSuccessSoft": "#2ea968",
94
+ "fgWarningSoft": "#e0982a",
95
+ "fgDangerSoft": "#ff5a5f",
96
+ "fgNeutralSoft": "#9a9aa2"
87
97
  }
88
98
  },
89
99
  "palette": {
@@ -125,7 +135,12 @@ export const tokens = {
125
135
  "orange600": "#f97514",
126
136
  "cyan600": "#00b9eb",
127
137
  "purple600": "#9b69ff",
128
- "graySidebar": "#fbfbfb"
138
+ "graySidebar": "#fbfbfb",
139
+ "softBrandText": "#2057bd",
140
+ "softSuccessText": "#186b3d",
141
+ "softWarningText": "#80520d",
142
+ "softDangerText": "#a33539",
143
+ "softNeutralText": "#686870"
129
144
  },
130
145
  "dark": {
131
146
  "gray00": "#0a0a0a",
@@ -165,7 +180,12 @@ export const tokens = {
165
180
  "orange600": "#f97514",
166
181
  "cyan600": "#00b9eb",
167
182
  "purple600": "#9b69ff",
168
- "graySidebar": "#0e0f10"
183
+ "graySidebar": "#0e0f10",
184
+ "softBrandText": "#568df3",
185
+ "softSuccessText": "#2ea968",
186
+ "softWarningText": "#e0982a",
187
+ "softDangerText": "#ff5a5f",
188
+ "softNeutralText": "#9a9aa2"
169
189
  }
170
190
  },
171
191
  "space": {
package/dist/select.css CHANGED
@@ -37,6 +37,12 @@
37
37
  .crisp-select--width_auto {
38
38
  width: auto;
39
39
  }
40
+ .crisp-select--invalid_true {
41
+ & .crisp-select-trigger, & .crisp-select-trigger:hover, & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
42
+ }
43
+ .crisp-select--invalid_false {
44
+
45
+ }
40
46
 
41
47
  @media (prefers-reduced-motion: reduce) {
42
48
  .crisp-select, .crisp-select * {
package/dist/select.d.ts CHANGED
@@ -3,6 +3,7 @@ import type { RecipeFn } from "../core/types";
3
3
  export interface SelectVariants {
4
4
  variant?: "default" | "ghost";
5
5
  width?: "full" | "auto";
6
+ invalid?: boolean;
6
7
  }
7
8
 
8
- export declare const selectRecipe: RecipeFn<{ variant: { default: string; ghost: string }; width: { full: string; auto: string } }>;
9
+ export declare const selectRecipe: RecipeFn<{ variant: { default: string; ghost: string }; width: { full: string; auto: string }; invalid: { true: string; false: string } }>;
@@ -38,6 +38,12 @@
38
38
  .crisp-select--width_auto {
39
39
  width: auto;
40
40
  }
41
+ .crisp-select--invalid_true {
42
+ & .crisp-select-trigger, & .crisp-select-trigger:hover, & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
43
+ }
44
+ .crisp-select--invalid_false {
45
+
46
+ }
41
47
 
42
48
  @media (prefers-reduced-motion: reduce) {
43
49
  .crisp-select, .crisp-select * {
package/dist/styles.css CHANGED
@@ -38,6 +38,11 @@
38
38
  --crisp-bg-purple: #9b69ff;
39
39
  --crisp-bg-sidebar: #fbfbfb;
40
40
  --crisp-bg-subtle: #fbfbfb;
41
+ --crisp-fg-brand-soft: #2057bd;
42
+ --crisp-fg-success-soft: #186b3d;
43
+ --crisp-fg-warning-soft: #80520d;
44
+ --crisp-fg-danger-soft: #a33539;
45
+ --crisp-fg-neutral-soft: #686870;
41
46
  }
42
47
 
43
48
  [data-theme="dark"] {
@@ -80,6 +85,11 @@
80
85
  --crisp-bg-purple: #9b69ff;
81
86
  --crisp-bg-sidebar: #0e0f10;
82
87
  --crisp-bg-subtle: #0e0f10;
88
+ --crisp-fg-brand-soft: #568df3;
89
+ --crisp-fg-success-soft: #2ea968;
90
+ --crisp-fg-warning-soft: #e0982a;
91
+ --crisp-fg-danger-soft: #ff5a5f;
92
+ --crisp-fg-neutral-soft: #9a9aa2;
83
93
  }
84
94
 
85
95
  :root {
@@ -196,19 +206,19 @@
196
206
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
197
207
  }
198
208
  .crisp-badge--tone_neutral {
199
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
209
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
200
210
  }
201
211
  .crisp-badge--tone_brand {
202
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
212
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
203
213
  }
204
214
  .crisp-badge--tone_success {
205
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
215
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
206
216
  }
207
217
  .crisp-badge--tone_warning {
208
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
218
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
209
219
  }
210
220
  .crisp-badge--tone_danger {
211
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
221
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
212
222
  }
213
223
  .crisp-badge--size_small {
214
224
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -1034,6 +1044,12 @@
1034
1044
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, var(--crisp-bg-cyan) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-cyan) 82%, var(--crisp-fg-primary)); }
1035
1045
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
1036
1046
  }
1047
+ .crisp-multiselect--invalid_true {
1048
+ & .crisp-multiselect-trigger, & .crisp-multiselect-trigger:hover, & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
1049
+ }
1050
+ .crisp-multiselect--invalid_false {
1051
+
1052
+ }
1037
1053
 
1038
1054
  @media (prefers-reduced-motion: reduce) {
1039
1055
  .crisp-multiselect, .crisp-multiselect * {
@@ -1246,6 +1262,12 @@
1246
1262
  .crisp-select--width_auto {
1247
1263
  width: auto;
1248
1264
  }
1265
+ .crisp-select--invalid_true {
1266
+ & .crisp-select-trigger, & .crisp-select-trigger:hover, & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
1267
+ }
1268
+ .crisp-select--invalid_false {
1269
+
1270
+ }
1249
1271
 
1250
1272
  @media (prefers-reduced-motion: reduce) {
1251
1273
  .crisp-select, .crisp-select * {
@@ -38,6 +38,11 @@
38
38
  --crisp-bg-purple: #9b69ff;
39
39
  --crisp-bg-sidebar: #fbfbfb;
40
40
  --crisp-bg-subtle: #fbfbfb;
41
+ --crisp-fg-brand-soft: #2057bd;
42
+ --crisp-fg-success-soft: #186b3d;
43
+ --crisp-fg-warning-soft: #80520d;
44
+ --crisp-fg-danger-soft: #a33539;
45
+ --crisp-fg-neutral-soft: #686870;
41
46
  }
42
47
 
43
48
  [data-theme="dark"] {
@@ -80,6 +85,11 @@
80
85
  --crisp-bg-purple: #9b69ff;
81
86
  --crisp-bg-sidebar: #0e0f10;
82
87
  --crisp-bg-subtle: #0e0f10;
88
+ --crisp-fg-brand-soft: #568df3;
89
+ --crisp-fg-success-soft: #2ea968;
90
+ --crisp-fg-warning-soft: #e0982a;
91
+ --crisp-fg-danger-soft: #ff5a5f;
92
+ --crisp-fg-neutral-soft: #9a9aa2;
83
93
  }
84
94
 
85
95
  :root {
@@ -197,19 +207,19 @@
197
207
  & .crisp-badge-dot { width: 6px; height: 6px; flex-shrink: 0; border-radius: var(--crisp-radius-full); background: currentColor; }
198
208
  }
199
209
  .crisp-badge--tone_neutral {
200
- background: var(--crisp-bg-raised); color: var(--crisp-fg-muted); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
210
+ background: var(--crisp-bg-raised); color: var(--crisp-fg-neutral-soft); box-shadow: inset 0 0 0 1px var(--crisp-border-default);
201
211
  }
202
212
  .crisp-badge--tone_brand {
203
- background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-bg-brand-solid); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
213
+ background: color-mix(in srgb, var(--crisp-bg-brand-solid) 14%, transparent); color: var(--crisp-fg-brand-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-brand-solid) 30%, transparent);
204
214
  }
205
215
  .crisp-badge--tone_success {
206
- background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-bg-success); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
216
+ background: color-mix(in srgb, var(--crisp-bg-success) 14%, transparent); color: var(--crisp-fg-success-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-success) 30%, transparent);
207
217
  }
208
218
  .crisp-badge--tone_warning {
209
- background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-bg-warning); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
219
+ background: color-mix(in srgb, var(--crisp-bg-warning) 16%, transparent); color: var(--crisp-fg-warning-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-warning) 32%, transparent);
210
220
  }
211
221
  .crisp-badge--tone_danger {
212
- background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-bg-danger); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
222
+ background: color-mix(in srgb, var(--crisp-bg-danger) 14%, transparent); color: var(--crisp-fg-danger-soft); box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--crisp-bg-danger) 30%, transparent);
213
223
  }
214
224
  .crisp-badge--size_small {
215
225
  height: 16px; padding-inline: var(--crisp-space-1-5); font-size: var(--crisp-text-xs); border-radius: var(--crisp-radius-sm);
@@ -1035,6 +1045,12 @@
1035
1045
  & .crisp-recordstable-tag[data-tone="cyan"] { background: color-mix(in srgb, var(--crisp-bg-cyan) 18%, transparent); color: color-mix(in srgb, var(--crisp-bg-cyan) 82%, var(--crisp-fg-primary)); }
1036
1046
  & .crisp-recordstable-tag[data-tone="orange"] { background: color-mix(in srgb, var(--crisp-bg-orange) 16%, transparent); color: color-mix(in srgb, var(--crisp-bg-orange) 82%, var(--crisp-fg-primary)); }
1037
1047
  }
1048
+ .crisp-multiselect--invalid_true {
1049
+ & .crisp-multiselect-trigger, & .crisp-multiselect-trigger:hover, & .crisp-multiselect-trigger[data-open], & .crisp-multiselect-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
1050
+ }
1051
+ .crisp-multiselect--invalid_false {
1052
+
1053
+ }
1038
1054
 
1039
1055
  @media (prefers-reduced-motion: reduce) {
1040
1056
  .crisp-multiselect, .crisp-multiselect * {
@@ -1247,6 +1263,12 @@
1247
1263
  .crisp-select--width_auto {
1248
1264
  width: auto;
1249
1265
  }
1266
+ .crisp-select--invalid_true {
1267
+ & .crisp-select-trigger, & .crisp-select-trigger:hover, & .crisp-select-trigger[data-open], & .crisp-select-trigger:focus-visible { box-shadow: inset 0 0 0 1px var(--crisp-bg-danger); }
1268
+ }
1269
+ .crisp-select--invalid_false {
1270
+
1271
+ }
1250
1272
 
1251
1273
  @media (prefers-reduced-motion: reduce) {
1252
1274
  .crisp-select, .crisp-select * {
package/dist/tokens.css CHANGED
@@ -38,6 +38,11 @@
38
38
  --crisp-bg-purple: #9b69ff;
39
39
  --crisp-bg-sidebar: #fbfbfb;
40
40
  --crisp-bg-subtle: #fbfbfb;
41
+ --crisp-fg-brand-soft: #2057bd;
42
+ --crisp-fg-success-soft: #186b3d;
43
+ --crisp-fg-warning-soft: #80520d;
44
+ --crisp-fg-danger-soft: #a33539;
45
+ --crisp-fg-neutral-soft: #686870;
41
46
  }
42
47
 
43
48
  [data-theme="dark"] {
@@ -80,6 +85,11 @@
80
85
  --crisp-bg-purple: #9b69ff;
81
86
  --crisp-bg-sidebar: #0e0f10;
82
87
  --crisp-bg-subtle: #0e0f10;
88
+ --crisp-fg-brand-soft: #568df3;
89
+ --crisp-fg-success-soft: #2ea968;
90
+ --crisp-fg-warning-soft: #e0982a;
91
+ --crisp-fg-danger-soft: #ff5a5f;
92
+ --crisp-fg-neutral-soft: #9a9aa2;
83
93
  }
84
94
 
85
95
  :root {
package/dist/tokens.json CHANGED
@@ -39,7 +39,12 @@
39
39
  "bgCyan": "#00b9eb",
40
40
  "bgPurple": "#9b69ff",
41
41
  "bgSidebar": "#fbfbfb",
42
- "bgSubtle": "#fbfbfb"
42
+ "bgSubtle": "#fbfbfb",
43
+ "fgBrandSoft": "#2057bd",
44
+ "fgSuccessSoft": "#186b3d",
45
+ "fgWarningSoft": "#80520d",
46
+ "fgDangerSoft": "#a33539",
47
+ "fgNeutralSoft": "#686870"
43
48
  },
44
49
  "dark": {
45
50
  "bgBrandSolid": "#266df0",
@@ -80,7 +85,12 @@
80
85
  "bgCyan": "#00b9eb",
81
86
  "bgPurple": "#9b69ff",
82
87
  "bgSidebar": "#0e0f10",
83
- "bgSubtle": "#0e0f10"
88
+ "bgSubtle": "#0e0f10",
89
+ "fgBrandSoft": "#568df3",
90
+ "fgSuccessSoft": "#2ea968",
91
+ "fgWarningSoft": "#e0982a",
92
+ "fgDangerSoft": "#ff5a5f",
93
+ "fgNeutralSoft": "#9a9aa2"
84
94
  }
85
95
  },
86
96
  "palette": {
@@ -122,7 +132,12 @@
122
132
  "orange600": "#f97514",
123
133
  "cyan600": "#00b9eb",
124
134
  "purple600": "#9b69ff",
125
- "graySidebar": "#fbfbfb"
135
+ "graySidebar": "#fbfbfb",
136
+ "softBrandText": "#2057bd",
137
+ "softSuccessText": "#186b3d",
138
+ "softWarningText": "#80520d",
139
+ "softDangerText": "#a33539",
140
+ "softNeutralText": "#686870"
126
141
  },
127
142
  "dark": {
128
143
  "gray00": "#0a0a0a",
@@ -162,7 +177,12 @@
162
177
  "orange600": "#f97514",
163
178
  "cyan600": "#00b9eb",
164
179
  "purple600": "#9b69ff",
165
- "graySidebar": "#0e0f10"
180
+ "graySidebar": "#0e0f10",
181
+ "softBrandText": "#568df3",
182
+ "softSuccessText": "#2ea968",
183
+ "softWarningText": "#e0982a",
184
+ "softDangerText": "#ff5a5f",
185
+ "softNeutralText": "#9a9aa2"
166
186
  }
167
187
  },
168
188
  "space": {
@@ -21,4 +21,4 @@ export type FileUploadProps = UseFileUploadProps & Omit<React.ComponentPropsWith
21
21
  * is theirs by construction. Controlled or uncontrolled, the same way every
22
22
  * other crisp component is.
23
23
  */
24
- export declare function FileUpload({ className, labels, "aria-label": ariaLabel, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }: FileUploadProps): React.JSX.Element;
24
+ export declare function FileUpload({ className, labels, "aria-label": ariaLabel, id, accept, disabled: disabledProp, required, invalid: invalidProp, readOnly, maxFiles, maxFileSize, minFileSize, name, validate, preventDocumentDrop, onFileReject, onFileAccept, acceptedFileEntries: acceptedFileEntriesProp, defaultAcceptedFileEntries, onAcceptedFileEntriesChange, ...rest }: FileUploadProps): React.JSX.Element;
@@ -37,6 +37,8 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
37
37
  /** Shown when the search matches nothing. */
38
38
  emptyLabel?: string;
39
39
  disabled?: boolean;
40
+ /** Red ring + `aria-invalid`, matching `Input`'s and `Select`'s. */
41
+ invalid?: boolean;
40
42
  className?: string;
41
43
  "aria-label"?: string;
42
44
  };
@@ -46,7 +48,7 @@ export type MultiSelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "valu
46
48
  * popover with a search field over rows of "[chip] … [checkbox]" that toggle on
47
49
  * click (the menu stays open). Keyboard: ↑/↓ move, Enter toggles, Esc closes.
48
50
  */
49
- export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required, id, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
51
+ export declare function MultiSelect({ options, value: valueProp, defaultValue, onValueChange, name, required, id, placeholder, searchable, searchPlaceholder, emptyLabel, disabled, invalid, className, "aria-label": ariaLabel, ...rest }: MultiSelectProps): React.JSX.Element;
50
52
  export declare namespace MultiSelect {
51
53
  var displayName: string;
52
54
  }
@@ -1,5 +1,20 @@
1
- declare const config: import("../..").RecipeConfig<{}>;
2
- export declare const multiselectRecipe: import("../..").RecipeFn<{}> & {
3
- config: import("../..").RecipeConfig<{}>;
1
+ declare const config: import("../..").RecipeConfig<{
2
+ invalid: {
3
+ true: string;
4
+ false: string;
5
+ };
6
+ }>;
7
+ export declare const multiselectRecipe: import("../..").RecipeFn<{
8
+ invalid: {
9
+ true: string;
10
+ false: string;
11
+ };
12
+ }> & {
13
+ config: import("../..").RecipeConfig<{
14
+ invalid: {
15
+ true: string;
16
+ false: string;
17
+ };
18
+ }>;
4
19
  };
5
20
  export default config;
@@ -42,6 +42,9 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
42
42
  /** Shown when the search matches nothing. */
43
43
  emptyLabel?: string;
44
44
  disabled?: boolean;
45
+ /** Red ring + `aria-invalid`, matching `Input`'s. Outranks the open/focus
46
+ * ring — a field being corrected stays visibly wrong while it is. */
47
+ invalid?: boolean;
45
48
  className?: string;
46
49
  "aria-label"?: string;
47
50
  id?: string;
@@ -53,7 +56,7 @@ export type SelectProps = Omit<React.ComponentPropsWithoutRef<"div">, "value" |
53
56
  * "No results" empty state). Full keyboard model — ↑/↓ move, Enter/Space pick,
54
57
  * Esc close, Home/End jump, opens upward when tight. Controlled or uncontrolled.
55
58
  */
56
- export declare function Select({ options, value, defaultValue, onValueChange, name, required, placeholder, variant, width, searchable, searchPlaceholder, emptyLabel, disabled, className, "aria-label": ariaLabel, id, ...rest }: SelectProps): React.JSX.Element;
59
+ export declare function Select({ options, value, defaultValue, onValueChange, name, required, placeholder, variant, width, searchable, searchPlaceholder, emptyLabel, disabled, invalid, className, "aria-label": ariaLabel, id, ...rest }: SelectProps): React.JSX.Element;
57
60
  export declare namespace Select {
58
61
  var displayName: string;
59
62
  }
@@ -7,6 +7,10 @@ declare const config: import("../..").RecipeConfig<{
7
7
  full: string;
8
8
  auto: string;
9
9
  };
10
+ invalid: {
11
+ true: string;
12
+ false: string;
13
+ };
10
14
  }>;
11
15
  export declare const selectRecipe: import("../..").RecipeFn<{
12
16
  variant: {
@@ -17,6 +21,10 @@ export declare const selectRecipe: import("../..").RecipeFn<{
17
21
  full: string;
18
22
  auto: string;
19
23
  };
24
+ invalid: {
25
+ true: string;
26
+ false: string;
27
+ };
20
28
  }> & {
21
29
  config: import("../..").RecipeConfig<{
22
30
  variant: {
@@ -27,6 +35,10 @@ export declare const selectRecipe: import("../..").RecipeFn<{
27
35
  full: string;
28
36
  auto: string;
29
37
  };
38
+ invalid: {
39
+ true: string;
40
+ false: string;
41
+ };
30
42
  }>;
31
43
  };
32
44
  export default config;
@@ -1,2 +1,2 @@
1
- export { ATTIO_SIDEBAR_CHATS_SECTION, ATTIO_SIDEBAR_FOOTER, ATTIO_SIDEBAR_ITEMS, ATTIO_SIDEBAR_LISTS_SECTION, ATTIO_SIDEBAR_RECORDS_SECTION, ATTIO_SIDEBAR_WORKSPACE, Sidebar, type SidebarFooter, type SidebarListsSection, type SidebarNavItem, type SidebarProps, type SidebarRecordItem, type SidebarRecordsSection, type SidebarSection, type SidebarWorkspace, } from "./sidebar";
1
+ export { ATTIO_SIDEBAR_CHATS_SECTION, ATTIO_SIDEBAR_FOOTER, ATTIO_SIDEBAR_ITEMS, ATTIO_SIDEBAR_LISTS_SECTION, ATTIO_SIDEBAR_RECORDS_SECTION, ATTIO_SIDEBAR_WORKSPACE, Sidebar, type SidebarFooter, type SidebarListsSection, type SidebarNavGroup, type SidebarNavGroupItem, type SidebarNavItem, type SidebarProps, type SidebarRecordItem, type SidebarRecordsSection, type SidebarSection, type SidebarWorkspace, } from "./sidebar";
2
2
  export { sidebarRecipe } from "./sidebar.recipe";
@@ -40,6 +40,11 @@ export declare const contract: MapLeafNodes<{
40
40
  bgPurple: "purple600";
41
41
  bgSidebar: "graySidebar";
42
42
  bgSubtle: "graySidebar";
43
+ fgBrandSoft: "softBrandText";
44
+ fgSuccessSoft: "softSuccessText";
45
+ fgWarningSoft: "softWarningText";
46
+ fgDangerSoft: "softDangerText";
47
+ fgNeutralSoft: "softNeutralText";
43
48
  }, `var(--${string})`> & {
44
49
  space: Record<"0.5" | "1" | "1.5" | "2" | "2.5" | "3" | "4" | "5" | "6" | "8" | "px", `var(--${string})`>;
45
50
  radius: Record<"2xl" | "chip" | "full" | "lg" | "md" | "sm" | "xl", `var(--${string})`>;
@@ -151,5 +151,25 @@ export declare const palette: {
151
151
  readonly light: "#fbfbfb";
152
152
  readonly dark: "#0e0f10";
153
153
  };
154
+ readonly softBrandText: {
155
+ readonly light: "#2057bd";
156
+ readonly dark: "#568df3";
157
+ };
158
+ readonly softSuccessText: {
159
+ readonly light: "#186b3d";
160
+ readonly dark: "#2ea968";
161
+ };
162
+ readonly softWarningText: {
163
+ readonly light: "#80520d";
164
+ readonly dark: "#e0982a";
165
+ };
166
+ readonly softDangerText: {
167
+ readonly light: "#a33539";
168
+ readonly dark: "#ff5a5f";
169
+ };
170
+ readonly softNeutralText: {
171
+ readonly light: "#686870";
172
+ readonly dark: "#9a9aa2";
173
+ };
154
174
  };
155
175
  export type PaletteKey = keyof typeof palette;
@@ -46,5 +46,12 @@ export declare const semantic: {
46
46
  bgPurple: "purple600";
47
47
  bgSidebar: "graySidebar";
48
48
  bgSubtle: "graySidebar";
49
+ /** Soft Badge's text colour (#323) — see palette.ts's comment on the
50
+ * soft*Text entries for the Attio measurement this is derived from. */
51
+ fgBrandSoft: "softBrandText";
52
+ fgSuccessSoft: "softSuccessText";
53
+ fgWarningSoft: "softWarningText";
54
+ fgDangerSoft: "softDangerText";
55
+ fgNeutralSoft: "softNeutralText";
49
56
  };
50
57
  export type Tokens = typeof semantic;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crisp-ui-kit/crisp",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "description": "A lean React design system, measured pixel-for-pixel from Attio.",
5
5
  "license": "MIT",
6
6
  "type": "module",