@crisp-ui-kit/crisp 0.57.0 → 0.57.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/dist/accentpicker.css +5 -0
  2. package/dist/accentpicker.d.ts +7 -0
  3. package/dist/accentpicker.layered.css +7 -0
  4. package/dist/avatar.css +1 -1
  5. package/dist/avatar.layered.css +1 -1
  6. package/dist/badge.css +2 -2
  7. package/dist/badge.layered.css +2 -2
  8. package/dist/button.css +3 -3
  9. package/dist/button.layered.css +3 -3
  10. package/dist/cjs/components/accentpicker/accentpicker.js +66 -0
  11. package/dist/cjs/components/accentpicker/accentpicker.recipe.js +25 -0
  12. package/dist/cjs/components/accentpicker/index.js +7 -0
  13. package/dist/cjs/components/avatar/avatar.recipe.js +4 -1
  14. package/dist/cjs/components/badge/badge.recipe.js +16 -2
  15. package/dist/cjs/components/button/button.recipe.js +16 -6
  16. package/dist/cjs/components/input/input.js +2 -2
  17. package/dist/cjs/components/input/input.recipe.js +8 -0
  18. package/dist/cjs/components/multiselect/multiselect.js +1 -1
  19. package/dist/cjs/components/multiselect/multiselect.recipe.js +12 -3
  20. package/dist/cjs/components/progress/progress.js +21 -4
  21. package/dist/cjs/components/progress/progress.recipe.js +30 -0
  22. package/dist/cjs/components/select/select.js +2 -2
  23. package/dist/cjs/components/select/select.recipe.js +32 -5
  24. package/dist/cjs/components/text/text.recipe.js +8 -7
  25. package/dist/cjs/components/textarea/textarea.js +3 -2
  26. package/dist/cjs/components/textarea/textarea.recipe.js +2 -1
  27. package/dist/cjs/components/textlink/textlink.recipe.js +6 -3
  28. package/dist/cjs/index.js +1 -0
  29. package/dist/cjs/product/recordpage/recordpage.js +46 -5
  30. package/dist/cjs/tokens/palette.js +79 -25
  31. package/dist/cjs/tokens/scale.js +1 -0
  32. package/dist/cjs/tokens/semantic.js +24 -0
  33. package/dist/esm/components/accentpicker/accentpicker.js +30 -0
  34. package/dist/esm/components/accentpicker/accentpicker.recipe.js +22 -0
  35. package/dist/esm/components/accentpicker/index.js +2 -0
  36. package/dist/esm/components/avatar/avatar.recipe.js +4 -1
  37. package/dist/esm/components/badge/badge.recipe.js +16 -2
  38. package/dist/esm/components/button/button.recipe.js +16 -6
  39. package/dist/esm/components/input/input.js +3 -3
  40. package/dist/esm/components/input/input.recipe.js +8 -0
  41. package/dist/esm/components/multiselect/multiselect.js +1 -1
  42. package/dist/esm/components/multiselect/multiselect.recipe.js +12 -3
  43. package/dist/esm/components/progress/progress.js +22 -5
  44. package/dist/esm/components/progress/progress.recipe.js +30 -0
  45. package/dist/esm/components/select/select.js +2 -2
  46. package/dist/esm/components/select/select.recipe.js +32 -5
  47. package/dist/esm/components/text/text.recipe.js +8 -7
  48. package/dist/esm/components/textarea/textarea.js +3 -2
  49. package/dist/esm/components/textarea/textarea.recipe.js +2 -1
  50. package/dist/esm/components/textlink/textlink.recipe.js +6 -3
  51. package/dist/esm/index.js +1 -0
  52. package/dist/esm/product/recordpage/recordpage.js +47 -6
  53. package/dist/esm/tokens/palette.js +79 -25
  54. package/dist/esm/tokens/scale.js +1 -0
  55. package/dist/esm/tokens/semantic.js +24 -0
  56. package/dist/input.css +7 -0
  57. package/dist/input.layered.css +7 -0
  58. package/dist/multiselect.css +4 -0
  59. package/dist/multiselect.d.ts +2 -2
  60. package/dist/multiselect.layered.css +4 -0
  61. package/dist/native/tokens.d.ts +1 -1
  62. package/dist/native/tokens.js +33 -22
  63. package/dist/progress.css +3 -0
  64. package/dist/progress.layered.css +3 -0
  65. package/dist/select.css +22 -2
  66. package/dist/select.d.ts +2 -2
  67. package/dist/select.layered.css +22 -2
  68. package/dist/styles.css +71 -23
  69. package/dist/styles.layered.css +71 -23
  70. package/dist/text.css +1 -1
  71. package/dist/text.layered.css +1 -1
  72. package/dist/textarea.css +2 -1
  73. package/dist/textarea.layered.css +2 -1
  74. package/dist/textlink.css +2 -2
  75. package/dist/textlink.layered.css +2 -2
  76. package/dist/tokens.css +18 -11
  77. package/dist/tokens.json +33 -22
  78. package/dist/types/components/accentpicker/accentpicker.d.ts +23 -0
  79. package/dist/types/components/accentpicker/accentpicker.recipe.d.ts +3 -0
  80. package/dist/types/components/accentpicker/index.d.ts +2 -0
  81. package/dist/types/components/button/button.recipe.d.ts +2 -2
  82. package/dist/types/components/input/input.d.ts +12 -0
  83. package/dist/types/components/multiselect/multiselect.d.ts +4 -3
  84. package/dist/types/components/multiselect/multiselect.recipe.d.ts +3 -0
  85. package/dist/types/components/progress/progress.d.ts +16 -4
  86. package/dist/types/components/progress/progress.recipe.d.ts +22 -0
  87. package/dist/types/components/select/select.d.ts +6 -5
  88. package/dist/types/components/select/select.recipe.d.ts +3 -0
  89. package/dist/types/components/textarea/textarea.d.ts +16 -0
  90. package/dist/types/index.d.ts +1 -0
  91. package/dist/types/product/recordpage/recordpage.d.ts +37 -1
  92. package/dist/types/tokens/contract.d.ts +4 -1
  93. package/dist/types/tokens/palette.d.ts +19 -11
  94. package/dist/types/tokens/scale.d.ts +1 -0
  95. package/dist/types/tokens/semantic.d.ts +15 -0
  96. package/package.json +6 -1
@@ -1,11 +1,12 @@
1
- import { jsx as _jsx } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { progressRecipe } from "./progress.recipe.js";
3
3
  /**
4
- * A progress bar — a continuous brand fill, or a segmented row of pills when
5
- * `segments` is set (the "Getting started 3/6" pattern). Reports its state via
6
- * `role="progressbar"` and the ARIA value attributes.
4
+ * A progress bar — a continuous brand fill, a segmented row of pills when
5
+ * `segments` is set (the "Getting started 3/6" pattern), or a small ring
6
+ * (`variant="circular"`, a character-counter's own progress). Reports its
7
+ * state via `role="progressbar"` and the ARIA value attributes.
7
8
  */
8
- export function Progress({ value, max = 100, segments, "aria-label": ariaLabel, className, ...rest }) {
9
+ export function Progress({ value, max = 100, segments, variant = "linear", size = 18, "aria-label": ariaLabel, className, ...rest }) {
9
10
  const pct = Math.max(0, Math.min(100, (value / max) * 100));
10
11
  const aria = {
11
12
  role: "progressbar",
@@ -14,6 +15,22 @@ export function Progress({ value, max = 100, segments, "aria-label": ariaLabel,
14
15
  "aria-valuemax": max,
15
16
  "aria-label": ariaLabel,
16
17
  };
18
+ if (variant === "circular") {
19
+ // Measured live: an 18x18 SVG, r=7.4, stroke-width 1.6 (a 0.6px margin on
20
+ // each side) — a two-circle ring (a soft-tinted track + a brand arc),
21
+ // the same track tint Badge/Callout's own `soft(bgBrandSolid, 14%)`
22
+ // formula already gives.
23
+ const strokeWidth = size * (1.6 / 18);
24
+ const r = size / 2 - strokeWidth;
25
+ const circumference = 2 * Math.PI * r;
26
+ const offset = circumference * (1 - pct / 100);
27
+ // The measured sample was at value=0 (a full, invisible offset either
28
+ // way), so the arc's start point couldn't be read off that one DOM
29
+ // snapshot -- 12 o'clock is the conventional start for this shape and
30
+ // isn't itself in tension with anything measured, but isn't independently
31
+ // confirmed at a nonzero value either.
32
+ return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-variant": "circular", children: _jsxs("svg", { width: size, height: size, viewBox: `0 0 ${size} ${size}`, "aria-hidden": "true", children: [_jsx("circle", { className: "crisp-progress-circle-track", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent" }), _jsx("circle", { className: "crisp-progress-circle-fill", r: r, cx: size / 2, cy: size / 2, strokeWidth: strokeWidth, fill: "transparent", strokeDasharray: circumference, strokeDashoffset: offset, strokeLinecap: "round", transform: `rotate(-90 ${size / 2} ${size / 2})` })] }) }));
33
+ }
17
34
  if (segments && segments > 0) {
18
35
  const filled = Math.round((pct / 100) * segments);
19
36
  return (_jsx("div", { ...aria, className: progressRecipe({ className }), ...rest, "data-segmented": "", children: Array.from({ length: segments }, (_, i) => (_jsx("span", { className: "crisp-progress-seg", "data-filled": i < filled ? "" : undefined }, i))) }));
@@ -49,6 +49,36 @@ const spec = defineSpec({
49
49
  selector: "&[data-segmented] .crisp-progress-seg[data-filled]",
50
50
  decls: { background: c.bgBrandSolid },
51
51
  },
52
+ // crisp#634: measured live on Attio's own Settings > Profile
53
+ // character-counter ring — the track tint is the same
54
+ // `soft(bgBrandSolid, 14%)` formula Badge/Callout already use (measured
55
+ // rgb(229,238,255), within the same noise this codebase already treats
56
+ // as equivalent), the arc is the plain brand fill.
57
+ {
58
+ selector: '&[data-variant="circular"]',
59
+ decls: {
60
+ display: "inline-flex",
61
+ height: "auto",
62
+ borderRadius: "0",
63
+ background: "transparent",
64
+ overflow: "visible",
65
+ },
66
+ },
67
+ {
68
+ slot: "circleTrack",
69
+ selector: "& .crisp-progress-circle-track",
70
+ decls: {
71
+ stroke: `color-mix(in srgb, ${c.bgBrandSolid} 14%, transparent)`,
72
+ },
73
+ },
74
+ {
75
+ slot: "circleFill",
76
+ selector: "& .crisp-progress-circle-fill",
77
+ decls: {
78
+ stroke: c.bgBrandSolid,
79
+ transition: "stroke-dashoffset 0.25s ease",
80
+ },
81
+ },
52
82
  ],
53
83
  });
54
84
  const config = specToRecipe(spec);
@@ -19,7 +19,7 @@ function optionText(o) {
19
19
  * move, Enter/Space pick, Esc close, Home/End jump, opens upward when tight.
20
20
  * Controlled or uncontrolled.
21
21
  */
22
- export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", size = "medium", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
22
+ export function Select({ options, value, defaultValue, onValueChange, name, required, placeholder: placeholderProp, variant = "default", width = "full", size = "small", searchable, searchPlaceholder: searchPlaceholderProp, onSearchChange, emptyLabel: emptyLabelProp, disabled, readOnly, invalid, className, "aria-label": ariaLabel, id, ...rest }) {
23
23
  // crisp/#471: an explicit prop always wins; missing, it reads the nearest
24
24
  // CrispMessagesProvider's translation of the English default, or that
25
25
  // default itself with no provider — unchanged from before this existed.
@@ -181,7 +181,7 @@ export function Select({ options, value, defaultValue, onValueChange, name, requ
181
181
  return (_jsxs("div", { ref: rootRef, className: selectRecipe({ variant, width, size, 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",
182
182
  // controlled by the listbox above; `readOnly` would bar it from
183
183
  // validation, which is the only reason this input is not hidden
184
- 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, "aria-readonly": readOnly ? "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.triggerLabel ?? selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, 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) => {
184
+ 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, "aria-readonly": readOnly ? "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.triggerLabel ?? selectedOption.label] })) : (_jsx("span", { className: "crisp-select-placeholder", children: placeholder })) }), _jsx(ChevronDown, { className: "crisp-select-chevron", size: 16, strokeWidth: 1.75 })] }), open && (_jsx(FloatingPortal, { sourceRef: rootRef, 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: 16, strokeWidth: 1.75 }), _jsx("input", { ref: searchRef, type: "text", placeholder: searchPlaceholder, "aria-label": searchPlaceholder, "aria-controls": listboxId, "aria-activedescendant": activeId, value: query, onChange: (e) => {
185
185
  setQuery(e.target.value);
186
186
  setHighlight(0);
187
187
  onSearchChange?.(e.target.value);
@@ -85,9 +85,19 @@ const config = defineRecipe({
85
85
  }
86
86
  .crisp-select-listbox[data-up] { transform-origin: bottom left; }
87
87
  .crisp-select-listbox { @starting-style { opacity: 0; transform: scale(0.95); } }
88
- .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: 34px; flex-shrink: 0; margin: calc(-1 * ${c.space["2"]}) calc(-1 * ${c.space["2"]}) ${c.space["2"]}; padding: 0 ${c.space["2.5"]}; border-bottom: 1px solid ${c.borderSubtle}; color: ${c.fgNeutralMuted}; }
88
+ /* Composed from @seed-design/rootage's text-input.yaml
89
+ (variant=outline,size=medium) -- seed's Select has no search field of
90
+ its own to resolve this from (see the file header), so this reuses
91
+ the SAME tokens #867 already resolved for the trigger at this size:
92
+ minHeight x10 (${c.size.selectTriggerMd}), paddingX x3_5
93
+ (${c.space["3.5"]}), gap x2 (${c.space["2"]}), cornerRadius r2
94
+ (${c.radius.md}), strokeColor stroke.neutral-weak
95
+ (${c.strokeSelectTrigger}) -- a real bordered field sitting flush
96
+ inside the listbox's own 8px padding, replacing the old bare
97
+ 34px/no-border/bleeds-to-the-edge strip (crisp/#904). */
98
+ .crisp-select-search { display: flex; align-items: center; gap: ${c.space["2"]}; height: ${c.size.selectTriggerMd}; flex-shrink: 0; padding: 0 ${c.space["3.5"]}; border: 1px solid ${c.strokeSelectTrigger}; border-radius: ${c.radius.md}; color: ${c.fgNeutralMuted}; }
89
99
  .crisp-select-search svg { flex-shrink: 0; }
90
- .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; color: ${c.fgSelectNeutral}; }
100
+ .crisp-select-search input { flex: 1; min-width: 0; border: none; outline: none; background: none; font-family: inherit; font-size: ${c.text.lg}; line-height: 19px; color: ${c.fgSelectNeutral}; }
91
101
  .crisp-select-search input::placeholder { color: ${c.fgSelectPlaceholder}; }
92
102
  .crisp-select-empty { padding: ${c.space["3"]} ${c.space["4"]}; text-align: center; font-size: ${c.text.md}; color: ${c.fgNeutralSubtle}; }
93
103
  /* select.yaml's groupLabel -- font-size/line-height/weight are the same
@@ -121,9 +131,26 @@ const config = defineRecipe({
121
131
  full: `width: 100%;`,
122
132
  auto: `width: auto;`,
123
133
  },
124
- // seed's two trigger/item sizes (select-trigger.yaml / select-item.yaml).
125
- // medium is the default -- closest to crisp's previous single size.
134
+ // Owner's follow-up after #867/#879 (crisp/#889): the LOOK stays seed's
135
+ // (border, listbox, radius, shadow, items, states, all unchanged below),
136
+ // but the SIZE is Attio's again -- a 40px trigger next to a 32px `Input`
137
+ // in the same form visibly mismatched (measured live in GRC's own "New
138
+ // employee" dialog). `small` is the new default: Attio's original
139
+ // 32px `c.size.menuRow`, `Input`'s own radius/padding/line-height so the
140
+ // two line up exactly, not just approximately. `medium`/`large` (seed's
141
+ // own 40/52px) stay, opt-in. Item rows are IDENTICAL across all three --
142
+ // only the trigger differs; only it was ever the mismatch.
126
143
  size: {
144
+ small: `
145
+ & .crisp-select-trigger { height: ${c.size.menuRow}; border-radius: ${c.radius.md}; gap: ${c.space["1.5"]}; padding-inline: ${c.space["2.5"]}; }
146
+ & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 20px; }
147
+ & .crisp-select-chevron { width: ${c.size.icon}; height: ${c.size.icon}; }
148
+ .crisp-select-option { gap: ${c.space["2"]}; }
149
+ .crisp-select-option-label { font-size: ${c.text.lg}; line-height: 19px; }
150
+ .crisp-select-option-description { font-size: ${c.text.sm}; line-height: 16px; }
151
+ .crisp-select-option-check { width: ${c.space["3"]}; height: ${c.space["3"]}; }
152
+ .crisp-select-label { padding-top: ${c.space["2"]}; }
153
+ `,
127
154
  medium: `
128
155
  & .crisp-select-trigger { height: ${c.size.selectTriggerMd}; border-radius: ${c.radius.md}; gap: ${c.space["2"]}; padding-inline: ${c.space["3.5"]}; }
129
156
  & .crisp-select-value, & .crisp-select-placeholder { font-size: ${c.text.lg}; line-height: 19px; }
@@ -175,7 +202,7 @@ const config = defineRecipe({
175
202
  },
176
203
  defaultVariants: {
177
204
  width: "full",
178
- size: "medium",
205
+ size: "small",
179
206
  invalid: false,
180
207
  variant: "default",
181
208
  },
@@ -33,13 +33,14 @@ const spec = defineSpec({
33
33
  muted: { color: c.fgNeutralMuted },
34
34
  // #858: no new measurement -- both reuse tokens already measured
35
35
  // against Attio for the same colours in a different arrangement.
36
- // `brand` is `textlink`'s own reused brand-text colour (`bgBrandSolid`,
37
- // the same alias GRC's own `--color-fg-brand` resolves to). `danger`
38
- // is `fgDanger` ("solid critical/destructive TEXT... an overdue
39
- // due-date, a destructive menu item's label" -- see its own doc in
40
- // tokens/semantic.ts), not `fgDangerSoft` (that one's a soft Badge
41
- // pill's text, a different measured hex).
42
- brand: { color: c.bgBrandSolid },
36
+ // `brand` is `textlink`'s own reused brand-text colour, now
37
+ // `fgBrandContrast` (your-moon/crisp#897: the plain accent,
38
+ // `bgBrandSolid`, only clears 3.23:1 as text on a white page).
39
+ // `danger` is `fgDanger` ("solid critical/destructive TEXT... an
40
+ // overdue due-date, a destructive menu item's label" -- see its own
41
+ // doc in tokens/semantic.ts), not `fgDangerSoft` (that one's a soft
42
+ // Badge pill's text, a different measured hex).
43
+ brand: { color: c.fgBrandContrast },
43
44
  danger: { color: c.fgDanger },
44
45
  },
45
46
  },
@@ -1,12 +1,13 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import * as React from "react";
3
+ import { Progress } from "../progress/index.js";
3
4
  import { textareaRecipe } from "./textarea.recipe.js";
4
5
  /**
5
6
  * A multiline text field — measured from settings. Forwards every native
6
7
  * `<textarea>` prop; pass `showCount` with `maxLength` to render a live
7
8
  * character counter (which turns red if the value exceeds the limit).
8
9
  */
9
- export const Textarea = React.forwardRef(({ showCount, maxLength, className, wrapperClassName, value, defaultValue, onChange,
10
+ export const Textarea = React.forwardRef(({ showCount, showProgress, maxLength, className, wrapperClassName, value, defaultValue, onChange,
10
11
  // `style` is pulled out and put on the ROOT, the way Input does it.
11
12
  // Everything else belongs on the <textarea> -- id, name, form
12
13
  // association, aria -- but `style` sizes the visual box, and the box is
@@ -22,6 +23,6 @@ style, ...props }, ref) => {
22
23
  if (value === undefined)
23
24
  setCount(e.target.value.length);
24
25
  onChange?.(e);
25
- }, ...props }) }), counted && (_jsxs("span", { className: "crisp-textarea-count", "data-over": len > (maxLength ?? 0) ? "" : undefined, "aria-hidden": "true", children: [len, " / ", maxLength] }))] }));
26
+ }, ...props }) }), counted && (_jsxs("span", { className: "crisp-textarea-count", "data-over": len > (maxLength ?? 0) ? "" : undefined, "aria-hidden": "true", children: [showProgress && (_jsx(Progress, { variant: "circular", value: len, max: maxLength ?? 0, className: "crisp-textarea-progress" })), len, " / ", maxLength] }))] }));
26
27
  });
27
28
  Textarea.displayName = "Textarea";
@@ -46,8 +46,9 @@ const config = defineRecipe({
46
46
  &[data-invalid] .crisp-textarea-wrap::after { border-color: ${c.bgDanger}; }
47
47
  &[data-invalid] .crisp-textarea-wrap:focus-within::after { border-color: ${c.bgDanger}; }
48
48
  &[data-counted] .crisp-textarea-field { padding-bottom: ${c.space["5"]}; }
49
- & .crisp-textarea-count { position: absolute; inset-inline-end: 14px; bottom: ${c.space["2.5"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; pointer-events: none; }
49
+ & .crisp-textarea-count { position: absolute; inset-inline-end: 14px; bottom: ${c.space["2.5"]}; display: flex; align-items: center; gap: ${c.space["1"]}; font-size: ${c.text.sm}; color: ${c.fgMuted}; pointer-events: none; }
50
50
  & .crisp-textarea-count[data-over] { color: ${c.bgDanger}; }
51
+ & .crisp-textarea-progress { flex-shrink: 0; }
51
52
  `,
52
53
  variants: {},
53
54
  defaultVariants: {},
@@ -18,11 +18,14 @@ const config = defineRecipe({
18
18
  variants: {
19
19
  tone: {
20
20
  // The bold, always-brand-coloured trailing link a Card's own `action`
21
- // slot uses ("View all →", "All registers →").
22
- brand: `font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.bgBrandSolid};`,
21
+ // slot uses ("View all →", "All registers →"). your-moon/crisp#897:
22
+ // `fgBrandContrast`, not `bgBrandSolid` (#0298d9) directly -- as TEXT
23
+ // on a white page the accent itself only clears 3.23:1; the darker
24
+ // contrast-safe sibling clears 4.5:1+.
25
+ brand: `font-size: ${c.text.sm}; font-weight: ${c.weight.medium}; color: ${c.fgBrandContrast};`,
23
26
  // A quieter in-context reference (a sample record under a finding)
24
27
  // that only goes brand on hover.
25
- muted: `font-size: ${c.text.md}; font-weight: ${c.weight.regular}; color: ${c.fgMuted}; &:hover { color: ${c.bgBrandSolid}; }`,
28
+ muted: `font-size: ${c.text.md}; font-weight: ${c.weight.regular}; color: ${c.fgMuted}; &:hover { color: ${c.fgBrandContrast}; }`,
26
29
  },
27
30
  // crisp#874: an explicit, OPTIONAL size override -- no `defaultVariants`
28
31
  // entry below, so omitting `size` emits no `--size_*` class at all and
package/dist/esm/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  // component, the design tokens, and the recipe core. Consumers also import the
3
3
  // stylesheet once: import "@crisp-ui-kit/crisp/styles.css";
4
4
  // primitives (components/)
5
+ export * from "./components/accentpicker/index.js";
5
6
  export * from "./components/accordion/index.js";
6
7
  export * from "./components/avatar/index.js";
7
8
  export * from "./components/avatargroup/index.js";
@@ -1,14 +1,16 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Activity as ActivityIcon, CalendarClock, ChevronDown, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, Mail, Maximize2, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, Workflow, X, Zap, } from "lucide-react";
2
+ import { Activity as ActivityIcon, CalendarClock, ChevronDown, CircleDollarSign, Copy, DollarSign, FileText, Folder, Inbox, Landmark, LayoutGrid, Mail, Maximize2, Merge, MessageSquare, Minimize2, MoreHorizontal, Pencil, Phone, Plus, Search, Settings, Sparkles, SquareCheck, Star, Trash2, Users, UsersRound, Workflow, X, Zap, } from "lucide-react";
3
3
  import * as React from "react";
4
4
  import { Avatar } from "../../components/avatar/index.js";
5
5
  import { Checkbox } from "../../components/checkbox/index.js";
6
+ import { Drawer } from "../../components/drawer/index.js";
6
7
  import { Menu, MenuItem, MenuSeparator } from "../../components/menu/index.js";
7
8
  import { Select } from "../../components/select/index.js";
8
9
  import { Spinner } from "../../components/spinner/index.js";
9
10
  import { Switch } from "../../components/switch/index.js";
10
11
  import { Tabs } from "../../components/tabs/index.js";
11
12
  import { cx, isRtlElement, useCrispComponentDefaults } from "../../core/index.js";
13
+ import { CommentsPanel, } from "../comments/index.js";
12
14
  import { tagTone } from "./column.js";
13
15
  import { editableInputType, useEditableField } from "./editable-field.js";
14
16
  import { recordpageRecipe } from "./recordpage.recipe.js";
@@ -33,9 +35,16 @@ export const recordPageLabels = {
33
35
  breadcrumb: "Breadcrumb",
34
36
  editRecord: "Edit record",
35
37
  recordActions: "Record actions",
36
- addToFavourites: "Add to favourites",
37
- removeFromFavourites: "Remove from favourites",
38
+ // US spelling, matching Attio's own live text exactly (#621) — this string
39
+ // is shared with the entity header's favourite-star aria-label, so both
40
+ // read "favorites" now, not just the menu.
41
+ addToFavourites: "Add to favorites",
42
+ removeFromFavourites: "Remove from favorites",
38
43
  copyRecordLink: "Copy record link",
44
+ configurePage: "Configure page",
45
+ copyPageUrl: "Copy page URL",
46
+ copyRecordId: "Copy record ID",
47
+ mergeRecords: "Merge records",
39
48
  deleteRecord: "Delete record",
40
49
  linkRecords: "Link records",
41
50
  // Tooltip copy re-measured live for #865 (was "Log a note"/"Create a
@@ -50,6 +59,8 @@ export const recordPageLabels = {
50
59
  composeEmail: "Compose email",
51
60
  askAttio: "Ask AI",
52
61
  addToList: "Add to list",
62
+ share: "Share",
63
+ comments: "Comments",
53
64
  noLists: "This record has not been added to any lists.",
54
65
  highlights: "Highlights",
55
66
  recordDetails: "Record Details",
@@ -423,7 +434,7 @@ RecordDetails.displayName = "RecordDetails";
423
434
  * Emails sections (Notes & Tasks are live — add and check things off). Drop it
424
435
  * into the app's main content area.
425
436
  */
426
- export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, quickActions: quickActionsProp, ...rest }) {
437
+ export function RecordPage({ record, columns, collection = "Records", labels, onBack, onToggleFull, full, className, onEdit, tabs: tabsProp, defaultTab, notes: notesProp, onAddNote, tasks: tasksProp, onAddTask, onToggleTask, sections, highlights: highlightsProp, quickActions: quickActionsProp, comments: commentsProp, onPostComment, currentUser, onShare, ...rest }) {
427
438
  const t = useRecordPageLabels(labels);
428
439
  const [favorite, setFavorite] = React.useState(false);
429
440
  // Notes/Tasks are controlled when the caller supplies `notes`/`tasks` (even
@@ -435,6 +446,11 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
435
446
  const [internalTasks, setInternalTasks] = React.useState([]);
436
447
  const notes = notesProp ?? internalNotes;
437
448
  const tasks = tasksProp ?? internalTasks;
449
+ // Comments follow the same controlled/self-contained duality (#864).
450
+ const [internalComments, setInternalComments] = React.useState([]);
451
+ const comments = commentsProp ?? internalComments;
452
+ const [commentsOpen, setCommentsOpen] = React.useState(false);
453
+ const me = currentUser ?? { id: "me", name: "You" };
438
454
  const [showAllActivity, setShowAllActivity] = React.useState(false);
439
455
  // Drag-resizable left details column.
440
456
  const [detailsW, setDetailsW] = React.useState(DETAILS_DEFAULT_W);
@@ -593,6 +609,31 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
593
609
  }
594
610
  setInternalTasks((n) => n.map((x) => (x.id === id ? { ...x, done: !x.done } : x)));
595
611
  };
612
+ const postComment = (text, threadId) => {
613
+ if (commentsProp) {
614
+ onPostComment?.(text, threadId);
615
+ return;
616
+ }
617
+ if (threadId == null) {
618
+ setInternalComments((prev) => [
619
+ {
620
+ id: nextId(),
621
+ root: { id: nextId(), author: me, time: t.justNow, body: text },
622
+ },
623
+ ...prev,
624
+ ]);
625
+ return;
626
+ }
627
+ setInternalComments((prev) => prev.map((th) => th.id === threadId
628
+ ? {
629
+ ...th,
630
+ replies: [
631
+ ...(th.replies ?? []),
632
+ { id: nextId(), author: me, time: t.justNow, body: text },
633
+ ],
634
+ }
635
+ : th));
636
+ };
596
637
  // A small, plausible activity feed derived from the record's own fields.
597
638
  const domain = byKey.get("domain");
598
639
  const cats = byKey.get("categories");
@@ -649,7 +690,7 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
649
690
  const effectivePanel = tabsProp
650
691
  ? (active) => tabsProp.find((tb) => tb.id === active)?.render()
651
692
  : builtinPanel;
652
- return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), shortcut: "\u2318C", children: t.copyRecordLink }), _jsx(MenuItem, { icon: _jsx(Plus, { size: 15, strokeWidth: 1.75 }), children: t.addToList }), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, shortcut: "\u232B", children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75 }), t.addToList] }), quickActions.map((a, i) => (_jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": a.label, onClick: a.onSelect, children: _jsx(a.icon, { size: 15, strokeWidth: 1.75 }) }, i)))] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": DETAILS_MIN_W, "aria-valuemax": DETAILS_MAX_W, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
693
+ return (_jsxs("div", { className: recordpageRecipe({ className }), ...rest, children: [_jsxs("div", { className: "crisp-recordpage-topbar", children: [_jsxs("nav", { className: "crisp-recordpage-crumbs", "aria-label": t.breadcrumb, children: [_jsx("button", { type: "button", className: "crisp-recordpage-crumb", onClick: onBack, "aria-label": `Back to ${collection}`, children: collection }), _jsx("span", { className: "crisp-recordpage-crumb-sep", children: "/" }), _jsx("span", { className: "crisp-recordpage-crumb-name", children: name })] }), _jsxs("div", { className: "crisp-recordpage-actions", children: [_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", onClick: onShare, children: t.share }), _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: () => setCommentsOpen(true), "aria-label": t.comments, children: _jsx(MessageSquare, { size: 15, strokeWidth: 1.75 }) }), _jsxs("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--ghost", children: [_jsx(Sparkles, { size: 14, strokeWidth: 1.75, color: "var(--crisp-bg-brand-solid)" }), t.askAttio] }), onToggleFull && (_jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", onClick: onToggleFull, "aria-label": full ? t.exitFullScreen : t.fullScreen, children: full ? (_jsx(Minimize2, { size: 15, strokeWidth: 1.75 })) : (_jsx(Maximize2, { size: 15, strokeWidth: 1.75 })) })), _jsxs(Menu, { align: "end", "aria-label": t.recordActions, trigger: _jsx("button", { type: "button", className: "crisp-recordpage-action crisp-recordpage-action--icon", "aria-label": t.recordActions, children: _jsx(MoreHorizontal, { size: 16, strokeWidth: 1.75 }) }), children: [_jsx(MenuItem, { icon: _jsx(Merge, { size: 15, strokeWidth: 1.75 }), children: t.mergeRecords }), _jsx(MenuItem, { icon: _jsx(Settings, { size: 15, strokeWidth: 1.75 }), children: t.configurePage }), _jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), children: t.copyPageUrl }), _jsx(MenuItem, { icon: _jsx(Copy, { size: 15, strokeWidth: 1.75 }), children: t.copyRecordId }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Star, { size: 15, strokeWidth: 1.75 }), onSelect: () => setFavorite((f) => !f), children: favorite ? t.removeFromFavourites : t.addToFavourites }), _jsx(MenuSeparator, {}), _jsx(MenuItem, { icon: _jsx(Trash2, { size: 15, strokeWidth: 1.75 }), danger: true, children: t.deleteRecord })] })] })] }), _jsxs("div", { className: "crisp-recordpage-body", children: [_jsxs("aside", { className: "crisp-recordpage-details", style: { width: detailsW }, children: [_jsxs("div", { className: "crisp-recordpage-entity", children: [entity?.avatar ? (_jsx("img", { className: "crisp-recordpage-entity-logo", src: entity.avatar, alt: "" })) : (_jsx(Avatar, { size: "lg", shape: "rounded", tone: "brand", children: name.charAt(0) })), _jsx("span", { className: "crisp-recordpage-entity-name", children: name }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "aria-label": t.editRecord, children: _jsx(Pencil, { size: 15, strokeWidth: 1.75 }) }), _jsx("button", { type: "button", className: "crisp-recordpage-entity-iconbtn", "data-active": favorite ? "" : undefined, "aria-pressed": favorite, onClick: () => setFavorite((f) => !f), "aria-label": favorite ? t.removeFromFavourites : t.addToFavourites, children: _jsx(Star, { size: 15, strokeWidth: 1.75 }) })] }), _jsxs("div", { className: "crisp-recordpage-quickactions", children: [_jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Mail, { size: 16, strokeWidth: 1.75 }), t.composeEmail] }), _jsxs("button", { type: "button", className: "crisp-recordpage-compose", children: [_jsx(Plus, { size: 16, strokeWidth: 1.75 }), t.addToList] }), quickActions.map((a, i) => (_jsx("button", { type: "button", className: "crisp-recordpage-quickbtn", "aria-label": a.label, onClick: a.onSelect, children: _jsx(a.icon, { size: 15, strokeWidth: 1.75 }) }, i)))] }), _jsx(RecordDetails, { record: record, columns: columns, sections: sections, onEdit: onEdit, labels: labels }), _jsxs("div", { className: "crisp-recordpage-section", children: [_jsx(SectionHead, { title: t.tabs.lists, action: _jsxs("button", { type: "button", className: "crisp-recordpage-addbtn", children: [_jsx(Plus, { size: 14, strokeWidth: 2 }), t.addToList] }) }), _jsx("div", { className: "crisp-recordpage-emptystate", children: t.noLists })] })] }), _jsx("div", { className: "crisp-recordpage-resizer", "data-active": resizing ? "" : undefined, role: "separator", "aria-orientation": "vertical", "aria-label": t.resizeDetails, "aria-valuenow": Math.round(detailsW), "aria-valuemin": DETAILS_MIN_W, "aria-valuemax": DETAILS_MAX_W, tabIndex: 0, onPointerDown: onResizeDown, onPointerMove: onResizeMove, onPointerUp: onResizeUp, onKeyDown: (e) => {
653
694
  if (e.key !== "ArrowLeft" && e.key !== "ArrowRight")
654
695
  return;
655
696
  // The pointer drag learned its direction in #212; this did not.
@@ -661,6 +702,6 @@ export function RecordPage({ record, columns, collection = "Records", labels, on
661
702
  setDetailsW((w) => grow
662
703
  ? Math.min(DETAILS_MAX_W, w + 16)
663
704
  : Math.max(DETAILS_MIN_W, w - 16));
664
- } }), _jsx("main", { className: "crisp-recordpage-main", children: _jsx(Tabs, { tabs: effectiveTabs, defaultValue: tabsProp ? defaultTab : "overview", "aria-label": `${name} record`, children: effectivePanel }) })] })] }));
705
+ } }), _jsx("main", { className: "crisp-recordpage-main", children: _jsx(Tabs, { tabs: effectiveTabs, defaultValue: tabsProp ? defaultTab : "overview", "aria-label": `${name} record`, children: effectivePanel }) })] }), _jsx(Drawer, { open: commentsOpen, onOpenChange: setCommentsOpen, "aria-label": t.comments, style: { width: 320 }, children: _jsx(CommentsPanel, { threads: comments, currentUserId: me.id, onPost: postComment, onClose: () => setCommentsOpen(false), role: undefined }) })] }));
665
706
  }
666
707
  RecordPage.displayName = "RecordPage";
@@ -5,9 +5,14 @@ export const palette = {
5
5
  // on a bright tone fill. Same value as gray900's light side (the measured
6
6
  // near-black), named separately because its job is not "primary foreground".
7
7
  ink: { light: "#101112", dark: "#101112" },
8
- gray50: { light: "#f4f4f5", dark: "#101112" },
8
+ // seed-identity migration (your-moon/crisp#890): seed's bg.layer-default-
9
+ // pressed / gray-100. Light side only — dark is untouched, unmeasured
10
+ // Attio territory this migration doesn't scope into.
11
+ gray50: { light: "#f7f8f9", dark: "#101112" },
9
12
  gray900: { light: "#101112", dark: "#f5f5f5" },
10
- line200: { light: "#e4e4e7", dark: "#212224" },
13
+ // seed-identity migration (your-moon/crisp#890): seed's stroke.neutral-
14
+ // weak / gray-400. Light side only, same scoping as `gray50` above.
15
+ line200: { light: "#dcdee3", dark: "#212224" },
11
16
  line100: { light: "#eeeff1", dark: "#1c1d1f" },
12
17
  // ImportDialog's dropzone border — measured live from Attio's Import CSV
13
18
  // flow (`rgb(230,231,234)`), distinct from `line200`/`line100` (#e4e4e7 /
@@ -19,8 +24,11 @@ export const palette = {
19
24
  line150: { light: "#e6e7ea", dark: "#212224" },
20
25
  // The filter chip's field/value segments while the condition is
21
26
  // incomplete (no value typed yet) — measured live on Attio's own filter
22
- // chip (`rgb(246,247,247)`), lighter than `gray50` (#f4f4f5, close but not
23
- // equal). The chip's operator/kebab segments stay `gray00` in the same
27
+ // chip (`rgb(246,247,247)`), close to but not equal to `gray50`
28
+ // (`#f7f8f9` since the seed-identity migration, your-moon/crisp#890 — the
29
+ // two used to differ the other direction, gray25 lighter; the relative
30
+ // order isn't the point, staying off `gray50` while remaining a
31
+ // near-neighbour is). The chip's operator/kebab segments stay `gray00` in the same
24
32
  // state; only the two text-content segments carry this muted fill. Dark
25
33
  // value not independently re-measured (Attio's dark theme is off-limits to
26
34
  // toggle) — reuses `gray50`'s dark value, the nearest existing "muted
@@ -81,25 +89,54 @@ export const palette = {
81
89
  shadowPopover2: { light: "rgba(28,40,64,0.12)", dark: "rgba(0,0,0,0.24)" },
82
90
  shadowPopover3: { light: "rgba(24,41,75,0.16)", dark: "rgba(0,0,0,0.32)" },
83
91
  gray400: { light: "#71717a", dark: "#9a9aa2" },
84
- brand600: { light: "#266df0", dark: "#266df0" },
85
- brand700: { light: "#1f5fd6", dark: "#1f5fd6" },
86
- red600: { light: "#e5484d", dark: "#ff5a5f" },
87
- // A red dark enough to carry white text. red600 cannot: white on it is
88
- // 3.91:1 light / 3.05:1 dark, and the solid button's hover DARKENS the fill
89
- // (filter: brightness), so dark ink fails there instead (4.49 at hover).
90
- // Measured from the reference stylesheet, which ships this red alongside the
91
- // #EB3B3B it uses for destructive menu TEXT. Does not flip: a filled
92
- // destructive control needs the same white-bearing fill in both themes.
93
- red700: { light: "#ba2525", dark: "#ba2525" },
94
- // The #EB3B3B named in red700's own comment above (destructive menu text) —
95
- // it had never actually been given a token. Independently re-measured live
96
- // on `app.attio.com/.../tasks`, `rgb(235,59,59)`, as the overdue due-date
97
- // text colour on the Tasks page (crisp#462) — same hex, second surface.
98
- // Dark not independently re-measured (Attio's theme toggle is off-limits,
99
- // same restriction noted throughout this file); reuses red600's dark value,
100
- // the same "brightens for legibility on a dark surface" tone this red
101
- // already sits next to, rather than inventing a third red.
102
- red500: { light: "#eb3b3b", dark: "#ff5a5f" },
92
+ // seed-identity migration (your-moon/crisp#890, "Attio structure, seed
93
+ // look"): the owner's TechPartners primary (#0298D9, sourced from the
94
+ // official logo SVG + the live site's own compiled CSS — see
95
+ // ~/grc-parity/seed-identity-spec.md §1) replaces the old measured-from-
96
+ // Attio brand blue, built as seed's own carrot-scale RELATIVE lightness
97
+ // shape around that exact hex at step 600 ("Method A" in the spec — the
98
+ // verified logo hex stays literally in the scale, at the step that's
99
+ // actually rendered, rather than a mathematically tidier neighbour).
100
+ // Does not flip with theme, matching this token's own prior precedent.
101
+ brand600: { light: "#0298d9", dark: "#0298d9" },
102
+ // Method A's raw carrot-shape output (L 37.1%) — the hover/pressed-darken
103
+ // step of the ACCENT colour above, same role brand700 has always had.
104
+ // Not tuned for any particular contrast target; see `brandContrast` below
105
+ // for the token that carries that job.
106
+ brand700: { light: "#0283bb", dark: "#0283bb" },
107
+ // your-moon/crisp#897: #0298d9 (the verified brand hex, kept exactly as
108
+ // the ACCENT everywhere else — focus rings, selected states, icons,
109
+ // tints) only clears 3.23:1 against white, short of the 4.5:1 a SOLID
110
+ // fill carrying white text needs (Button primary, Badge solid+brand,
111
+ // Avatar's brand tone), or brand-coloured TEXT sitting directly on a white
112
+ // page (TextLink/Text's brand tone). This is that darker, AA-safe step —
113
+ // same hue (198°), L dropped to 34% — clearing 4.88:1 with white. An
114
+ // earlier round of this fix used dark ink text instead of finding this
115
+ // value; that read off-brand (seed's own solid buttons use white) and got
116
+ // reverted in favour of this.
117
+ brandContrast: { light: "#0278ac", dark: "#0278ac" },
118
+ // seed's red-700 (fg.critical) — the accent used for plain danger TEXT
119
+ // (an overdue due-date, a destructive menu label) and for tinting
120
+ // (Chip/Badge's soft-danger `color-mix()` inputs). NOT used for a solid
121
+ // fill paired with white text — see `red700` below for why that split
122
+ // matters here specifically.
123
+ red600: { light: "#fa342c", dark: "#ff5a5f" },
124
+ // seed's red-800 (bg.critical-solid-**pressed**), not red-700
125
+ // (bg.critical-solid-enabled, `red600` above) — deliberately the darker
126
+ // of the two. `red700` is crisp's SOLID destructive fill (Button danger,
127
+ // Badge solid+danger), which pairs with white text and, on Button,
128
+ // DARKENS further on hover/active via a brightness filter (not a second
129
+ // token, per this token's own original comment below this migration note
130
+ // — that invariant is why a dark-enough red matters at all). Seed's own
131
+ // red-700 only clears 3.76:1 with white (measured) — short of AA even
132
+ // before any hover darkening — so reusing crisp's existing "needs to
133
+ // survive getting darker" role means red-800 (5.68:1 at rest, improving
134
+ // through hover/active) is the correct step, not seed's literal
135
+ // "enabled" one. `red500` below is pure text again (no fill/contrast
136
+ // constraint), so it stays on the
137
+ // lighter red-700, matching seed exactly.
138
+ red700: { light: "#ca1d13", dark: "#ba2525" },
139
+ red500: { light: "#fa342c", dark: "#ff5a5f" },
103
140
  // seed-design's field focus ring (stroke-neutral-contrast = gray-1000),
104
141
  // measured from @seed-design/css. A high-contrast neutral rather than a brand
105
142
  // hue: the ring reads as "this field has focus", not as a state colour.
@@ -119,8 +156,25 @@ export const palette = {
119
156
  neutralSubtle: { light: "#868b94", dark: "#b0b3ba" },
120
157
  neutralMuted: { light: "#555d6d", dark: "#dcdee3" },
121
158
  focusRing: { light: "#1e82eb", dark: "#5e98fe" },
122
- green600: { light: "#1f9d57", dark: "#2ea968" },
123
- amber600: { light: "#b5730f", dark: "#e0982a" },
159
+ // seed's fg.positive / bg.positive-solid (green-700).
160
+ green600: { light: "#079171", dark: "#2ea968" },
161
+ // seed's fg.warning / stroke.warning-solid (yellow-700) — the ACCENT used
162
+ // for tinting (Badge/Chip's `soft()`/`ring()` colour-mix formulas), not
163
+ // seed's actual solid-fill colour, which is a much paler yellow at a
164
+ // different scale step — see `amberSolid` below for why the two need to
165
+ // stay separate tokens.
166
+ amber600: { light: "#9b7821", dark: "#e0982a" },
167
+ // seed's bg.warning-solid (yellow-300) — genuinely NOT amber600 tinted
168
+ // lighter: seed's warning-solid is its own pale scale step, distinct from
169
+ // the darker accent every other tone's solid fill derives from (see the
170
+ // spec's Badge section). Badge's `variant="solid" tone="warning"` uses
171
+ // this directly, unmixed, the same way `red700`'s solid destructive fill
172
+ // is used directly rather than computed from a tint. Dark side is NOT
173
+ // independently sourced (this is a new token; no prior Attio measurement
174
+ // exists to preserve) — reuses amber600's own dark value as the nearest
175
+ // existing "warning, dark theme" tone, same convention this file uses
176
+ // throughout for an unmeasured dark side (ringSwitch, line150, gray25).
177
+ amberSolid: { light: "#fbdc65", dark: "#e0982a" },
124
178
  orange600: { light: "#f97514", dark: "#f97514" },
125
179
  cyan600: { light: "#00b9eb", dark: "#00b9eb" },
126
180
  purple600: { light: "#9b69ff", dark: "#9b69ff" },
@@ -26,6 +26,7 @@ export const space = {
26
26
  // Corner radius. sm/md/lg are the three used on stacked surfaces
27
27
  // (inputs, rows, tiles); full is the pill.
28
28
  export const radius = {
29
+ xs: "4px", // seed-design's $radius.r1 — small badge / checkbox box corner
29
30
  chip: "7px", // tag / chip corner — the single most used radius
30
31
  sm: "6px", // chips, badges
31
32
  md: "8px", // inputs, search box
@@ -1,6 +1,17 @@
1
1
  export const semantic = {
2
2
  bgBrandSolid: "brand600",
3
3
  bgBrandSolidPressed: "brand700",
4
+ /** your-moon/crisp#897: the AA-safe darker sibling of `bgBrandSolid` — a
5
+ * SOLID fill carrying white text (Button primary, Badge solid+brand,
6
+ * Avatar's brand tone). `bgBrandSolid` itself stays the exact brand
7
+ * accent (focus rings, selected states, icons, tints), unpaired with
8
+ * white text anywhere that matters for contrast. */
9
+ bgBrandContrast: "brandContrast",
10
+ /** your-moon/crisp#897: brand-coloured TEXT sitting on a white page
11
+ * (TextLink/Text's `brand` tone) — same value as `bgBrandContrast`,
12
+ * named separately because one's a background and one's a foreground,
13
+ * matching every other fg/bg pair in this file. */
14
+ fgBrandContrast: "brandContrast",
4
15
  fgOnBrand: "white",
5
16
  // text on a solid success/warning/danger fill. Those tone tokens BRIGHTEN in
6
17
  // dark (they are tuned for tinted backgrounds and coloured text), so white on
@@ -38,6 +49,15 @@ export const semantic = {
38
49
  shadowPopover2: "shadowPopover2",
39
50
  shadowPopover3: "shadowPopover3",
40
51
  fgPrimary: "gray900",
52
+ // seed-identity migration (your-moon/crisp#890): NOT touched here, on
53
+ // purpose. seed's closest analog (fg.neutral-subtle, #868b94) is lighter
54
+ // than crisp's current #71717a, and Badge's neutral-SOLID variant reuses
55
+ // this exact token as a BACKGROUND paired with white text
56
+ // (`badge.recipe.ts`'s compound variant) — #868b94 drops that pairing to
57
+ // 3.42:1, below AA. Retargeting `fgMuted` and fixing that one background
58
+ // choice belong together in Badge's own upcoming look PR, not split
59
+ // across two PRs where the token change alone would ship a known
60
+ // contrast regression.
41
61
  fgMuted: "gray400",
42
62
  bgDanger: "red600",
43
63
  /** Fill for a SOLID destructive control that carries white text. */
@@ -63,6 +83,10 @@ export const semantic = {
63
83
  strokeFocusRing: "focusRing",
64
84
  bgSuccess: "green600",
65
85
  bgWarning: "amber600",
86
+ /** A SOLID warning fill (Badge's `variant="solid" tone="warning"`) — seed's
87
+ * own warning-solid is a distinct, paler yellow, not `bgWarning` tinted
88
+ * lighter. See `amberSolid` in palette.ts. */
89
+ bgWarningSolid: "amberSolid",
66
90
  bgOrange: "orange600",
67
91
  bgCyan: "cyan600",
68
92
  bgPurple: "purple600",
package/dist/input.css CHANGED
@@ -6,6 +6,13 @@
6
6
  & .crisp-input-value::placeholder { color: var(--crisp-fg-muted); }
7
7
  & .crisp-input-value:disabled { opacity: 0.5; cursor: not-allowed; }
8
8
  &:has(.crisp-input-value:disabled) { opacity: 1; }
9
+ /* crisp#634: measured live on Attio's own Settings > Profile read-only
10
+ primary-email field -- a tinted field, not just muted text, driven off
11
+ the native `readOnly` attribute. `ringField` (rgba(0,0,0,.06)) is an
12
+ exact match for the measured ring; the 2%-fgPrimary tint reuses the
13
+ same color-mix idiom every other soft crisp surface already uses. */
14
+ &[data-readonly] { background: color-mix(in srgb, var(--crisp-fg-primary) 2%, transparent); box-shadow: inset 0 0 0 1px var(--crisp-ring-field); }
15
+ & .crisp-input-suffix { flex-shrink: 0; margin-inline-start: var(--crisp-space-1); }
9
16
  }
10
17
  .crisp-input--size_sm {
11
18
  height: var(--crisp-size-row); border-radius: var(--crisp-radius-md);